From 1b82746dd75bc67d8b13a0bdc904fb66cd11386a Mon Sep 17 00:00:00 2001 From: LukeSpad <64772822+LukeSpad@users.noreply.github.com> Date: Tue, 23 Nov 2021 15:14:40 +0000 Subject: [PATCH] Add files via upload --- Resources/BLHIP_CLIENT.py | 234 +++++++++++++++++++ Resources/CONSTANTS.py | 463 ++++++++++++++++++++++++++++++++++++++ Resources/MLCLI_CLIENT.py | 301 +++++++++++++++++++++++++ Resources/MLGW_CLIENT.py | 362 +++++++++++++++++++++++++++++ Resources/MLtn_CLIENT.py | 353 +++++++++++++++++++++++++++++ 5 files changed, 1713 insertions(+) create mode 100644 Resources/BLHIP_CLIENT.py create mode 100644 Resources/CONSTANTS.py create mode 100644 Resources/MLCLI_CLIENT.py create mode 100644 Resources/MLGW_CLIENT.py create mode 100644 Resources/MLtn_CLIENT.py diff --git a/Resources/BLHIP_CLIENT.py b/Resources/BLHIP_CLIENT.py new file mode 100644 index 0000000..f19077c --- /dev/null +++ b/Resources/BLHIP_CLIENT.py @@ -0,0 +1,234 @@ +import asynchat +import logging +import socket +import time +import json +import urllib +from collections import OrderedDict + +import Resources.CONSTANTS as const + +class BLHIPClient(asynchat.async_chat): + """Client to interact with a Beolink Gateway via the Home Integration Protocol + https://manualzz.com/download/14415327 + Full documentation of states, commands and events can be found in the driver development guide + https://vdocument.in//blgw-driver-development-guide-blgw-driver-development-guide-7-2016-10-10""" + def __init__(self, host_address='blgw.local', port=9100, user='admin', pwd='admin', name='BLGW_HIP', cb=None): + asynchat.async_chat.__init__(self) + self.log = logging.getLogger('Client (%7s)' % name) + self.log.setLevel('INFO') + + self._host = host_address + self._port = int(port) + self._user = user + self._pwd = pwd + self.name = name + self.is_connected = False + + self._received_data = '' + self.last_sent = '' + self.last_sent_at = time.time() + self.last_received = '' + self.last_received_at = time.time() + self.last_message = {} + + #Optional callback function + if cb: + self.messageCallBack = cb + else: + self.messageCallBack = None + + # ######################################################################################## + # ##### Open Socket and connect to B&O Gateway + self.client_connect() + + # ######################################################################################## + # ##### Client functions + def collect_incoming_data(self, data): + self.is_connected = True + self.log.debug(data) + self._received_data += data + + def found_terminator(self): + self.last_received = self._received_data + self.last_received_at = time.time() + self.log.debug(self._received_data) + + if self._received_data == 'error': + self.handle_close() + + if self._received_data == 'e OK f%20%2A/%2A/%2A/%2A': + self.log.info('\tAuthentication Successful!') + self.query(dev_type="AV renderer") + + telegram = urllib.unquote(self._received_data) + telegram = telegram.split('/') + header = telegram[0:4] + self._received_data = "" + + self._decode(header, telegram) + + def _decode(self, header, telegram): + e_string = str(header[0]) + if e_string[0] == 'e': + if e_string[2:4] == 'OK': + self.log.info('Command Successfully Processed: ' + urllib.unquote(self._received_data)) + elif e_string[2:5] == 'CMD': + self.log.info('Wrong or Unrecognised Command: ' + urllib.unquote(self._received_data)) + return + elif e_string[2:5] == 'SYN': + self.log.info('Bad Syntax, or Wrong Character Encoding: ' + urllib.unquote(self._received_data)) + return + elif e_string[2:5] == 'ACC': + self.log.info('Zone Access Violation: ' + urllib.unquote(self._received_data)) + return + elif e_string[2:5] == 'LEN': + self.log.info('Received Message Too Long: ' + urllib.unquote(self._received_data)) + return + + if len(telegram) > 4: + state = telegram[4].replace('?','&') + state = state.split('&')[1:] + + message = OrderedDict() + message['Zone'] = telegram[0][2:].upper() + message['Room'] = telegram[1].upper() + message['Type'] = telegram[2].upper() + message['Device'] = telegram[3] + message['State_Update'] = OrderedDict() + + for s in state: + if s.split('=')[0] == "nowPlayingDetails": + playDetails = s.split('=') + if len(playDetails[1]) >0: + playDetails = playDetails[1].split('; ') + message['State_Update']["nowPlayingDetails"] = OrderedDict() + for p in playDetails: + if p.split(': ')[0] in ['track number','channel number']: + message['State_Update']["nowPlayingDetails"]['channel_track'] = p.split(': ')[1] + else: + message['State_Update']["nowPlayingDetails"][p.split(': ')[0]] = p.split(': ')[1] + + elif s.split('=')[0] == "sourceUniqueId": + src = s.split('=')[1].split(':')[0].upper() + message['State_Update']['source'] = self._srcdictsanitize(const._blgw_srcdict, src) + message['State_Update'][s.split('=')[0]] = s.split('=')[1] + else: + message['State_Update'][s.split('=')[0]] = s.split('=')[1] + + if message.get('Type') == 'BUTTON': + if message['State_Update'].get('STATE') == '0': + message['State_Update']['Status'] = 'Off' + else: + message['State_Update']['Status'] = 'On' + + if message.get('Type') == 'DIMMER': + if message['State_Update'].get('LEVEL') == '0': + message['State_Update']['Status'] = 'Off' + else: + message['State_Update']['Status'] = 'On' + + self._report(header, state, message) + + def _report(self, header, payload, message): + #Report messages, excluding regular clock pings from gateway + self.last_message = message + if message.get('Device').upper() != 'CLOCK': + self.log.debug(self.name + "\n" + str(json.dumps(message, indent=4))) + if self.messageCallBack: + self.messageCallBack(self.name, str(list(header)), str(list(payload)), message) + + def client_connect(self): + self.log.info('Connecting to host at %s, port %i', self._host, self._port) + self.set_terminator(b'\r\n') + #Create the socket + try: + socket.setdefaulttimeout(3) + self.create_socket(socket.AF_INET, socket.SOCK_STREAM) + except socket.error, e: + self.log.info("Error creating socket: %s" % e) + self.handle_close() + #Now connect + try: + self.connect((self._host, self._port)) + except socket.gaierror, e: + self.log.info("\tError with address %s:%i - %s" % (self._host, self._port, e)) + self.handle_close() + except socket.error, e: + self.log.info("\tError opening connection to %s:%i - %s" % (self._host, self._port, e)) + self.handle_close() + except socket.timeout, e: + self.log.info("\tSocket connection to %s:%i timed out- %s" % (self._host, self._port, e)) + self.handle_close() + else: + self.is_connected = True + self.log.info("\tConnected to B&O Gateway") + + def handle_connect(self): + self.log.info("\tAttempting to Authenticate...") + self.send_cmd(self._user) + self.send_cmd(self._pwd) + self.statefiler() + + def handle_close(self): + self.log.info(self.name + ": Closing socket") + self.is_connected = False + self.close() + + def send_cmd(self,telegram): + try: + self.push(telegram.encode("ascii") + "\r\n") + except socket.error, e: + self.log.info("Error sending data: %s" % e) + self.handle_close() + except socket.timeout, e: + self.log.info("\tSocket connection to %s:%i timed out- %s" % (self._host, self._port, e)) + self.handle_close() + else: + self.last_sent = telegram + self.last_sent_at = time.time() + self.log.info(self.name + " >>-SENT--> : " + telegram) + time.sleep(0.2) + + def query(self, zone='*',room='*',dev_type='*',device='*'): + query = "q " + zone + "/" + room + "/" + dev_type + '/' + device + + #Convert to human readable string + if zone == '*': + zone = ' in all zones.' + else: + zone = ' in zone ' + zone + '.' + if room == '*': + room = ' in all rooms' + else: + room = ' in room ' + room + if dev_type == '*': + dev_type = ' of all types' + else: + dev_type = ' of type ' + dev_type + if device == '*': + device = ' all devices' + else: + device = ' devices called ' + device + + self.log.info(self.name + ": sending state update request for" + device + dev_type + room + zone) + self.send_cmd(query) + + def statefiler(self, zone='*',room='*',dev_type='*',device='*'): + s_filter = "f " + zone + "/" + room + "/" + dev_type + '/' + device + self.send_cmd(s_filter) + + def locationevent(self, event): + if event in ['leave', 'arrive']: + event = 'l ' + event + self.send_cmd(event) + + def ping(self): + self.query('Main', 'global', 'SYSTEM', 'BeoLink') + + def _srcdictsanitize(self, d, s): + result = d.get(s) + if result == None: + result = s + return str(result) + diff --git a/Resources/CONSTANTS.py b/Resources/CONSTANTS.py new file mode 100644 index 0000000..2392432 --- /dev/null +++ b/Resources/CONSTANTS.py @@ -0,0 +1,463 @@ +# Constants for B&O telegram protocols +# ######################################################################################## +### Config data (set on initialisation) +gateway = dict() +rooms = [] +devices = [] +available_sources = [] + +# ######################################################################################## +### Beo4 Commands +beo4_commanddict = dict( + [ + # Source selection: + (0x0C, "Standby"), + (0x47, "Sleep"), + (0x80, "TV"), + (0x81, "Radio"), + (0x82, "V.Aux/DTV2"), + (0x83, "A.Aux"), + (0x84, "Media"), + (0x85, "V.Tape/V.Mem/DVD2"), + (0x86, "DVD"), + (0x87, "Camera"), + (0x88, "Text"), + (0x8A, "Sat/DTV"), + (0x8B, "PC"), + (0x8C, "Web"), + (0x8D, "Doorcam"), + (0x8E, "Photo"), + (0x90, "USB2"), + (0x91, "A.Tape/A.Mem"), + (0x92, "CD"), + (0x93, "Phono/N.Radio"), + (0x94, "A.Tape2/N.Music"), + (0x95, "Server"), + (0x96, "Spotify"), + (0x97, "CD2/Join"), + (0xBF, "AV"), + (0xFA, "P-IN-P"), + # Digits: + (0x00, "Digit-0"), + (0x01, "Digit-1"), + (0x02, "Digit-2"), + (0x03, "Digit-3"), + (0x04, "Digit-4"), + (0x05, "Digit-5"), + (0x06, "Digit-6"), + (0x07, "Digit-7"), + (0x08, "Digit-8"), + (0x09, "Digit-9"), + # Source control: + (0x1E, "Step Up"), + (0x1F, "Step Down"), + (0x32, "Rewind"), + (0x33, "Return"), + (0x34, "Wind"), + (0x35, "Go/Play"), + (0x36, "Stop"), + (0xD4, "Yellow"), + (0xD5, "Green"), + (0xD8, "Blue"), + (0xD9, "Red"), + # Sound and picture control + (0x0D, "Mute"), + (0x1C, "P.Mute"), + (0x2A, "Format"), + (0x44, "Sound/Speaker"), + (0x5C, "Menu"), + (0x60, "Volume Up"), + (0x64, "Volume Down"), + (0xDA, "Cinema_On"), + (0xDB, "Cinema_Off"), + # Other controls: + (0xF7, "Stand"), + (0x0A, "Clear"), + (0x0B, "Store"), + (0x0E, "Reset"), + (0x14, "Back"), + (0x15, "MOTS"), + (0x20, "Goto"), + (0x28, "Show Clock"), + (0x2D, "Eject"), + (0x37, "Record"), + (0x3F, "Select"), + (0x46, "Sound"), + (0x7F, "Exit"), + (0xC0, "Shift-0/Edit"), + (0xC1, "Shift-1/Random"), + (0xC2, "Shift-2"), + (0xC3, "Shift-3/Repeat"), + (0xC4, "Shift-4/Select"), + (0xC5, "Shift-5"), + (0xC6, "Shift-6"), + (0xC7, "Shift-7"), + (0xC8, "Shift-8"), + (0xC9, "Shift-9"), + # Continue functionality: + (0x70, "Rewind Repeat"), + (0x71, "Wind Repeat"), + (0x72, "Step_UP Repeat"), + (0x73, "Step_DW Repeat"), + (0x75, "Go Repeat"), + (0x76, "Green Repeat"), + (0x77, "Yellow Repeat"), + (0x78, "Blue Repeat"), + (0x79, "Red Repeat"), + (0x7E, "Key Release"), + # Functions: + (0x40, "Guide"), + (0x43, "Info"), + # Cursor functions: + (0x13, "Select"), + (0xCA, "Cursor_Up"), + (0xCB, "Cursor_Down"), + (0xCC, "Cursor_Left"), + (0xCD, "Cursor_Right"), + # Light/Control commands + (0x9B, "Light"), + (0x9C, "Command"), + (0x58, "Light Timeout"), + # Dummy for 'Listen for all commands' + (0xFF, ""), + ] +) +BEO4_CMDS = {v.upper(): k for k, v in beo4_commanddict.items()} + +### BeoRemote One Commands +beoremoteone_commanddict = dict( + [ + #Source, (Cmd, Unit) + ("TV", (0x80, 0)), + ("RADIO", (0x81, 0)), + ("TUNEIN", (0x81, 1)), + ("DVB_RADIO", (0x81, 2)), + ("AV.IN", (0x82, 0)), + ("LINE.IN", (0x83, 0)), + ("A.AUX", (0x83, 1)), + ("BLUETOOTH", (0x83, 2)), + ("HOMEMEDIA", (0x84, 0)), + ("DNLA", (0x84, 1)), + ("RECORDINGS", (0x85, 0)), + ("CAMERA", (0x87, 0)), + ("FUTURE.USE", (0x89, 0)), + ("USB", (0x90, 0)), + ("A.MEM", (0x91, 0)), + ("CD", (0x92, 0)), + ("N.RADIO", (0x93, 0)), + ("MUSIC", (0x94, 0)), + ("DNLA-DMR", (0x94, 1)), + ("AIRPLAY", (0x94, 2)), + ("SPOTIFY", (0x96, 0)), + ("DEEZER", (0x96, 1)), + ("QPLAY", (0x96, 2)), + ("JOIN", (0x97, 0)), + ("WEBMEDIA", (0x8C, 0)), + ("YOUTUBE", (0x8C, 1)), + ("HOME.APP", (0x8C, 2)), + ("HDMI_1", (0xCE, 0)), + ("HDMI_2", (0xCE, 1)), + ("HDMI_3", (0xCE, 2)), + ("HDMI_4", (0xCE, 3)), + ("HDMI_5", (0xCE, 4)), + ("HDMI_6", (0xCE, 5)), + ("HDMI_7", (0xCE, 6)), + ("HDMI_8", (0xCE, 7)), + ("MATRIX_1", (0xCF, 0)), + ("MATRIX_2", (0xCF, 1)), + ("MATRIX_3", (0xCF, 2)), + ("MATRIX_4", (0xCF, 3)), + ("MATRIX_5", (0xCF, 4)), + ("MATRIX_6", (0xCF, 5)), + ("MATRIX_7", (0xCF, 6)), + ("MATRIX_8", (0xCF, 7)), + ("MATRIX_9", (0xD0, 0)), + ("MATRIX_10", (0xD0, 1)), + ("MATRIX_11", (0xD0, 2)), + ("MATRIX_12", (0xD0, 3)), + ("MATRIX_13", (0xD0, 4)), + ("MATRIX_14", (0xD0, 5)), + ("MATRIX_15", (0xD0, 6)), + ("MATRIX_16", (0xD0, 7)), + ("PERSONAL_1", (0xD1, 0)), + ("PERSONAL_2", (0xD1, 1)), + ("PERSONAL_3", (0xD1, 2)), + ("PERSONAL_4", (0xD1, 3)), + ("PERSONAL_5", (0xD1, 4)), + ("PERSONAL_6", (0xD1, 5)), + ("PERSONAL_7", (0xD1, 6)), + ("PERSONAL_8", (0xD1, 7)), + ("TV.ON", (0xD2, 0)), + ("MUSIC.ON", (0xD3, 0)), + ("PATTERNPLAY",(0xD3, 1)), + ] +) + +# ######################################################################################## +# Source Activity +_sourceactivitydict = dict( + [ + (0x00, "Unknown"), + (0x01, "Stop"), + (0x02, "Play"), + (0x03, "Wind"), + (0x04, "Rewind"), + (0x05, "Record Lock"), + (0x06, "Standby"), + (0x07, "Load/No Media"), + (0x08, "Still Picture"), + (0x14, "Scan Forward"), + (0x15, "Scan Reverse"), + (0xFF, "None"), + ] +) + +# ######################################################################################## +# ##### MasterLink (not MLGW) Protocol packet constants +_ml_telegram_type_dict = dict( + [ + (0x0A, "COMMAND"), + (0x0B, "REQUEST"), + (0x14, "RESPONSE"), + (0x2C, "INFO"), + (0x5E, "CONFIG"), + ] +) + +_ml_command_type_dict = dict( + [ + (0x04, "MASTER_PRESENT"), + # REQUEST_DISTRIBUTED_SOURCE: seen when a device asks what source is being distributed + # subtypes seen 01:request 04:no source 06:has source (byte 13 is source) + (0x08, "REQUEST_DISTRIBUTED_SOURCE"), + (0x0D, "BEO4_KEY"), + (0x10, "STANDBY"), + (0x11, "RELEASE"), # when a device turns off + (0x20, "MLGW_REMOTE_BEO4"), + # REQUEST_LOCAL_SOURCE: Seen when a device asks what source is playing locally to a device + # subtypes seen 02:request 04:no source 05:secondary source 06:primary source (byte 11 is source) + # byte 10 is bitmask for distribution: 0x01: coaxial cable - 0x02: MasterLink ML_BUS - + # 0x08: local screen + (0x30, "REQUEST_LOCAL_SOURCE"), + (0x3C, "TIMER"), + (0x40, "CLOCK"), + (0x44, "TRACK_INFO"), + # LOCK_MANAGER_COMMAND: Lock to Determine what device issues source commands + # reference: https://tidsskrift.dk/daimipb/article/download/7043/6004/0 + (0x45, "GOTO_SOURCE"), + (0x5C, "LOCK_MANAGER_COMMAND"), + (0x6C, "DISTRIBUTION_REQUEST"), + (0x82, "TRACK_INFO_LONG"), + # Source Status + # byte 10:source - byte 13: 80 when DTV is turned off. 00 when it's on + # byte 18H 17L: source medium - byte 19: channel/track - byte 21:activity + # byte 22: 01: audio source 02: video source ff:undefined - byte 23: picture identifier + (0x87, "STATUS_INFO"), + (0x94, "VIDEO_TRACK_INFO"), + # + # ----------------------------------------------------------------------- + # More packets that we see on the bus, with a guess of the type + # DISPLAY_SOURCE: Message sent with a payload showing the displayed source name. + # subtype 3 has the printable source name starting at byte 10 of the payload + (0x06, "DISPLAY_SOURCE"), + # START_VIDEO_DISTRIBUTION: Sent when a locally playing source starts being distributed on coaxial cable + (0x07, "START_VIDEO_DISTRIBUTION"), + # EXTENDED_SOURCE_INFORMATION: message with 6 subtypes showing information about the source. + # Printable info at byte 14 of the payload + # For Radio: 1: "" 2: Genre 3: Country 4: RDS info 5: Associated beo4 button 6: "Unknown" + # For A.Mem: 1: Genre 2: Album 3: Artist 4: Track name 5: Associated beo4 button 6: "Unknown" + (0x0B, "EXTENDED_SOURCE_INFORMATION"), + (0x96, "PC_PRESENT"), + # PICTURE AND SOUND STATUS + # byte 0: bit 0-1: sound status - bit 2-3: stereo mode (can be 0 in a 5.1 setup) + # byte 1: speaker mode (see below) + # byte 2: audio volume + # byte 3: picture format identifier (see below) + # byte 4: bit 0: screen1 mute - bit 1: screen2 mute - bit 2: screen1 active - + # bit 3: screen2 active - bit 4: cinema mode + (0x98, "PICT_SOUND_STATUS"), + # Unknown commands - seen on power up and initialisation + ######################################################### + # On power up all devices send out a request key telegram. If + # no lock manager is allocated the devices send out a key_lost telegram. The Video Master (or Power + # Master in older implementations) then asserts a NEW_LOCK_MANAGER telegram and assumes responsibility + # for LOCK_MANAGER_COMMAND telegrams until a key transfer occurs. + (0x12, "KEY_LOST"), #? + # Unknown command with payload of length 1. + # bit 0: unknown + # bit 1: unknown + (0xA0, "NEW_LOCK_MANAGER"), #? + # Unknown command with payload of length 2 + # bit 0: unknown + # bit 1: unknown + # bit 2: unknown + ] +) + +_ml_command_type_request_key_subtype_dict = dict( + [ + (0x01, "Request Key"), + (0x02, "Transfer Key"), + (0x03, "Transfer Impossible"), + (0x04, "Key Received"), + (0x05, "Timeout"), + (0xFF, "Undefined"), + ] +) + +_ml_activity_dict = dict( + [ + (0x01, "Request Source"), + (0x02, "Request Source"), + (0x04, "No Source"), + (0x06, "Source Active"), + ] +) + +_ml_device_dict = dict( + [ + (0xC0, "VIDEO_MASTER"), + (0xC1, "AUDIO_MASTER"), + (0xC2, "SOURCE_CENTER"), + (0x81, "ALL_AUDIO_LINK_DEVICES"), + (0x82, "ALL_VIDEO_LINK_DEVICES"), + (0x83, "ALL_LINK_DEVICES"), + (0x80, "ALL"), + (0xF0, "MLGW"), + # Power Master exists in older (pre 1996?) ML implementations. Later revisions enforced the Video Master + # as lock key manager for the system and the concept was phased out. If your system is older than 2000 + # you may see this device type on the network. + (0xFF, "POWER_MASTER"), #? + ] +) + +_ml_pictureformatdict = dict( + [ + (0x00, "Not known"), + (0x01, "Known by decoder"), + (0x02, "4:3"), + (0x03, "16:9"), + (0x04, "4:3 Letterbox middle"), + (0x05, "4:3 Letterbox top"), + (0x06, "4:3 Letterbox bottom"), + (0xFF, "Blank picture"), + ] +) + +_ml_selectedsourcedict = dict( + [ + (0x00, "NONE"), + (0x0B, "TV"), + (0x15, "V.TAPE/V.MEM"), + (0x16, "DVD2"), + (0x1F, "DTV"), + (0x29, "DVD"), + (0x33, "V.AUX"), + (0x3E, "DOORCAM"), + (0x47, "PC"), + (0x6F, "RADIO"), + (0x79, "A.TAPE/A.MEM"), + (0x7A, "A.TAPE2/N.MUSIC"), + (0x8D, "CD"), + (0x97, "A.AUX"), + (0xA1, "PHONO/N.RADIO"), + # Dummy for 'Listen for all sources' + (0xFE, "ALL"), # have also seen 0xFF as "all" + (0xFF, "ALL"), + ] +) + +_ml_trackinfo_subtype_dict = dict([(0x05, "Current Source"),(0x07, "Change Source"),]) + +_ml_selectedsource_type_dict = dict( + [ + ("VIDEO", (0x0B, 0x1F)), + ("VIDEO_PAUSABLE", (0x15, 0x16, 0x29, 0x33)), + ("AUDIO", (0x6F, 0x97)), + ("AUDIO_PAUSABLE", (0x8D, 0x79, 0x7A, 0xA1, 0x8D)), + ("ALL", (0xFE, 0xFF)), + ("OTHER", (0x47, 0x3E)), + ] +) + +# ######################################################################################## +# ##### MLGW Protocol packet constants +_mlgw_payloadtypedict = dict( + [ + (0x01, "Beo4 Command"), + (0x02, "Source Status"), + (0x03, "Picture and Sound Status"), + (0x04, "Light and Control command"), + (0x05, "All standby notification"), + (0x06, "BeoRemote One control command"), + (0x07, "BeoRemote One source selection"), + (0x20, "MLGW virtual button event"), + (0x30, "Login request"), + (0x31, "Login status"), + (0x32, "Change password request"), + (0x33, "Change password response"), + (0x34, "Secure login request"), + (0x36, "Ping"), + (0x37, "Pong"), + (0x38, "Configuration change notification"), + (0x39, "Request Serial Number"), + (0x3A, "Serial Number"), + (0x40, "Location based event"), + ] +) +MLGW_PL = {v.upper(): k for k, v in _mlgw_payloadtypedict.items()} + +_destselectordict = dict( + [ + (0x00, "Video Source"), + (0x01, "Audio Source"), + (0x05, "V.TAPE/V.MEM"), + (0x0F, "All Products"), + (0x1B, "MLGW"), + ] +) +CMDS_DEST = {v.upper(): k for k, v in _destselectordict.items()} + +_mlgw_secsourcedict = dict([(0x00, "V.TAPE/V.MEM"),(0x01, "V.TAPE2/DVD2/V.MEM2"),]) +_mlgw_linkdict = dict([(0x00, "Local/Default Source"),(0x01, "Remote Source/Option 4 Product"),]) + +_mlgw_virtualactiondict = dict([(0x01, "PRESS"), (0x02, "HOLD"), (0x03, "RELEASE")]) + +### for '0x03: Picture and Sound Status' +_mlgw_soundstatusdict = dict([(0x00, "Not muted"), (0x01, "Muted")]) + +_mlgw_speakermodedict = dict( + [ + (0x01, "Center channel"), + (0x02, "2ch stereo"), + (0x03, "Front surround"), + (0x04, "4ch stereo"), + (0x05, "Full surround"), + (0xFD, ""), # Dummy for 'Listen for all modes' + ] +) + +_mlgw_screenmutedict = dict([(0x00, "not muted"), (0x01, "muted")]) +_mlgw_screenactivedict = dict([(0x00, "not active"), (0x01, "active")]) +_mlgw_cinemamodedict = dict([(0x00, "Cinemamode=off"), (0x01, "Cinemamode=on")]) +_mlgw_stereoindicatordict = dict([(0x00, "Mono"), (0x01, "Stereo")]) + +### for '0x04: Light and Control command' +_mlgw_lctypedict = dict([(0x01, "LIGHT"), (0x02, "CONTROL")]) + +### for '0x31: Login Status +_mlgw_loginstatusdict = dict([(0x00, "OK"), (0x01, "FAIL")]) + +# ######################################################################################## +# ##### BeoLink Gateway Protocol packet constants +_blgw_srcdict = dict( + [ + ("TV", "TV"), + ("DVD", "DVD"), + ("RADIO", "RADIO"), + ("TP1", "A.TAPE/A.MEM"), + ("TP2", "A.TAPE2/N.MUSIC"), + ("CD", "CD"), + ("PH", "PHONO/N.RADIO"), + ] +) diff --git a/Resources/MLCLI_CLIENT.py b/Resources/MLCLI_CLIENT.py new file mode 100644 index 0000000..c5a776c --- /dev/null +++ b/Resources/MLCLI_CLIENT.py @@ -0,0 +1,301 @@ +import asynchat +import logging +import socket +import time +import json +from collections import OrderedDict + +import Resources.CONSTANTS as const + +class MLCLIClient(asynchat.async_chat): + """Client to monitor raw packet traffic on the Masterlink network via the undocumented command line interface + of the Bang & Olufsen Gateway.""" + def __init__(self, host_address='blgw.local', port=23, user='admin', pwd='admin', name='ML_CLI', cb=None): + asynchat.async_chat.__init__(self) + self.log = logging.getLogger('Client (%7s)' % name) + self.log.setLevel('INFO') + + self._host = host_address + self._port = int(port) + self._user = user + self._pwd = pwd + self.name = name + self.is_connected = False + + self._i = 0 + self._header_lines = 6 + self._received_data = "" + self.last_sent = '' + self.last_sent_at = time.time() + self.last_received = '' + self.last_received_at = time.time() + self.last_message = {} + + self.isBLGW = False + + # Optional callback function + if cb: + self.messageCallBack = cb + else: + self.messageCallBack = None + + # ######################################################################################## + # ##### Open Socket and connect to B&O Gateway + self.client_connect() + + # ######################################################################################## + # ##### Client functions + def collect_incoming_data(self, data): + self.log.debug(data) + self._received_data += data + + def found_terminator(self): + self.last_received = self._received_data + self.last_received_at = time.time() + self.log.debug(self._received_data) + + telegram = self._received_data + self._received_data = "" + + #clear login process lines before processing telegrams + if self._i <= self._header_lines: + self._i += 1 + if self._i == self._header_lines - 1: + self.log.info("\tAuthenticated! Gateway type is " + telegram[0:4] + "\n") + if telegram[0:4] != "MLGW": + self.isBLGW = True + + #Process telegrams and return json data in human readable format + if self._i > self._header_lines: + items = telegram.split()[1:] + if len(items): + telegram=bytearray() + for item in items: + try: + telegram.append(int(item[:-1],base=16)) + except: + #abort if invalid character found + break + + #Decode any telegram with a valid 9 byte header, excluding typy 0x14 (regular clock sync pings) + if len(telegram) >= 9 and telegram[7] != 0x14: + #Header: To_Device/From_Device/1/Type/To_Source/From_Source/0/Payload_Type/Length + header = telegram[:9] + payload = telegram[9:] + message = self._decode(telegram) + self._report(header, payload, message) + + def client_connect(self): + self.log.info('Connecting to host at %s, port %i', self._host, self._port) + self.set_terminator(b'\r\n') + # Create the socket + try: + socket.setdefaulttimeout(3) + self.create_socket(socket.AF_INET, socket.SOCK_STREAM) + except socket.error, e: + self.log.info("Error creating socket: %s" % e) + self.handle_close() + # Now connect + try: + self.connect((self._host, self._port)) + except socket.gaierror, e: + self.log.info("\tError with address %s:%i - %s" % (self._host, self._port, e)) + self.handle_close() + except socket.error, e: + self.log.info("\tError opening connection to %s:%i - %s" % (self._host, self._port, e)) + self.handle_close() + except socket.timeout, e: + self.log.info("\tSocket connection to %s:%i timed out- %s" % (self._host, self._port, e)) + self.handle_close() + else: + self.is_connected = True + self.log.info("\tConnected to B&O Gateway") + + def handle_connect(self): + self.log.info("\tAttempting to Authenticate...") + self.send_cmd(self._pwd) + self.send_cmd("_MLLOG ONLINE") + + def handle_close(self): + self.log.info(self.name + ": Closing socket") + self.is_connected = False + self.close() + + def send_cmd(self,telegram): + try: + self.push(telegram + "\r\n") + except socket.error, e: + self.log.info("Error sending data: %s" % e) + self.handle_close() + except socket.timeout, e: + self.log.info("\tSocket connection to %s:%i timed out- %s" % (self._host, self._port, e)) + self.handle_close() + else: + self.last_sent = telegram + self.last_sent_at = time.time() + self.log.info(self.name + " >>-SENT--> : " + telegram) + time.sleep(0.2) + + def _report(self, header, payload, message): + # Report messages, excluding regular clock pings from gateway + self.last_message = message + self.log.debug(self.name + "\n" + str(json.dumps(message, indent=4))) + if self.messageCallBack: + self.messageCallBack(self.name, str(list(header)), str(list(payload)), message) + + def ping(self): + self.log.info(self.name + " >>-SENT--> : Ping") + self.push('\n') + + # ######################################################################################## + # ##### Utility functions + def _hexbyte(self, byte): + resultstr = hex(byte) + if byte < 16: + resultstr = resultstr[:2] + "0" + resultstr[2] + return resultstr + + def _hexword(self, byte1, byte2): + resultstr = self._hexbyte(byte2) + resultstr = self._hexbyte(byte1) + resultstr[2:] + return resultstr + + def _dictsanitize(self, d, s): + result = d.get(s) + if result == None: + result = self._hexbyte(s) + self.log.debug("UNKNOWN (type=" + result + ")") + return str(result) + + def _get_type(self, d, s): + rev_dict = {value: key for key, value in d.items()} + for i in range(len(list(rev_dict))): + if s in list(rev_dict)[i]: + return rev_dict.get(list(rev_dict)[i]) + + # ######################################################################################## + # ##### Decode Masterlink Protocol packet to a serializable dict + + def _decode(self, telegram): + # Decode header + message = OrderedDict() + if const.devices: + for device in const.devices: + if device['ML_ID'] == telegram[1]: + message["Zone"] = device["Zone"].upper() + message["Room"] = device["Room"].uppr() + message["Type"] = "AV RENDERER" + message["Device"] = device["Device"] + message["from_device"] = self._dictsanitize(const._ml_device_dict, telegram[1]) + message["from_source"] = self._dictsanitize(const._ml_selectedsourcedict, telegram[5]) + message["to_device"] = self._dictsanitize(const._ml_device_dict, telegram[0]) + message["to_source"] = self._dictsanitize(const._ml_selectedsourcedict, telegram[4]) + message["type"] = self._dictsanitize(const._ml_telegram_type_dict, telegram[3]) + message["payload_type"] = self._dictsanitize(const._ml_command_type_dict, telegram[7]) + message["payload_len"] = telegram[8] + 1 + message["payload"] = OrderedDict() + + # source status info + # TTFF__TYDSOS__PTLLPS SR____LS______SLSHTR__ACSTPI________________________TRTR______ + if message.get("payload_type") == "STATUS_INFO": + message["payload"]["source"] = self._dictsanitize(const._ml_selectedsourcedict, telegram[10]) + message["payload"]["sourceID"] = telegram[10] + message["payload"]["source_type"] = telegram[22] + message["payload"]["local_source"] = telegram[13] + message["payload"]["source_medium"] = self._hexword(telegram[18], telegram[17]) + message["payload"]["channel_track"] = ( + telegram[19] if telegram[8] < 27 else (telegram[36] * 256 + telegram[37]) + ) + message["payload"]["picture_identifier"] = self._dictsanitize(const._ml_pictureformatdict, telegram[23]) + message["payload"]["state"] = self._dictsanitize(const._sourceactivitydict, telegram[21]) + + # display source information + if message.get("payload_type") == "DISPLAY_SOURCE": + _s = "" + for i in range(0, telegram[8] - 5): + _s = _s + chr(telegram[i + 15]) + message["payload"]["display_source"] = _s.rstrip() + + # extended source information + if message.get("payload_type") == "EXTENDED_SOURCE_INFORMATION": + message["payload"]["info_type"] = telegram[10] + _s = "" + for i in range(0, telegram[8] - 14): + _s = _s + chr(telegram[i + 24]) + message["payload"]["info_value"] = _s + + # beo4 command + if message.get("payload_type") == "BEO4_KEY": + message["payload"]["source"] = self._dictsanitize(const._ml_selectedsourcedict, telegram[10]) + message["payload"]["sourceID"] = telegram[10] + message["payload"]["source_type"] = self._get_type(const._ml_selectedsource_type_dict, telegram[10]) + message["payload"]["command"] = self._dictsanitize(const.beo4_commanddict, telegram[11]) + + # audio track info long + if message.get("payload_type") == "TRACK_INFO_LONG": + message["payload"]["source"] = self._dictsanitize(const._ml_selectedsourcedict, telegram[11]) + message["payload"]["sourceID"] = telegram[11] + message["payload"]["source_type"] = self._get_type(const._ml_selectedsource_type_dict, telegram[11]) + message["payload"]["channel_track"] = telegram[12] + message["payload"]["state"] = self._dictsanitize(const._sourceactivitydict, telegram[13]) + + # video track info + if message.get("payload_type") == "VIDEO_TRACK_INFO": + message["payload"]["source"] = self._dictsanitize(const._ml_selectedsourcedict, telegram[13]) + message["payload"]["sourceID"] = telegram[13] + message["payload"]["source_type"] = self._get_type(const._ml_selectedsource_type_dict, telegram[13]) + message["payload"]["channel_track"] = telegram[11] * 256 + telegram[12] + message["payload"]["state"] = self._dictsanitize(const._sourceactivitydict, telegram[14]) + + # track change info + if message.get("payload_type") == "TRACK_INFO": + message["payload"]["subtype"] = self._dictsanitize(const._ml_trackinfo_subtype_dict, telegram[9]) + if message["payload"].get("subtype") == "Change Source": + message["payload"]["prev_source"] = self._dictsanitize(const._ml_selectedsourcedict, telegram[11]) + message["payload"]["prev_sourceID"] = telegram[11] + message["payload"]["prev_source_type"] = self._get_type( + const._ml_selectedsource_type_dict, telegram[11]) + if len(telegram) > 18: + message["payload"]["source"] = self._dictsanitize(const._ml_selectedsourcedict, telegram[22]) + message["payload"]["sourceID"] = telegram[22] + if message["payload"].get("subtype") == "Current Source": + message["payload"]["source"] = self._dictsanitize(const._ml_selectedsourcedict, telegram[11]) + message["payload"]["sourceID"] = telegram[11] + message["payload"]["source_type"] = self._get_type(const._ml_selectedsource_type_dict, telegram[11]) + else: + message["payload"]["subtype"] = "Undefined: " + self._hexbyte(telegram[9]) + + # goto source + if message.get("payload_type") == "GOTO_SOURCE": + message["payload"]["source"] = self._dictsanitize(const._ml_selectedsourcedict, telegram[11]) + message["payload"]["sourceID"] = telegram[11] + message["payload"]["source_type"] = self._get_type(const._ml_selectedsource_type_dict, telegram[11]) + message["payload"]["channel_track"] = telegram[12] + + # remote request + if message.get("payload_type") == "MLGW_REMOTE_BEO4": + message["payload"]["command"] = self._dictsanitize(const.beo4_commanddict, telegram[14]) + message["payload"]["destination"] = self._dictsanitize(const._destselectordict, telegram[11]) + + # request_key + if message.get("payload_type") == "LOCK_MANAGER_COMMAND": + message["payload"]["subtype"] = self._dictsanitize( + const._ml_command_type_request_key_subtype_dict, telegram[9]) + + # request distributed audio source + if message.get("payload_type") == "REQUEST_DISTRIBUTED_SOURCE": + message["payload"]["subtype"] = self._dictsanitize(const._ml_activity_dict, telegram[9]) + if message["payload"].get('subtype') == "Source Active": + message["payload"]["source"] = self._dictsanitize(const._ml_selectedsourcedict, telegram[13]) + message["payload"]["sourceID"] = telegram[13] + message["payload"]["source_type"] = self._get_type(const._ml_selectedsource_type_dict, telegram[13]) + + # request local audio source + if message.get("payload_type") == "REQUEST_LOCAL_SOURCE": + message["payload"]["subtype"] = self._dictsanitize(const._ml_activity_dict, telegram[9]) + if message["payload"].get('subtype') == "Source Active": + message["payload"]["source"] = self._dictsanitize(const._ml_selectedsourcedict, telegram[11]) + message["payload"]["sourceID"] = telegram[11] + message["payload"]["source_type"] = self._get_type(const._ml_selectedsource_type_dict, telegram[11]) + + return message \ No newline at end of file diff --git a/Resources/MLGW_CLIENT.py b/Resources/MLGW_CLIENT.py new file mode 100644 index 0000000..feede96 --- /dev/null +++ b/Resources/MLGW_CLIENT.py @@ -0,0 +1,362 @@ +import asynchat +import logging +import socket +import time +import json +from collections import OrderedDict + +import Resources.CONSTANTS as const + +class MLGWClient(asynchat.async_chat): + """Client to interact with a B&O Gateway via the MasterLink Gateway Protocol + http://mlgw.bang-olufsen.dk/source/documents/mlgw_2.24b/MlgwProto0240.pdf .""" + + def __init__(self, host_address='blgw.local', port=9000, user='admin', pwd='admin', name='MLGW_Protocol', cb=None): + asynchat.async_chat.__init__(self) + self.log = logging.getLogger('Client (%7s)' % name) + self.log.setLevel('INFO') + + self._host = host_address + self._port = int(port) + self._user = user + self._pwd = pwd + self.name = name + self.is_connected = False + + self._received_data = bytearray() + self.last_sent = '' + self.last_sent_at = time.time() + self.last_received = '' + self.last_received_at = time.time() + self.last_message = {} + + # Optional callback function + if cb: + self.messageCallBack = cb + else: + self.messageCallBack = None + + #Expose dictionaries via API + self.BEO4_CMDS = const.BEO4_CMDS + self.CMDS_DEST = const.CMDS_DEST + self.MLGW_PL = const.MLGW_PL + + # ######################################################################################## + # ##### Open Socket and connect to B&O Gateway + self.client_connect() + + # ######################################################################################## + # ##### Client functions + def collect_incoming_data(self, data): + self.is_connected = True + self.log.debug(data) + self._received_data = bytearray(data) + + bit1 = int(self._received_data[0]) #Start of Header == 1 + bit2 = int(self._received_data[1]) #Message Type + bit3 = int(self._received_data[2]) #Payload length + bit4 = int(self._received_data[3]) #Spare Bit/End of Header == 0 + + payload = bytearray() + for item in self._received_data[4:bit3 + 4]: + payload.append(item) + + if bit1 == 1 and len(self._received_data) == bit3 + 4 and bit4 == 0: + self.found_terminator(bit2, payload) + else: + self.log.info("Incomplete Telegram Received: " + str(list(self._received_data)) + " - Ignoring!\n") + + def found_terminator(self, msg_type, payload): + self.last_received = str(list(self._received_data)) + self.last_received_at = time.time() + self.log.debug(self._received_data) + + header = self._received_data[0:4] + self._received_data = "" + self._decode(msg_type, header, payload) + + def _decode(self, msg_type, header, payload): + message = OrderedDict() + payload_type = self._dictsanitize(const._mlgw_payloadtypedict, msg_type) + message["Payload_type"] = payload_type + + if payload_type == "MLGW virtual button event": + virtual_btn = payload[0] + if len(payload) < 1: + virtual_action = self._getvirtualactionstr(0x01) + else: + virtual_action = self._getvirtualactionstr(payload[1]) + + message["button"] = virtual_btn + message["action"] = virtual_action + + elif payload_type == "Login status": + if payload == 0: + self.log.info("\tAuthentication Failed: MLGW protocol Password required for %s", self._host) + self.handle_close() + message['Connected'] = "False" + return + else: + self.log.info("\tLogin successful to %s", self._host) + self.is_connected = True + message['Connected'] = "True" + self.get_serial() + + elif payload_type == "Pong": + self.is_connected = True + message['CONNECTION'] = 'Online' + + elif payload_type == "Serial Number": + sn = '' + for c in payload: + sn += chr(c) + message['Serial_Num'] = sn + + elif payload_type == "Source Status": + if const.rooms and const.devices: + for device in const.devices: + if device['MLN'] == payload[0]: + name = device['Device'] + for room in const.rooms: + if name in room['Products']: + message["Zone"] = room['Zone'].upper() + message["Room"] = room['Room_Name'].upper() + message["Type"] = 'AV RENDERER' + message["Device"] = name + + message["MLN"] = payload[0] + message["Source"] = self._getselectedsourcestr(payload[1]).upper() + message["Source_medium_position"] = self._hexword(payload[2], payload[3]) + message["Source_position"] = self._hexword(payload[4], payload[5]) + message["Picture_format"] = self._getdictstr(const.ml_pictureformatdict, payload[7]) + message["State"] = self._getdictstr(const._sourceactivitydict, payload[6]) + + elif payload_type == "Picture and Sound Status": + if const.rooms and const.devices: + for device in const.devices: + if device['MLN'] == payload[0]: + name = device['Device'] + for room in const.rooms: + if name in room['Products']: + message["Zone"] = room['Zone'].upper() + message["Room"] = room['Room_Name'].upper() + message["Type"] = 'AV RENDERER' + message["Device"] = name + + message["MLN"] = payload[0] + message["Sound_status"] = self._getdictstr(const.mlgw_soundstatusdict, payload[1]) + message["Speaker_mode"] = self._getdictstr(const._mlgw_speakermodedict, payload[2]) + message["Stereo_mode"] = self._getdictstr(const._mlgw_stereoindicatordict, payload[9]) + message["Volume"] = int(payload[3]) + message["Screen1_mute"] = self._getdictstr(const._mlgw_screenmutedict, payload[4]) + message["Screen1_active"] = self._getdictstr(const._mlgw_screenactivedict, payload[5]) + message["Screen2_mute"] = self._getdictstr(const._mlgw_screenmutedict, payload[6]) + message["Screen2_active"] = self._getdictstr(const._mlgw_screenactivedict, payload[7]) + message["Cinema_mode"] = self._getdictstr(const._mlgw_cinemamodedict, payload[8]) + + + elif payload_type == "All standby notification": + message["Command"] = "All Standby" + + elif payload_type == "Light and Control command": + if const.rooms: + for room in const.rooms: + if room['Room_Number'] == payload[0]: + message["Zone"] = room['Zone'].upper() + message["Room"] = room['Room_Name'].upper() + message["Type"] = self._getdictstr(const._mlgw_lctypedict, payload[1]).upper() + " COMMAND" + message["Device"] = 'Beo4/BeoRemote One' + message["Room number"] = str(payload[0]) + message["Command"] = self._getbeo4commandstr(payload[2]) + + if message != '': + self._report(header, payload, message) + + def client_connect(self): + self.log.info('Connecting to host at %s, port %i', self._host, self._port) + self.set_terminator(b'\r\n') + # Create the socket + try: + socket.setdefaulttimeout(3) + self.create_socket(socket.AF_INET, socket.SOCK_STREAM) + except socket.error, e: + self.log.info("Error creating socket: %s" % e) + self.handle_close() + # Now connect + try: + self.connect((self._host, self._port)) + except socket.gaierror, e: + self.log.info("\tError with address %s:%i - %s" % (self._host, self._port, e)) + self.handle_close() + except socket.error, e: + self.log.info("\tError opening connection to %s:%i - %s" % (self._host, self._port, e)) + self.handle_close() + except socket.timeout, e: + self.log.info("\tSocket connection to %s:%i timed out- %s" % (self._host, self._port, e)) + self.handle_close() + else: + self.is_connected = True + self.log.info("\tConnected to B&O Gateway") + + def handle_connect(self): + login=[] + for c in self._user: + login.append(c) + login.append(0x00) + for c in self._pwd: + login.append(c) + + self.log.info("\tAttempting to Authenticate...") + self._send_cmd(const.MLGW_PL.get("LOGIN REQUEST"), login) + + def handle_close(self): + self.log.info(self.name + ": Closing socket") + self.is_connected = False + self.close() + + def _report(self, header, payload, message): + self.last_message = message + self.log.debug(self.name + "\n" + str(json.dumps(message, indent=4))) + if self.messageCallBack: + self.messageCallBack(self.name, str(list(header)), str(list(payload)), message) + + # ######################################################################################## + # ##### mlgw send_cmder functions + + ## send_cmd command to mlgw + def _send_cmd(self, msg_type, payload): + #construct header + telegram = [1,msg_type,len(payload),0] + #append payload + for p in payload: + telegram.append(p) + + try: + self.push(str(bytearray(telegram))) + except socket.error, e: + self.log.info("Error sending data: %s" % e) + self.handle_close() + except socket.timeout, e: + self.log.info("\tSocket connection to %s:%i timed out- %s" % (self._host, self._port, e)) + self.handle_close() + else: + self.last_sent = str(bytearray(telegram)) + self.last_sent_at = time.time() + self.log.info( + self.name + " >>-SENT--> " + + self._getpayloadtypestr(msg_type) + + ": " + + str(list(telegram)) + ) + + # Sleep to allow msg to arrive + time.sleep(0.2) + + def ping(self): + self._send_cmd(const.MLGW_PL.get("PING"), "") + + ## Get serial number of mlgw + def get_serial(self): + if self.is_connected: + # Request serial number + self._send_cmd(const.MLGW_PL.get("REQUEST SERIAL NUMBER"), "") + + ## send_cmd Beo4 command to mlgw + def send_beo4_cmd(self, mln, dest, cmd, sec_source=0x00, link=0x00): + payload = [] + payload.append(mln) # byte[0] MLN + payload.append(dest) # byte[1] Dest-Sel (0x00, 0x01, 0x05, 0x0f) + payload.append(cmd) # byte[2] Beo4 Command + payload.append(sec_source) # byte[3] Sec-Source + payload.append(link) # byte[4] Link + self._send_cmd(const.MLGW_PL.get("BEO4 COMMAND"), payload) + + ## send_cmd BeoRemote One command to mlgw + def send_beoremoteone_cmd(self, mln, cmd, network_bit=0x00): + payload = [] + payload.append(mln) # byte[0] MLN + payload.append(cmd) # byte[1] Beo4 Command + payload.append(0x00) # byte[2] AV (needs to be 0) + payload.append(network_bit) # byte[3] Network_bit (0 = local source, 1 = network source) + self._send_cmd(const.MLGW_PL.get("BEOREMOTE ONE CONTROL COMMAND"), payload) + + ## send_cmd BeoRemote One Source Select to mlgw + def send_beoremoteone_select_source(self, mln, cmd, unit, network_bit=0x00): + payload = [] + payload.append(mln) # byte[0] MLN + payload.append(cmd) # byte[1] Beormyone Command + payload.append(unit) # byte[2] Unit + payload.append(0x00) # byte[3] AV (needs to be 0) + payload.append(network_bit) # byte[4] Network_bit (0 = local source, 1 = network source) + self._send_cmd(const.MLGW_PL.get("BEOREMOTE ONE SOURCE SELECTION"), payload) + + ## send_cmd Beo4 commmand and store the source name + def send_beo4_select_source(self, mln, dest, source, sec_source=0x00, link=0x00): + beolink_source = self._dictsanitize(const.beo4_commanddict, source).upper() + self.send_beo4_cmd(mln, dest, source, sec_source, link) + + # ######################################################################################## + # ##### Utility functions + + + def _hexbyte(self, byte): + resultstr = hex(byte) + if byte < 16: + resultstr = resultstr[:2] + "0" + resultstr[2] + return resultstr + + def _hexword(self, byte1, byte2): + resultstr = self._hexbyte(byte2) + resultstr = self._hexbyte(byte1) + resultstr[2:] + return resultstr + + def _dictsanitize(self, d, s): + result = d.get(s) + if result == None: + result = "UNKNOWN (type=" + self._hexbyte(s) + ")" + return str(result) + + # ######################################################################################## + # ##### Decode MLGW Protocol packet to readable string + + ## Get message string for mlgw packet's payload type + # + def _getpayloadtypestr(self, payloadtype): + result = const._mlgw_payloadtypedict.get(payloadtype) + if result == None: + result = "UNKNOWN (type=" + self._hexbyte(payloadtype) + ")" + return str(result) + + def _getmlnstr(self, mln): + result = "MLN=" + str(mln) + return result + + def _getbeo4commandstr(self, command): + result = const.beo4_commanddict.get(command) + if result == None: + result = "Cmd=" + self._hexbyte(command) + return result + + def _getvirtualactionstr(self, action): + result = const._mlgw_virtualactiondict.get(action) + if result == None: + result = "Action=" + self._hexbyte(action) + return result + + def _getselectedsourcestr(self, source): + result = const.ml_selectedsourcedict.get(source) + if result == None: + result = "Src=" + self._hexbyte(source) + return result + + def _getspeakermodestr(self, source): + result = const._mlgw_speakermodedict.get(source) + if result == None: + result = "mode=" + self._hexbyte(source) + return result + + def _getdictstr(self, mydict, mykey): + result = mydict.get(mykey) + if result == None: + result = self._hexbyte(mykey) + return result \ No newline at end of file diff --git a/Resources/MLtn_CLIENT.py b/Resources/MLtn_CLIENT.py new file mode 100644 index 0000000..8972a60 --- /dev/null +++ b/Resources/MLtn_CLIENT.py @@ -0,0 +1,353 @@ +import asynchat +import logging +import socket +import time +import json +from collections import OrderedDict + +import Resources.CONSTANTS as const + +class MLtnClient(asynchat.async_chat): + """Client to monitor network activity on a Masterlink Gateway via the telnet monitor""" + def __init__(self, host_address='mlgw.local', port=23, user='admin', pwd='admin', name='MLGW_HIP', cb=None): + asynchat.async_chat.__init__(self) + self.log = logging.getLogger('Client (%7s)' % name) + self.log.setLevel('INFO') + + self._host = host_address + self._port = int(port) + self._user = user + self._pwd = pwd + self.name = name + self.is_connected = False + + self._i = 0 + self._header_lines = 4 + self._received_data = '' + self.last_sent = '' + self.last_sent_at = time.time() + self.last_received = '' + self.last_received_at = time.time() + self.last_message = {} + + self.isBLGW = False + + # Optional callback function + if cb: + self.messageCallBack = cb + else: + self.messageCallBack = None + + # ######################################################################################## + # ##### Open Socket and connect to B&O Gateway + self.client_connect() + + # ######################################################################################## + # ##### Client functions + def collect_incoming_data(self, data): + self.log.debug(data) + self._received_data += data + + def found_terminator(self): + self.last_received = self._received_data + self.last_received_at = time.time() + self.log.debug(self._received_data) + + items = self._received_data.split(' ') + + if self._i <= self._header_lines: + self._i += 1 + if self._received_data[0:4] != "MLGW": + self.isBLGW = True + if self._i == self._header_lines - 1: + self.log.info("\t" + self._received_data) + if self._received_data == 'incorrect password': + self.handle_close() + + else: + self._decode(items) + + self._received_data = "" + self.last_sent = '' + self.last_sent_at = 0 + self.last_received = '' + self.last_received_at = 0 + + def _decode(self, items): + time_stamp = ''.join([items[1],' at ',items[0]]) + header = items[3][:-1] + telegramStarts = len(''.join(items[:4])) + 4 + telegram = self._received_data[telegramStarts:].replace('!','').split('/') + message = OrderedDict() + + if header == 'integration_protocol': + message = self._decode_ip(telegram, message) + + if header == 'resource_found': + message['State_Update'] = telegram[0] + + if header == 'action_executed': + message = self._decode_action(telegram, message) + + if header == 'command_executed': + message = self._decode_command(telegram, message) + + if header == 'macro_fired': + message['Zone'] = telegram[0].upper() + message['Room'] = telegram[1].upper() + message['Macro_Name'] = telegram[3] + + if header == 'trigger_fired': + message = self._decode_trigger(telegram, message) + + self._report(header, telegram, message) + + def _decode_ip(self, telegram, message): + if ''.join(telegram).split(':')[0] == 'Integration Protocol login': + chars = ''.join(telegram).split(':')[1][2:].split(' ') + message['Payload'] = '' + for c in chars: + if c == '0x0': + message['Payload'] += '0' + else: + message['Payload'] += chr(int(c, base=16)) + + if ''.join(telegram).split(':')[0] == 'Integration Protocol': + if ''.join(telegram).split(':')[1] == ' processed serial number request': + message['Payload'] = 'processed serial number request' + else: + s = ''.join(telegram).split(' ') + message['Type'] = 'Send Beo4 Command' + message[s[5]] = s[6] + message['Payload'] = OrderedDict() + for k in range(10, len(s)): + if k == 10: + message['Payload']['to_MLN'] = int(s[k], base=16) + if k == 11: + message['Payload']['Destination'] = self._dictsanitize( + const._mlgw_payloaddestdict, int(s[k], base=16)) + if k == 12: + message['Payload']['Command'] = self._dictsanitize( + const.beo4_commanddict, int(s[k], base=16)).upper() + if k == 13: + message['Payload']['Sec-Source'] = self._dictsanitize( + const._mlgw_secsourcedict, int(s[k], base=16)) + if k == 14: + message['Payload']['Link'] = self._dictsanitize( + const._mlgw_linkdict, int(s[k], base=16)) + if k > 14: + message['Payload']['cmd' + str(k - 9)] = self._dictsanitize( + const.beo4_commanddict, int(s[k], base=16)) + return message + + def _decode_action(self, telegram, message): + message['Zone'] = telegram[0].upper() + message['Room'] = telegram[1].upper() + message['Type'] = telegram[2].upper() + message['Device'] = telegram[3] + message['State_Update'] = OrderedDict() + if message.get('Type') == 'BUTTON': + if telegram[4].split('=')[0] == '_SET STATE?STATE': + message['State_Update']['STATE'] = telegram[4].split('=')[1] + if message['State_Update'].get('STATE') == '0': + message['State_Update']['Status'] = "Off" + else: + message['State_Update']['Status'] = "On" + else: + message['State_Update']['STATE'] = telegram[4] + + if message.get('Type') == 'DIMMER': # e.g. DownstairsHallwayDIMMERWall LightSTATE_UPDATE?LEVEL=5 + if telegram[4].split('=')[0] == '_SET STATE?LEVEL': + message['State_Update']['LEVEL'] = telegram[4].split('=')[1] + if message['State_Update'].get('LEVEL') == '0': + message['State_Update']['Status'] = "Off" + else: + message['State_Update']['Status'] = "On" + else: + message['State_Update']['STATE'] = telegram[4] + return message + + def _decode_command(self, telegram, message): + message['Zone'] = telegram[0].upper() + message['Room'] = telegram[1].upper() + message['Type'] = telegram[2].upper() + message['Device'] = telegram[3] + message['State_Update'] = OrderedDict() + if message.get('Type') == 'BUTTON': + if telegram[4].split('=')[0] == '_SET STATE?STATE': + message['State_Update']['STATE'] = telegram[4].split('=')[1] + if message['State_Update'].get('STATE') == '0': + message['State_Update']['Status'] = "Off" + else: + message['State_Update']['Status'] = "On" + else: + message['State_Update']['STATE'] = telegram[4] + + if message.get('Type') == 'DIMMER': + if telegram[4].split('=')[0] == '_SET STATE?LEVEL': + message['State_Update']['LEVEL'] = telegram[4].split('=')[1] + if message['State_Update'].get('LEVEL') == '0': + message['State_Update']['Status'] = "Off" + else: + message['State_Update']['Status'] = "On" + else: + message['State_Update']['STATE'] = telegram[4] + return message + + def _decode_trigger(self, telegram, message): + message['Zone'] = telegram[0].upper() + message['Room'] = telegram[1].upper() + message['Type'] = telegram[2].upper() + message['Device'] = telegram[3] + message['State_Update'] = OrderedDict() + + if message.get('Type') == 'BUTTON': + if telegram[4].split('=')[0] == 'STATE_UPDATE?STATE': + message['State_Update']['STATE'] = telegram[4].split('=')[1] + if message['State_Update'].get('STATE') == '0': + message['State_Update']['Status'] = "Off" + else: + message['State_Update']['Status'] = "On" + else: + message['State_Update']['STATE'] = telegram[4] + + if message.get('Type') == 'DIMMER': + if telegram[4].split('=')[0] == 'STATE_UPDATE?LEVEL': + message['State_Update']['LEVEL'] = telegram[4].split('=')[1] + if message['State_Update'].get('LEVEL') == '0': + message['State_Update']['Status'] = "Off" + else: + message['State_Update']['Status'] = "On" + else: + message['State_Update']['STATE'] = telegram[4] + + if message.get('Type') == 'AV RENDERER': + if telegram[4][:5] == 'Light': + state = telegram[4][6:].split('&') + message['State_Update']['type'] = 'Light Command' + for s in state: + message['State_Update'][s.split('=')[0].lower()] = s.split('=')[1].title() + if message['State_Update'].get('command') == ' Cmd': + message['State_Update']['command'] = self._dictsanitize(const.beo4_commanddict, + int(s[13:].strip())).title() + elif telegram[4][:7] == 'Control': + state = telegram[4][6:].split('&') + message['State_Update']['type'] = 'Control Command' + for s in state: + message['State_Update'][s.split('=')[0].lower()] = s.split('=')[1] + if message['State_Update'].get('command') == ' cmd': + message['State_Update']['command'] = self._dictsanitize(const.beo4_commanddict, + int(s[13:].strip())).title() + elif telegram[4] == 'All standby': + message['State_Update']['command'] = telegram[4] + + else: + state = telegram[4][13:].split('&') + for s in state: + if s.split('=')[0] == 'sourceUniqueId': + src = s.split('=')[1].split(':')[0].upper() + message['State_Update']['source'] = self._srcdictsanitize(const._blgw_srcdict, src) + message['State_Update'][s.split('=')[0]] = s.split('=')[1] + elif s.split('=')[0] == 'nowPlayingDetails': + message['State_Update']['nowPlayingDetails'] = OrderedDict() + details = s.split('=')[1].split(';') + if len(details) > 1: + for d in details: + if d.split(':')[0].strip() in ['track number', 'channel number']: + message['State_Update']['nowPlayingDetails']['channel_track'] \ + = d.split(':')[1].strip() + else: + message['State_Update']['nowPlayingDetails'][d.split(':')[0].strip()] \ + = d.split(':')[1].strip() + else: + message['State_Update'][s.split('=')[0]] = s.split('=')[1] + return message + + def _report(self, header, telegram, message): + self.last_message = message + self.log.debug(self.name + "\n" + str(json.dumps(message, indent=4))) + if self.messageCallBack: + self.messageCallBack(self.name, ''.join(header).upper(), ''.join(telegram), message) + + def client_connect(self): + self.log.info('Connecting to host at %s, port %i', self._host, self._port) + self.set_terminator(b'\r\n') + # Create the socket + try: + socket.setdefaulttimeout(3) + self.create_socket(socket.AF_INET, socket.SOCK_STREAM) + except socket.error, e: + self.log.info("Error creating socket: %s" % e) + self.handle_close() + # Now connect + try: + self.connect((self._host, self._port)) + except socket.gaierror, e: + self.log.info("\tError with address %s:%i - %s" % (self._host, self._port, e)) + self.handle_close() + except socket.error, e: + self.log.info("\tError opening connection to %s:%i - %s" % (self._host, self._port, e)) + self.handle_close() + except socket.timeout, e: + self.log.info("\tSocket connection to %s:%i timed out- %s" % (self._host, self._port, e)) + self.handle_close() + else: + self.is_connected = True + self.log.info("\tConnected to B&O Gateway") + + def handle_connect(self): + self.log.info("\tAttempting to Authenticate...") + self._send_cmd(self._pwd) + self._send_cmd("MONITOR") + + def handle_close(self): + self.log.info(self.name + ": Closing socket") + self.is_connected = False + self.close() + + def _send_cmd(self,telegram): + try: + self.push(telegram +"\r\n") + except socket.error, e: + self.log.info("Error sending data: %s" % e) + self.handle_close() + except socket.timeout, e: + self.log.info("\tSocket connection to %s:%i timed out- %s" % (self._host, self._port, e)) + self.handle_close() + else: + self.last_sent = telegram + self.last_sent_at = time.time() + self.log.info(self.name + " >>-SENT--> : " + telegram) + time.sleep(0.2) + + def toggleEvents(self): + self._send_cmd('e') + + def toggleMacros(self): + self._send_cmd('m') + + def toggleCommands(self): + self._send_cmd('c') + + def ping(self): + self._send_cmd('') + + # ######################################################################################## + # ##### Utility functions + def _hexbyte(self, byte): + resultstr = hex(byte) + if byte < 16: + resultstr = resultstr[:2] + "0" + resultstr[2] + return resultstr + + def _dictsanitize(self, d, s): + result = d.get(s) + if result == None: + result = "UNKNOWN (type=" + self._hexbyte(s) + ")" + return str(result) + + def _srcdictsanitize(self, d, s): + result = d.get(s) + if result == None: + result = s + return str(result) \ No newline at end of file