diff --git a/BeoGateway.indigoPlugin/Contents/Server Plugin/Actions.xml b/BeoGateway.indigoPlugin/Contents/Server Plugin/Actions.xml new file mode 100644 index 0000000..b807e45 --- /dev/null +++ b/BeoGateway.indigoPlugin/Contents/Server Plugin/Actions.xml @@ -0,0 +1,203 @@ + + + http:// + + Send Beo4 Source Selection Command + send_beo4_src + + + + + + + + + + + + + + + + + + + + + Send Beo4 Key + send_beo4_key + + + + + + + + + + + + + + + + + + + + + Send BeoRemote One Source Selection Command + send_br1_src + + + + + + + + + + + + + + + + + Send BeoRemote One Key + send_br1_key + + + + + + + + + + + + + + + + + Request Device State Update + request_state_update + + + + Request BLGW Home Integration Protocol State Updates + send_hip_query + + + + + + + + + + + + + + + + + + + + + Send BLGW Home Integration Protocol Command + send_hip_cmd + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Send Free Text Home Integration Protocol Command + send_hip_cmd2 + + + + + + + + + Send Virtual Button + send_virtual_button + + + + + + + + + + + + + + + + + Post Message in Notification Centre + post_notification + + + + + + + + + + + + Send All Standby Command + all_standby + + diff --git a/BeoGateway.indigoPlugin/Contents/Server Plugin/Devices.xml b/BeoGateway.indigoPlugin/Contents/Server Plugin/Devices.xml new file mode 100644 index 0000000..97e5f01 --- /dev/null +++ b/BeoGateway.indigoPlugin/Contents/Server Plugin/Devices.xml @@ -0,0 +1,157 @@ + + + + B&O Gateway (MLGW, BLGW) + + + + + + + + + + + + + + + + + + + + + String + Audio Source Changed + Current Audio Source is + + + String + Audio SourceName Changed + Current Audio SourceName is + + + String + Now Playing + Now Playing + + + Integer + Count of Active Audio Renderers + Count of Active Audio Renderers + + + String + Names of Active Audio Renderers + Names of Active Audio Renderers + + + Integer + Count of Active Video Renderers + Count of Active Video Renderers + + + String + Names of Active Video Renderers + Names of Active Video Renderers + + + + + + AV renderer (Beovision, Beosound) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Player Status Changed + Player Status is + Current Player Status + Player Status is + + + Boolean + Mute + Mute + + + Integer + Current Volume + Current Volume + + + Separator + + + + Integer + Channel/Track + Channel/Track + + + String + Now Playing + Now Playing + + + Separator + + + + + + + + + Source Changed + Source is + Current Source + Source is + + + playState + + diff --git a/BeoGateway.indigoPlugin/Contents/Server Plugin/Events.xml b/BeoGateway.indigoPlugin/Contents/Server Plugin/Events.xml new file mode 100644 index 0000000..66473d6 --- /dev/null +++ b/BeoGateway.indigoPlugin/Contents/Server Plugin/Events.xml @@ -0,0 +1,66 @@ + + + http:// + + All Standby + + + + Light Command Received + + + + + + + + + + + + + + Control Command Received + + + + + + + + + + + + + + BeoRemote Command Received + + + + + + + + + + + + + + Virtual Button Pressed + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/BeoGateway.indigoPlugin/Contents/Server Plugin/MenuItems.xml b/BeoGateway.indigoPlugin/Contents/Server Plugin/MenuItems.xml new file mode 100644 index 0000000..d8b9ee9 --- /dev/null +++ b/BeoGateway.indigoPlugin/Contents/Server Plugin/MenuItems.xml @@ -0,0 +1,15 @@ + + + + Request Gateway Serial Number + request_serial_number + + + Request Device State Update + request_device_update + + + Reset Gateway Client Connections + reset_clients + + \ No newline at end of file diff --git a/BeoGateway.indigoPlugin/Contents/Server Plugin/PluginConfig.xml b/BeoGateway.indigoPlugin/Contents/Server Plugin/PluginConfig.xml new file mode 100644 index 0000000..93cb36a --- /dev/null +++ b/BeoGateway.indigoPlugin/Contents/Server Plugin/PluginConfig.xml @@ -0,0 +1,93 @@ + + + http:// + + + + + + + + + set_gateway + + + + set_gateway + + + + set_gateway + + + + + + + + set_login + + + + set_login + + + + + + + + + + + + + + + + set_default_audio + + + + set_music_control + play and control Apple Music + + + + + + + + + + + + set_music_control + + + + set_trackmode + prints track info to the Indigo log + + + + + + + + set_verbose + prints device telegrams to the Indigo log + + + + set_notifymode + posts information to the Notification Centre + + + + set_debug + prints debug info to the Indigo log + + diff --git a/BeoGateway.indigoPlugin/Contents/Server Plugin/Resources/ASBridge.py b/BeoGateway.indigoPlugin/Contents/Server Plugin/Resources/ASBridge.py new file mode 100644 index 0000000..31a6fd4 --- /dev/null +++ b/BeoGateway.indigoPlugin/Contents/Server Plugin/Resources/ASBridge.py @@ -0,0 +1,140 @@ +#!/usr/bin/python +import indigo +import logging +import os +import unicodedata +import threading +from Foundation import NSAppleScript +from ScriptingBridge import SBApplication + +''' Module defining a MusicController class for Apple Music, enables: + basic transport control of the player, + query of player status, + playing existing playlists, + running external appleScripts for more complex control, and + reporting messages in the notification centre''' + +PLAYSTATE = dict([ + (1800426320, "Play"), + (1800426352, "Pause"), + (1800426323, "Stop"), + (1800426310, "Wind"), + (1800426322, "Rewind") + ]) + + +class MusicController(object): + + def __init__(self): + self.app = SBApplication.applicationWithBundleIdentifier_("com.apple.Music") + + # ######################################################################################## + # Player information + def get_current_track_info(self): + name = self.app.currentTrack().name() + album = self.app.currentTrack().album() + artist = self.app.currentTrack().artist() + number = self.app.currentTrack().trackNumber() + if name: + # Deal with tracks with non-ascii characters such as accents + name = unicodedata.normalize('NFD', name).encode('ascii', 'ignore') + album = unicodedata.normalize('NFD', album).encode('ascii', 'ignore') + artist = unicodedata.normalize('NFD', artist).encode('ascii', 'ignore') + return [name, album, artist, number] + + def get_current_play_state(self): + return PLAYSTATE.get(self.app.playerState()) + + def get_current_track_position(self): + return self.app.playerPosition() + + # ######################################################################################## + # Transport Controls + def playpause(self): + self.app.playpause() + + def play(self): + if PLAYSTATE.get(self.app.playerState()) in ['Wind', 'Rewind']: + self.app.resume() + elif PLAYSTATE.get(self.app.playerState()) == 'Pause': + self.app.playpause() + elif PLAYSTATE.get(self.app.playerState()) == 'Stop': + self. app.setValue_forKey_('true', 'shuffleEnabled') + playlist = self.app.sources().objectWithName_("Library") + playlist.playOnce_(None) + + def pause(self): + if PLAYSTATE.get(self.app.playerState()) == 'Play': + self.app.pause() + + def stop(self): + if PLAYSTATE.get(self.app.playerState()) != 'Stop': + self.app.stop() + + def next_track(self): + self.app.nextTrack() + + def previous_track(self): + self.app.previousTrack() + + def wind(self, time): + # self.app.wind() + + # Native wind function can be a bit annoying + # I provide an alternative below that skips a set number of seconds forwards + self.set_current_track_position(time) + + def rewind(self, time): + # self.app.rewind() + + # Native rewind function can be a bit annoying + # I provide an alternative below that skips a set number of seconds back + self.set_current_track_position(time) + + # ######################################################################################## + # More complex playback control functions + def shuffle(self): + if self.app.shuffleEnabled(): + self.app.setValue_forKey_('false', 'shuffleEnabled') + else: + self.app.setValue_forKey_('true', 'shuffleEnabled') + + def set_current_track_position(self, time, mode='Relative'): + if mode == 'Relative': + # Set playback position in seconds relative to current position + self.app.setPlayerPosition_(self.app.playerPosition() + time) + elif mode == 'Absolute': + # Set playback position in seconds from the start of the track + self.app.setPlayerPosition_(time) + + def play_playlist(self, playlist): + self.app.stop() + playlist = self.app.sources().objectWithName_("Library").playlists().objectWithName_(playlist) + playlist.playOnce_(None) + + # ######################################################################################## + # Accessory functions - threaded due to execution time + @staticmethod + def run_script(script, debug): + script = 'run script ("' + script + '" as POSIX file)' + if debug: + indigo.server.log(script, level=logging.DEBUG) + + def applet(_script): + # Run an external applescript file + s = NSAppleScript.alloc().initWithSource_(_script) + s.executeAndReturnError_(None) + + threading.Thread(target=applet, args=(script,)).start() + + @staticmethod + def notify(message, subtitle): + def applet(body, title): + # Post message in notification center + path = os.path.dirname(os.path.abspath(__file__)) + script = 'tell application "' + path + '/Notify.app" to notify("BeoGateway", "' + \ + body + '", "' + title + '")' + s = NSAppleScript.alloc().initWithSource_(script) + s.executeAndReturnError_(None) + + threading.Thread(target=applet, args=(message, subtitle,)).start() diff --git a/BeoGateway.indigoPlugin/Contents/Server Plugin/Resources/BLHIP_CLIENT.py b/BeoGateway.indigoPlugin/Contents/Server Plugin/Resources/BLHIP_CLIENT.py new file mode 100644 index 0000000..0ec9dbb --- /dev/null +++ b/BeoGateway.indigoPlugin/Contents/Server Plugin/Resources/BLHIP_CLIENT.py @@ -0,0 +1,282 @@ +import indigo +import asynchat +import socket +import time +import urllib +import logging +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', + debug=False, cb=None): + asynchat.async_chat.__init__(self) + + self.debug = debug + + 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._received_data += data + + def found_terminator(self): + # indigo.server.log("Raw Data: " + self._received_data) + self.last_received = self._received_data + self.last_received_at = time.time() + + if self._received_data == 'error': + self.handle_close() + + if self._received_data == 'e OK f%20%2A/%2A/%2A/%2A': + indigo.server.log('\tAuthentication Successful!', level=logging.DEBUG) + self.query(dev_type="AV renderer") + + self._received_data = urllib.unquote(self._received_data) + telegram = self._received_data.replace("%201", "") + telegram = telegram.split('/') + header = telegram[0:4] + + 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' and self.debug: + indigo.server.log('Command Successfully Processed: ' + str(urllib.unquote(self._received_data)), + level=logging.DEBUG) + + elif e_string[2:5] == 'CMD': + indigo.server.log('Wrong or Unrecognised Command: ' + str(urllib.unquote(self._received_data)), + level=logging.WARNING) + + elif e_string[2:5] == 'SYN': + indigo.server.log('Bad Syntax, or Wrong Character Encoding: ' + + str(urllib.unquote(self._received_data)), level=logging.WARNING) + + elif e_string[2:5] == 'ACC': + indigo.server.log('Zone Access Violation: ' + str(urllib.unquote(self._received_data)), + level=logging.WARNING) + + elif e_string[2:5] == 'LEN': + indigo.server.log('Received Message Too Long: ' + str(urllib.unquote(self._received_data)), + level=logging.WARNING) + + self._received_data = "" + return + else: + self._received_data = "" + + 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": + play_details = s.split('=') + if len(play_details[1]) > 0: + play_details = play_details[1].split('; ') + message['State_Update']["nowPlayingDetails"] = OrderedDict() + for p in play_details: + 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] + + # call function to find channel details if type = Legacy + try: + if 'nowPlayingDetails' in message['State_Update'] \ + and message['State_Update']['nowPlayingDetails']['type'] == 'Legacy': + self._get_channel_track(message) + except KeyError: + pass + + 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): + self.last_message = message + if self.messageCallBack: + self.messageCallBack(self.name, str(list(header)), str(list(payload)), message) + + def client_connect(self): + indigo.server.log('Connecting to host at ' + self._host + ', port ' + str(self._port), level=logging.WARNING) + self.set_terminator(b'\r\n') + # Create the socket + try: + self.create_socket(socket.AF_INET, socket.SOCK_STREAM) + except socket.error, e: + indigo.server.log("Error creating socket: " + str(e), level=logging.ERROR) + self.handle_close() + # Now connect + try: + self.connect((self._host, self._port)) + except socket.gaierror, e: + indigo.server.log("\tError with address: " + str(e), level=logging.ERROR) + self.handle_close() + except socket.timeout, e: + indigo.server.log("\tSocket connection timed out: " + str(e), level=logging.ERROR) + self.handle_close() + except socket.error, e: + indigo.server.log("\tError opening connection: " + str(e), level=logging.ERROR) + self.handle_close() + else: + self.is_connected = True + indigo.server.log("\tConnected to B&O Gateway", level=logging.DEBUG) + + def handle_connect(self): + indigo.server.log("\tAttempting to Authenticate...", level=logging.WARNING) + self.send_cmd(self._user) + self.send_cmd(self._pwd) + self.statefilter() + + def handle_close(self): + indigo.server.log(self.name + ": Closing socket", level=logging.ERROR) + self.is_connected = False + self.close() + + def send_cmd(self, telegram): + try: + self.push(telegram.encode("ascii") + "\r\n") + except socket.timeout, e: + indigo.server.log("\tSocket connection timed out: " + str(e), level=logging.ERROR) + self.handle_close() + except socket.error, e: + indigo.server.log("Error sending data: " + str(e), level=logging.ERROR) + self.handle_close() + else: + self.last_sent = telegram + self.last_sent_at = time.time() + if telegram == 'q Main/global/SYSTEM/BeoLink': + if self.debug: + indigo.server.log(self.name + " >>-SENT--> : " + telegram, level=logging.DEBUG) + else: + indigo.server.log(self.name + " >>-SENT--> : " + telegram, level=logging.INFO) + 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 + + if self.debug: + indigo.server.log(self.name + ": sending state update request for" + device + dev_type + room + zone, + level=logging.DEBUG) + self.send_cmd(query) + + def statefilter(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') + + # Utility Functions + @staticmethod + def _srcdictsanitize(d, s): + result = d.get(s) + if result is None: + result = s + return str(result) + + def _get_channel_track(self, message): + try: + node = indigo.devices[message['Device']] + # Get properties + node_props = node.pluginProps + source_name = message["State_Update"]["sourceName"].strip().replace(" ", "_") + if self.debug: + indigo.server.log('searching device ' + node.name + ' channel list for source ' + source_name, + level=logging.DEBUG) + if 'channels' in node_props['sources'][source_name]: + for channel in node_props['sources'][source_name]['channels']: + if self.debug: + indigo.server.log(source_name + " Channel " + channel[0][1:] + " = " + channel[1], + level=logging.DEBUG) + if int(channel[0][1:]) == int( + message["State_Update"]['nowPlayingDetails']["channel_track"]): + message["State_Update"]["nowPlaying"] = channel[1] + if self.debug: + indigo.server.log("Current Channel: " + channel[1], level=logging.DEBUG) + return + + # If source list exhausted then return Unknown + message["State_Update"]["nowPlaying"] = 'Unknown' + except KeyError: + message["State_Update"]["nowPlaying"] = 'Unknown' diff --git a/BeoGateway.indigoPlugin/Contents/Server Plugin/Resources/CONSTANTS.py b/BeoGateway.indigoPlugin/Contents/Server Plugin/Resources/CONSTANTS.py new file mode 100644 index 0000000..7ea7e41 --- /dev/null +++ b/BeoGateway.indigoPlugin/Contents/Server Plugin/Resources/CONSTANTS.py @@ -0,0 +1,683 @@ +from collections import OrderedDict + + +# Constants for B&O telegram protocols +# ######################################################################################## +# Config data (set on initialisation) +rooms = [] +available_sources = [] + +standby_state = [ + {'key': 'onOffState', 'value': False}, + {'key': 'playState', 'value': 'Standby'}, + {'key': 'source', 'value': 'Standby'}, + {'key': 'nowPlaying', 'value': 'Unknown'}, + {'key': 'channelTrack', 'value': 0}, + {'key': 'mute', 'value': True}, + {'key': 'volume', 'value': 0}, +] + +gw_all_stb = [ + {'key': 'AudioRenderers', 'value': ''}, + {'key': 'VideoRenderers', 'value': ''}, + {'key': 'nAudioRenderers', 'value': 0}, + {'key': 'nVideoRenderers', 'value': 0}, + {'key': 'currentAudioSource', 'value': 'Unknown'}, + {'key': 'currentAudioSourceName', 'value': 'Unknown'}, + {'key': 'nowPlaying', 'value': 'Unknown'}, +] + +# ######################################################################################## +# Source Types + +source_type_dict = dict( + [ + ("Video Sources", ("TV", "V.AUX/DTV2", "MEDIA", "V.TAPE/V.MEM/DVD2", "DVD", "DVD2", "CAMERA", + "SAT/DTV", "PC", "WEB", "DOORCAM", "PHOTO", "USB2", "WEBMEDIA", "AV.IN", + "HOMEMEDIA", "DVB_RADIO", "DNLA", "RECORDINGS", "CAMERA", "USB", "DNLA-DMR", "YOUTUBE", + "HOME.APP", "HDMI_1", "HDMI_2", "HDMI_3", "HDMI_4", "HDMI_5", "HDMI_6", + "HDMI_7", "HDMI_8", "MATRIX_1", "MATRIX_2", "MATRIX_3", "MATRIX_4", "MATRIX_5", + "MATRIX_6", "MATRIX_7", "MATRIX_8", "MATRIX_9", "MATRIX_10", "MATRIX_11", + "MATRIX_12", "MATRIX_13", "MATRIX_14", "MATRIX_15", "MATRIX_16", "PERSONAL_1", + "PERSONAL_2", "PERSONAL_3", "PERSONAL_4", "PERSONAL_5", "PERSONAL_6", "PERSONAL_7", + "PERSONAL_8")), + ("Audio Sources", ("RADIO", "A.AUX", "A.TAPE/A.MEM", "CD", "PHONO/N.RADIO", "A.TAPE2/N.MUSIC", + "SERVER", "SPOTIFY", "CD2/JOIN", "TUNEIN", "DVB_RADIO", "LINE.IN", "BLUETOOTH", + "MUSIC", "AIRPLAY", "SPOTIFY", "DEEZER", "QPLAY")) + ] +) + +# ######################################################################################## +# Beo4 Commands +beo4_srcdict = OrderedDict( + [ + # Source selection: + (0x0C, "Standby"), + (0x47, "Sleep"), + (0x80, "TV"), + (0x81, "Radio"), + (0x82, "V.Aux/DTV2"), + (0x83, "A.Aux"), + (0x84, "Media"), + (0x85, "V.Tape/V.Mem"), + (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"), + ] +) + +beo4_commanddict = OrderedDict( + [ + # Source selection: + (0x0C, "Standby"), + (0x47, "Sleep"), + (0x80, "TV"), + (0x81, "Radio"), + (0x82, "V.Aux/DTV2"), + (0x83, "A.Aux"), + (0x84, "Media"), + (0x85, "V.Tape/V.Mem"), + (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"), + (0x0F, "Function_1"), + (0x10, "Function_2"), + (0x11, "Function_3"), + (0x12, "Function_4"), + (0x19, "Function_5"), + (0x1A, "Function_6"), + (0x21, "Function_7"), + (0x22, "Function_8"), + (0x23, "Function_9"), + (0x24, "Function_10"), + (0x25, "Function_11"), + (0x26, "Function_12"), + (0x27, "Function_13"), + (0x39, "Function_14"), + (0x3A, "Function_15"), + (0x3B, "Function_16"), + (0x3C, "Function_17"), + (0x3D, "Function_18"), + (0x3E, "Function_19"), + (0x4B, "Function_20"), + (0x4C, "Function_21"), + (0x50, "Function_22"), + (0x51, "Function_23"), + (0x7D, "Function_24"), + (0xA5, "Function_25"), + (0xA6, "Function_26"), + (0xA9, "Function_27"), + (0xAA, "Function_28"), + (0xDD, "Function_29"), + (0xDE, "Function_30"), + (0xE0, "Function_31"), + (0xE1, "Function_32"), + (0xE2, "Function_33"), + (0xE6, "Function_34"), + (0xE7, "Function_35"), + (0xF2, "Function_36"), + (0xF3, "Function_37"), + (0xF4, "Function_38"), + (0xF5, "Function_39"), + (0xF6, "Function_40"), + # 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 = OrderedDict( + [ + # Source, (Cmd, Unit) + ("Standby", (0x0C, 0)), + ("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)), + ("A.TAPE2/N.MUSIC", (0x94, 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)), + ] +) + +beoremoteone_keydict = OrderedDict( + [ + (0x0C, "Standby"), + # 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") + ] +) + +# ######################################################################################## +# Source Activity +sourceactivitydict = OrderedDict( + [ + (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"), + (0x45, "GOTO_SOURCE"), + # LOCKMANAGER_COMMAND: Lock to Determine what device issues source commands + # reference: https://tidsskrift.dk/daimipb/article/download/7043/6004/0 + (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, "PICTURE_AND_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_LOCKmANAGER telegram and assumes responsibility + # for LOCKMANAGER_COMMAND telegrams until a key transfer occurs. + # reference: https://tidsskrift.dk/daimipb/article/download/7043/6004/0 + (0x12, "KEY_LOST"), # ? + # Unknown command with payload of length 1. + # bit 0: unknown + # bit 1: unknown + (0xA0, "NEW_LOCKMANAGER"), # ? + # 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/SLAVE DEVICE"), + (0x81, "ALL AUDIO LINK DEVICES"), + (0x82, "ALL VIDEO LINK DEVICES"), + (0x83, "ALL LINK DEVICES"), + (0x80, "ALL"), + (0xF0, "MLGW"), + (0x29, "SYSTEM CONTROLLER/TIMER"), + # 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. + # reference: https://tidsskrift.dk/daimipb/article/download/7043/6004/0 + (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, "SAT/DTV"), + (0x29, "DVD"), + (0x33, "V.AUX/DTV2"), + (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_sourcekind_dict = dict([(0x01, "audio source"), (0x02, "video source"), (0xFF, "undefined")]) + +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 = OrderedDict( + [ + (0x00, "Video Source"), + (0x01, "Audio Source"), + (0x05, "Peripheral Video Source (V.TAPE/V.MEM/DVD)"), + (0x06, "Secondary Peripheral Video Source (V.TAPE2/V.MEM2/DVD2)"), + (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, "2 channel stereo"), + (0x03, "Front surround"), + (0x04, "4 channel 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, "Cinema mode off"), (0x01, "Cinema mode 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"), + ] +) + +blgw_devtypes = OrderedDict( + [ + ("*", "All"), + ("SYSTEM", "System"), + ("AV renderer", "AV Renderer"), + ("BUTTON", "Button"), + ("Dimmer", "Dimmer"), + ("GPIO", "GPIO"), + ("Thermostat 1 setpoint", "Thermostat 1 setpoint"), + ("Thermostat 2 setpoints", "Thermostat 2 setpoints") + ] +) diff --git a/BeoGateway.indigoPlugin/Contents/Server Plugin/Resources/MLCLI_CLIENT.py b/BeoGateway.indigoPlugin/Contents/Server Plugin/Resources/MLCLI_CLIENT.py new file mode 100644 index 0000000..3ceef3a --- /dev/null +++ b/BeoGateway.indigoPlugin/Contents/Server Plugin/Resources/MLCLI_CLIENT.py @@ -0,0 +1,462 @@ +import indigo +import asynchat +import socket +import time +import logging +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', + debug=False, cb=None): + asynchat.async_chat.__init__(self) + + self.debug = debug + + 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._received_data += data + + def found_terminator(self): + self.last_received = self._received_data + self.last_received_at = time.time() + + 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: + indigo.server.log("\tAuthenticated! Gateway type is " + telegram[0:4] + "\n", level=logging.DEBUG) + if telegram[0:4] != "MLGW": + self.isBLGW = True + + # Process telegrams and return json data in human readable format + if self._i > self._header_lines: + if "---- Logging" in telegram: + # Pong telegram + header = telegram + payload = [] + message = OrderedDict([('payload_type', 'Pong'), ('State_Update', dict([('CONNECTION', 'Online')]))]) + self.is_connected = True + if self.messageCallBack: + self.messageCallBack(self.name, header, str(list(payload)), message) + else: + # ML protocol message detected + items = telegram.split()[1:] + if len(items): + telegram = bytearray() + for item in items: + try: + telegram.append(int(item[:-1], base=16)) + except (ValueError, TypeError): + # abort if invalid character found + if self.debug: + indigo.server.log('Invalid character ' + str(item) + ' found in telegram: ' + + ''.join(items) + '\nAborting!', level=logging.ERROR) + break + + # Decode any telegram with a valid 9 byte header, excluding typy 0x14 (regular clock sync pings) + if len(telegram) >= 9: + # 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): + indigo.server.log('Connecting to host at ' + self._host + ', port ' + str(self._port), level=logging.WARNING) + self.set_terminator(b'\r\n') + # Create the socket + try: + self.create_socket(socket.AF_INET, socket.SOCK_STREAM) + except socket.error, e: + indigo.server.log("Error creating socket: " + str(e), level=logging.ERROR) + self.handle_close() + # Now connect + try: + self.connect((self._host, self._port)) + except socket.gaierror, e: + indigo.server.log("\tError with address: " + str(e), level=logging.ERROR) + self.handle_close() + except socket.timeout, e: + indigo.server.log("\tSocket connection timed out: " + str(e), level=logging.ERROR) + self.handle_close() + except socket.error, e: + indigo.server.log("\tError opening connection: " + str(e), level=logging.ERROR) + self.handle_close() + else: + self.is_connected = True + indigo.server.log("\tConnected to B&O Gateway", level=logging.DEBUG) + + def handle_connect(self): + indigo.server.log("\tAttempting to Authenticate...", level=logging.WARNING) + self.send_cmd(self._pwd) + self.send_cmd("_MLLOG ONLINE") + + def handle_close(self): + indigo.server.log(self.name + ": Closing socket", level=logging.ERROR) + self.is_connected = False + self.close() + + def send_cmd(self, telegram): + try: + self.push(telegram + "\r\n") + except socket.timeout, e: + indigo.server.log("\tSocket connection timed out: " + str(e), level=logging.ERROR) + self.handle_close() + except socket.error, e: + indigo.server.log("Error sending data: " + str(e), level=logging.ERROR) + self.handle_close() + else: + self.last_sent = telegram + self.last_sent_at = time.time() + indigo.server.log(self.name + " >>-SENT--> : " + telegram, level=logging.INFO) + time.sleep(0.2) + + def _report(self, header, payload, message): + self.last_message = message + if self.messageCallBack: + self.messageCallBack(self.name, str(list(header)), str(list(payload)), message) + + def ping(self): + if self.debug: + indigo.server.log(self.name + " >>-SENT--> : Ping", level=logging.DEBUG) + self.push('\n') + + # ######################################################################################## + # ##### Utility functions + @staticmethod + def _hexbyte(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 is None: + result = self._hexbyte(s) + return str(result) + + @staticmethod + def _get_type(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() + self._get_device_info(message, telegram) + if 'Device' not in message: + # If ML telegram has been matched to a Masterlink node in the devices list then the 'from_device' + # key is redundant - it will always be identical to the 'Device' key + 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._get_device_name(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["State_Update"] = OrderedDict() + + # RELEASE command signifies product standby + if message.get("payload_type") in ["RELEASE", "STANDBY"]: + message["State_Update"]["state"] = 'Standby' + + # source status info + # TTFF__TYDSOS__PTLLPS SR____LS______SLSHTR__ACSTPI________________________TRTR______ + if message.get("payload_type") == "STATUS_INFO": + message["State_Update"]["nowPlaying"] = 'Unknown' + + if telegram[8] < 27: + c_trk = telegram[19] + else: + c_trk = telegram[36] * 256 + telegram[37] + + message["State_Update"]["nowPlayingDetails"] = OrderedDict( + [ + ("local_source", telegram[13]), + ("type", self._dictsanitize(CONST.ml_sourcekind_dict, telegram[22])), + ("channel_track", c_trk), + ("source_medium_position", self._hexword(telegram[18], telegram[17])), + ("picture_format", self._dictsanitize(CONST.ml_pictureformatdict, telegram[23])) + ] + ) + source = self._dictsanitize(CONST.ml_selectedsourcedict, telegram[10]) + self._get_source_name(source, message) + message["State_Update"]["source"] = source + message["State_Update"]["sourceID"] = telegram[10] + self._get_channel_track(message) + message["State_Update"]["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["State_Update"]["display_source"] = _s.rstrip() + + # extended source information + if message.get("payload_type") == "EXTENDED_SOURCE_INFORMATION": + message["State_Update"]["info_type"] = telegram[10] + _s = "" + for i in range(0, telegram[8] - 14): + _s = _s + chr(telegram[i + 24]) + message["State_Update"]["info_value"] = _s + + # beo4 command + if message.get("payload_type") == "BEO4_KEY": + source = self._dictsanitize(CONST.ml_selectedsourcedict, telegram[10]) + self._get_source_name(source, message) + message["State_Update"] = OrderedDict( + [ + ("source", source), + ("sourceID", telegram[10]), + ("source_type", self._get_type(CONST.ml_selectedsource_type_dict, telegram[10])), + ("command", self._dictsanitize(CONST.beo4_commanddict, telegram[11])) + ] + ) + + # audio track info long + if message.get("payload_type") == "TRACK_INFO_LONG": + message["State_Update"]["nowPlaying"] = 'Unknown' + message["State_Update"]["nowPlayingDetails"] = OrderedDict( + [ + ("type", self._get_type(CONST.ml_selectedsource_type_dict, telegram[11])), + ("channel_track", telegram[12]), + ] + ) + source = self._dictsanitize(CONST.ml_selectedsourcedict, telegram[11]) + self._get_source_name(source, message) + message["State_Update"]["source"] = source + message["State_Update"]["sourceID"] = telegram[11] + self._get_channel_track(message) + message["State_Update"]["state"] = self._dictsanitize(CONST.sourceactivitydict, telegram[13]) + + # video track info + if message.get("payload_type") == "VIDEO_TRACK_INFO": + message["State_Update"]["nowPlaying"] = 'Unknown' + message["State_Update"]["nowPlayingDetails"] = OrderedDict( + [ + ("source_type", self._get_type(CONST.ml_selectedsource_type_dict, telegram[13])), + ("channel_track", telegram[11] * 256 + telegram[12]) + ] + ) + source = self._dictsanitize(CONST.ml_selectedsourcedict, telegram[13]) + self._get_source_name(source, message) + message["State_Update"]["source"] = source + message["State_Update"]["sourceID"] = telegram[13] + self._get_channel_track(message) + message["State_Update"]["state"] = self._dictsanitize(CONST.sourceactivitydict, telegram[14]) + + # track change info + if message.get("payload_type") == "TRACK_INFO": + message["State_Update"]["subtype"] = self._dictsanitize(CONST.ml_trackinfo_subtype_dict, telegram[9]) + + # Change source + if message["State_Update"].get("subtype") == "Change Source": + message["State_Update"]["prev_source"] = self._dictsanitize(CONST.ml_selectedsourcedict, telegram[11]) + message["State_Update"]["prev_sourceID"] = telegram[11] + message["State_Update"]["prev_source_type"] = self._get_type( + CONST.ml_selectedsource_type_dict, telegram[11]) + if len(telegram) > 18: + source = self._dictsanitize(CONST.ml_selectedsourcedict, telegram[22]) + self._get_source_name(source, message) + message["State_Update"]["source"] = source + message["State_Update"]["sourceID"] = telegram[22] + + # Current Source + if message["State_Update"].get("subtype") == "Current Source": + source = self._dictsanitize(CONST.ml_selectedsourcedict, telegram[11]) + self._get_source_name(source, message) + message["State_Update"]["source"] = source + message["State_Update"]["sourceID"] = telegram[11] + message["State_Update"]["source_type"] = self._get_type(CONST.ml_selectedsource_type_dict, telegram[11]) + message["State_Update"]["state"] = 'Unknown' + else: + message["State_Update"]["subtype"] = "Undefined: " + self._hexbyte(telegram[9]) + + # goto source + if message.get("payload_type") == "GOTO_SOURCE": + message["State_Update"]["nowPlaying"] = 'Unknown' + message["State_Update"]["nowPlayingDetails"] = OrderedDict( + [ + ("source_type", self._get_type(CONST.ml_selectedsource_type_dict, telegram[11])), + ("channel_track", telegram[12]) + ] + ) + source = self._dictsanitize(CONST.ml_selectedsourcedict, telegram[11]) + self._get_source_name(source, message) + message["State_Update"]["source"] = source + message["State_Update"]["sourceID"] = telegram[11] + if telegram[12] not in [0, 255]: + self._get_channel_track(message) + # Device sending goto source command is playing + message["State_Update"]["state"] = 'Play' + + # remote request + if message.get("payload_type") == "MLGW_REMOTE_BEO4": + message["State_Update"]["command"] = self._dictsanitize(CONST.beo4_commanddict, telegram[14]) + message["State_Update"]["destination"] = self._dictsanitize(CONST.destselectordict, telegram[11]) + + # request_key + if message.get("payload_type") == "LOCK_MANAGER_COMMAND": + message["State_Update"]["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["State_Update"]["subtype"] = self._dictsanitize(CONST.ml_activity_dict, telegram[9]) + if message["State_Update"].get('subtype') == "Source Active": + source = self._dictsanitize(CONST.ml_selectedsourcedict, telegram[13]) + self._get_source_name(source, message) + message["State_Update"]["source"] = source + message["State_Update"]["sourceID"] = telegram[13] + message["State_Update"]["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["State_Update"]["subtype"] = self._dictsanitize(CONST.ml_activity_dict, telegram[9]) + if message["State_Update"].get('subtype') == "Source Active": + source = self._dictsanitize(CONST.ml_selectedsourcedict, telegram[11]) + self._get_source_name(source, message) + message["State_Update"]["source"] = source + message["State_Update"]["sourceID"] = telegram[11] + message["State_Update"]["source_type"] = self._get_type(CONST.ml_selectedsource_type_dict, telegram[11]) + + # request local audio source + if message.get("payload_type") == "PICTURE_AND_SOUND_STATUS": + message["State_Update"]["sound_status"] = OrderedDict( + [ + ("mute_status", self._dictsanitize(CONST.mlgw_soundstatusdict, telegram[10])), + ("speaker_mode", self._dictsanitize(CONST.mlgw_speakermodedict, telegram[11])), + # ("stereo_mode", self._dictsanitize(CONST.mlgw_stereoindicatordict, telegram[9])) + ] + ) + # message["State_Update"]["picture_status"] = OrderedDict() + + message['State_Update']['source'] = 'Unknown' + message['State_Update']['sourceName'] = 'Unknown' + message["State_Update"]["state"] = 'Unknown' + message["volume"] = int(telegram[12]) + + return message + + @staticmethod + def _get_device_info(message, telegram): + # Loop over the device list + for node in indigo.devices.iter('uk.co.lukes_plugins.BeoGateway.plugin.AVrenderer'): + # Get properties + node_props = node.pluginProps + + # Skip netlink devices with no ml_id + if node_props['mlid'] == 'NA': + continue + + # identify if the mlid is a number or a text string + try: + ml_id = int(node_props['mlid'], base=16) + except ValueError: + # If it is a text mlid then loop over the dictionary and get the numeric key + for item in CONST.ml_device_dict.items(): + if item[1] == node_props['mlid']: + ml_id = int(item[0]) + + if ml_id == int(telegram[1]): # Match ML_ID + try: + message["Zone"] = node_props['zone'].upper() + except KeyError: + pass + message["Room"] = node_props['room'].upper() + message["Type"] = "AV RENDERER" + message["Device"] = node.name + break + + def _get_channel_track(self, message): + try: + node = indigo.devices[message['Device']] + # Get properties + node_props = node.pluginProps + source_name = message["State_Update"]["sourceName"].strip().replace(" ", "_") + if self.debug: + indigo.server.log('searching device ' + node.name + ' channel list for source ' + source_name, + level=logging.DEBUG) + if 'channels' in node_props['sources'][source_name]: + for channel in node_props['sources'][source_name]['channels']: + if self.debug: + indigo.server.log(source_name + " Channel " + channel[0][1:] + " = " + channel[1], + level=logging.DEBUG) + if int(channel[0][1:]) == int( + message["State_Update"]['nowPlayingDetails']["channel_track"]): + message["State_Update"]["nowPlaying"] = channel[1] + if self.debug: + indigo.server.log("Current Channel: " + channel[1], level=logging.DEBUG) + return + + # If source list exhausted then return Unknown + message["State_Update"]["nowPlaying"] = 'Unknown' + except KeyError: + message["State_Update"]["nowPlaying"] = 'Unknown' + + @staticmethod + def _get_device_name(dev): + for node in indigo.devices.iter('uk.co.lukes_plugins.BeoGateway.plugin.AVrenderer'): + # Get properties + node_props = node.pluginProps + if node_props['mlid'] == dev: + return node.name + return dev + + @staticmethod + def _get_source_name(source, message): + if CONST.available_sources: + for src in CONST.available_sources: + if str(src[0]) == str(source): + message["State_Update"]["sourceName"] = src[1] + return + # If source list exhausted then return Unknown + message["State_Update"]["sourceName"] = 'Unknown' diff --git a/BeoGateway.indigoPlugin/Contents/Server Plugin/Resources/MLCONFIG.py b/BeoGateway.indigoPlugin/Contents/Server Plugin/Resources/MLCONFIG.py new file mode 100644 index 0000000..c1fc0bc --- /dev/null +++ b/BeoGateway.indigoPlugin/Contents/Server Plugin/Resources/MLCONFIG.py @@ -0,0 +1,298 @@ +import indigo +import asyncore +import json +import requests +import logging +from requests.auth import HTTPDigestAuth, HTTPBasicAuth +from collections import OrderedDict + +import Resources.CONSTANTS as CONST + + +class MLConfig: + + def __init__(self, host_address='blgw.local', user='admin', pwd='admin', debug=False): + self.debug =debug + + self._host = host_address + self._user = user + self._pwd = pwd + + self._download_data() + + def _download_data(self): + try: + indigo.server.log('Downloading configuration data from Gateway...', level=logging.WARNING) + url = 'http://' + self._host + '/mlgwpservices.json' + # try Basic Auth next (this is needed for the BLGW) + response = requests.get(url, auth=HTTPBasicAuth(self._user, self._pwd)) + + if response.status_code == 401: + # try Digest Auth first (this is needed for the MLGW) + response = requests.get(url, auth=HTTPDigestAuth(self._user, self._pwd)) + + if response.status_code == 401: + return + else: + # Once logged in successfully download and process the configuration data + configurationdata = json.loads(response.text) + self.configure_mlgw(configurationdata) + except ValueError: + pass + + def configure_mlgw(self, data): + if "BeoGateway" not in indigo.devices.folders: + indigo.devices.folder.create("BeoGateway") + folder_id = indigo.devices.folders.getId("BeoGateway") + + # Check to see if any devices already exist to avoid duplication + _nodes = [] + for node in indigo.devices.iter('uk.co.lukes_plugins.BeoGateway.plugin.AVrenderer'): + _nodes.append(int(node.address)) + + indigo.server.log('Processing Gateway configuration data...\n', level=logging.WARNING) + # Check to see if gateway device exists and create one if not + try: + gw = indigo.device.create( + protocol=indigo.kProtocol.Plugin, + name="Bang and Olufsen Gateway", + description="Automatically generated device for BeoGateway plugin:\n" + " - Please do not delete or rename!\n" + " - Editing device properties for advanced users only!", + deviceTypeId="BOGateway", + pluginId='uk.co.lukes_plugins.BeoGateway.plugin', + folder=folder_id, + address=1 + ) + except ValueError: + gw = indigo.devices['Bang and Olufsen Gateway'] + + try: + gateway_type = 'blgw' + gw.replacePluginPropsOnServer( + { + 'serial_no': data['sn'], + 'project': data['project'], + 'installer': str(data['installer']['name']), + 'contact': str(data['installer']['contact']), + 'isBLGW': 'BLGW' + } + ) + except KeyError: + gateway_type = 'mlgw' + gw.replacePluginPropsOnServer( + { + 'serial_no': data['sn'], + 'project': data['project'], + 'isBLGW': 'MLGW' + } + ) + + # Replace States + gw.updateStatesOnServer(CONST.gw_all_stb) + + # Loop over the config data to find the rooms, devices and sources in the installation + for zone in data["zones"]: + # Get rooms + if int(zone['number']) == 240: + continue + room = OrderedDict() + room['Room_Number'] = zone['number'] + if gateway_type == 'blgw': + # BLGW arranges rooms within zones + room['Zone'] = str(zone['name']).split('/')[0] + room['Room_Name'] = str(zone['name']).split('/')[1] + elif gateway_type == 'mlgw': + # MLGW has no zoning concept - devices are arranged in rooms only + room['Room_Name'] = str(zone['name']) + + # Get products + for product in zone["products"]: + device = OrderedDict() + + # Device identification + device['Device'] = str(product["name"]) + device['MLN'] = product["MLN"] + device['ML_ID'] = '' + try: + device['Serial_num'] = str(product["sn"]) + except KeyError: + device['Serial_num'] = 'NA' + + # Physical location + if gateway_type == 'blgw': + # BLGW arranges rooms within zones + device['Zone'] = str(zone['name']).split('/')[0] + device['Room'] = str(zone['name']).split('/')[1] + elif gateway_type == 'mlgw': + # MLGW has no zoning concept - devices are arranged in rooms only + device['Room'] = str(zone['name']) + device['Room_Number'] = str(zone["number"]) + + # Source information + device['Sources'] = dict() + + for source in product["sources"]: + src_name = str(source["name"]).strip().replace(' ', '_') + device['Sources'][src_name] = dict() + for selectCmd in source["selectCmds"]: + if gateway_type == 'blgw': + # get source information from the BLGW config file + if str(source['sourceId']) == '': + source_id = self._srcdictsanitize(CONST.beo4_commanddict, source['selectID']).upper() + else: + source_id = str(source['sourceId'].split(':')[0]) + source_id = self._srcdictsanitize(CONST.blgw_srcdict, source_id).upper() + device['Sources'][src_name]['source'] = source_id + device['Sources'][src_name]['uniqueID'] = str(source['sourceId']) + else: + # MLGW config file is structured differently + source_id = self._srcdictsanitize(CONST.beo4_commanddict, source['selectID']).upper() + device['Sources'][src_name]['source'] = source_id + source_tuple = (str(source_id), str(source["name"])) + device['Sources'][src_name]['BR1_cmd'] = \ + dict([('command', int(selectCmd["cmd"])), ('unit', int(selectCmd["unit"]))]) + + # Establish the channel list for sources with favourites lists + if 'channels' in source: + device['Sources'][src_name]['channels'] = [] + for channel in source['channels']: + c_num = 'c' + if gateway_type == 'blgw': + num = channel['selectSEQ'][::2] + else: + num = channel['selectSEQ'][:-1] + for n in num: + c_num += str(n) + c = (c_num, str(channel['name'])) + device['Sources'][src_name]['channels'].append(c) + + if source_tuple not in CONST.available_sources: + CONST.available_sources.append(source_tuple) + + # Create indigo devices to represent the B&O AV renderers in the installation + if int(device['MLN']) not in _nodes: + if self.debug: + indigo.server.log("New Device! Creating Indigo Device " + device['Device'], + level=logging.DEBUG) + + node = indigo.device.create( + protocol=indigo.kProtocol.Plugin, + name=device['Device'], + description="Automatically generated device for BeoGateway plugin:\n" + "- Device data sourced from gateway config:\n" + "- Please do not delete or rename!\n" + "- Editing device properties for advanced users only!", + deviceTypeId="AVrenderer", + pluginId='uk.co.lukes_plugins.BeoGateway.plugin', + folder=folder_id, + address=device['MLN'], + props={ + 'serial_no': device['Serial_num'], + 'mlid': 'NA', + 'zone': room['Zone'], + 'room': device['Room'], + 'roomnum': device['Room_Number'], + 'sources': device['Sources'] + } + ) + + # Update the device states + node.updateStatesOnServer(CONST.standby_state) + node.updateStateImageOnServer(indigo.kStateImageSel.PowerOff) + else: + # if node already exists, update the properties in case they have been updated + for node in indigo.devices.iter('uk.co.lukes_plugins.BeoGateway.plugin.AVrenderer'): + if int(node.address) == int(device['MLN']): + if self.debug: + indigo.server.log("Old Device! Updating Properties for " + device['Device'] + "\n", + level=logging.DEBUG) + # Update the name of the device + node.name = device['Device'] + node.description = "Automatically generated device for BeoGateway plugin:\n" \ + " - Device data sourced from gateway config:\n" \ + " - Please do not delete or rename!\n" \ + " - Editing device properties for advanced users only!" + node.replaceOnServer() + # Update the properties of the device + node_props = node.pluginProps + node_props.update( + { + 'serial_no': device['Serial_num'], + 'zone': room['Zone'], + 'room': device['Room'], + 'roomnum': device['Room_Number'], + 'sources': device['Sources'] + } + ) + node.replacePluginPropsOnServer(node_props) + + # Update the device states + node.stateListOrDisplayStateIdChanged() + node.updateStatesOnServer(CONST.standby_state) + node.updateStateImageOnServer(indigo.kStateImageSel.PowerOff) + indigo.device.moveToFolder(node.id, value=folder_id) + break + + # Keep track of the room data + CONST.rooms.append(room) + + # Report details of the configuration + n_devices = indigo.devices.len(filter="uk.co.lukes_plugins.BeoGateway.plugin.AVrenderer") - 1 + indigo.server.log('Found ' + str(n_devices) + ' AV Renderers!', level=logging.DEBUG) + for node in indigo.devices.iter('uk.co.lukes_plugins.BeoGateway.plugin.AVrenderer'): + indigo.server.log('\tMLN ' + str(node.address) + ': ' + str(node.name), level=logging.INFO) + indigo.server.log('\tFound ' + str(len(CONST.available_sources)) + ' Available Sources [Type, Name]:', + level=logging.DEBUG) + for i in range(len(CONST.available_sources)): + indigo.server.log('\t\t' + str(list(CONST.available_sources[i])), level=logging.INFO) + indigo.server.log('\tDone!\n', level=logging.DEBUG) + + @staticmethod + def get_masterlink_id(mlgw, mlcli): + # Identify the MasterLink ID of products + indigo.server.log("Finding MasterLink ID of products:", level=logging.WARNING) + if mlgw.is_connected and mlcli.is_connected: + for node in indigo.devices.iter('uk.co.lukes_plugins.BeoGateway.plugin.AVrenderer'): + indigo.server.log("Finding MasterLink ID of product " + node.name, level=logging.WARNING) + # Ping the device with a light timeout to elicit a ML telegram containing its ML_ID + mlgw.send_beo4_cmd(int(node.address), + CONST.CMDS_DEST.get("AUDIO SOURCE"), + CONST.BEO4_CMDS.get("LIGHT TIMEOUT")) + node_props = node.pluginProps + if node_props['serial_no'] in [None, 'NA', '']: + # If this is a MasterLink product it has no serial number... + # loop to until expected response received from ML Command Line Interface + test = True + while test: + try: + if mlcli.last_message['from_device'] == "MLGW" and \ + mlcli.last_message['payload_type'] == "MLGW_REMOTE_BEO4" and \ + mlcli.last_message['State_Update']['command'] == "Light Timeout": + + if node_props['mlid'] == 'NA': + node_props['mlid'] = mlcli.last_message.get('to_device') + node_props['serial_no'] = 'NA' + node.replacePluginPropsOnServer(node_props) + + indigo.server.log("\tMasterLink ID of product " + + node.name + " is " + node_props['mlid'] + ".\n", + level=logging.DEBUG) + test = False + except KeyError: + asyncore.loop(count=1, timeout=0.2) + + else: + # If this is a NetLink product then it has a serial number and no ML_ID + indigo.server.log("\tNetworkLink ID of product " + node.name + " is " + + node_props['serial_no'] + ". No MasterLink ID assigned.\n", + level=logging.DEBUG) + + # ######################################################################################## + # Utility Functions + @staticmethod + def _srcdictsanitize(d, s): + result = d.get(s) + if result is None: + result = s + return str(result) diff --git a/BeoGateway.indigoPlugin/Contents/Server Plugin/Resources/MLGW_CLIENT.py b/BeoGateway.indigoPlugin/Contents/Server Plugin/Resources/MLGW_CLIENT.py new file mode 100644 index 0000000..e10f46c --- /dev/null +++ b/BeoGateway.indigoPlugin/Contents/Server Plugin/Resources/MLGW_CLIENT.py @@ -0,0 +1,431 @@ +import indigo +import asynchat +import socket +import time +import logging +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', + debug=False, cb=None): + asynchat.async_chat.__init__(self) + + self.debug = debug + + 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.BEORMT1_CMDS = CONST.beoremoteone_commanddict + 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._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: + if self.debug: + indigo.server.log("Incomplete Telegram Received: " + str(list(self._received_data)) + " - Ignoring!\n", + level=logging.ERROR) + self._received_data = "" + + def found_terminator(self, msg_type, payload): + self.last_received = str(list(self._received_data)) + self.last_received_at = time.time() + + 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) + + 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["payload_type"] = payload_type + message["button"] = virtual_btn + message["action"] = virtual_action + + elif payload_type == "Login status": + if payload == 0: + indigo.server.log("\tAuthentication Failed: Incorrect Password", level=logging.ERROR) + self.handle_close() + message['Connected'] = "False" + return + else: + indigo.server.log("\tLogin successful to " + self._host, level=logging.DEBUG) + self.is_connected = True + message["payload_type"] = payload_type + message['Connected'] = "True" + self.get_serial() + + elif payload_type == "Pong": + self.is_connected = True + message = OrderedDict([('payload_type', 'Pong'), ('State_Update', dict([('CONNECTION', 'Online')]))]) + + elif payload_type == "Serial Number": + sn = '' + for c in payload: + sn += chr(c) + message["payload_type"] = payload_type + message['serial_Num'] = sn + + elif payload_type == "Source Status": + self._get_device_info(message, payload) + message["payload_type"] = payload_type + message["MLN"] = payload[0] + message["State_Update"] = OrderedDict() + message["State_Update"]["nowPlaying"] = 'Unknown' + message["State_Update"]["nowPlayingDetails"] = OrderedDict( + [ + ("channel_track", self._hexword(payload[4], payload[5])), + ("source_medium_position", self._hexword(payload[2], payload[3])), + ("picture_format", self._getdictstr(CONST.ml_pictureformatdict, payload[7])), + ] + ) + source = self._getselectedsourcestr(payload[1]).upper() + self._get_source_name(source, message) + message["State_Update"]["source"] = source + self._get_channel_track(message) + message["State_Update"]["state"] = self._getdictstr(CONST.sourceactivitydict, payload[6]) + + elif payload_type == "Picture and Sound Status": + self._get_device_info(message, payload) + message["payload_type"] = payload_type + message["MLN"] = payload[0] + message["State_Update"] = OrderedDict() + message["State_Update"]["sound_status"] = OrderedDict( + [ + ("mute_status", self._getdictstr(CONST.mlgw_soundstatusdict, payload[1])), + ("speaker_mode", self._getdictstr(CONST.mlgw_speakermodedict, payload[2])), + ("stereo_mode", self._getdictstr(CONST.mlgw_stereoindicatordict, payload[9])), + ] + ) + message["State_Update"]["picture_status"] = OrderedDict( + [ + ("screen1_mute", self._getdictstr(CONST.mlgw_screenmutedict, payload[4])), + ("screen1_active", self._getdictstr(CONST.mlgw_screenactivedict, payload[5])), + ("screen2_mute", self._getdictstr(CONST.mlgw_screenmutedict, payload[6])), + ("screen2_active", self._getdictstr(CONST.mlgw_screenactivedict, payload[7])), + ("cinema_mode", self._getdictstr(CONST.mlgw_cinemamodedict, payload[8])), + ] + ) + message["State_Update"]["state"] = 'Unknown' + message["volume"] = int(payload[3]) + + elif payload_type == "All standby notification": + message["payload_type"] = payload_type + 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]: + try: + message["Zone"] = room['Zone'].upper() + except KeyError: + pass + message["Room"] = room['Room_Name'].upper() + message["Type"] = self._getdictstr(CONST.mlgw_lctypedict, payload[1]).upper() + " COMMAND" + message["Device"] = 'Beo4/BeoRemote One' + message["payload_type"] = payload_type + message["room_number"] = str(payload[0]) + message["command"] = self._getbeo4commandstr(payload[2]) + + if message != '': + self._report(header, payload, message) + + def client_connect(self): + indigo.server.log('Connecting to host at ' + self._host + ', port ' + str(self._port), level=logging.WARNING) + self.set_terminator(b'\r\n') + # Create the socket + try: + self.create_socket(socket.AF_INET, socket.SOCK_STREAM) + except socket.error, e: + indigo.server.log("Error creating socket: " + str(e), level=logging.ERROR) + self.handle_close() + # Now connect + try: + self.connect((self._host, self._port)) + except socket.gaierror, e: + indigo.server.log("\tError with address: " + str(e), level=logging.ERROR) + self.handle_close() + except socket.timeout, e: + indigo.server.log("\tSocket connection timed out: " + str(e), level=logging.ERROR) + self.handle_close() + except socket.error, e: + indigo.server.log("\tError opening connection: " + str(e), level=logging.ERROR) + self.handle_close() + else: + self.is_connected = True + indigo.server.log("\tConnected to B&O Gateway", level=logging.DEBUG) + + def handle_connect(self): + login = [] + for c in self._user: + login.append(c) + login.append(0x00) + for c in self._pwd: + login.append(c) + + indigo.server.log("\tAttempting to Authenticate...", level=logging.WARNING) + self._send_cmd(CONST.MLGW_PL.get("LOGIN REQUEST"), login) + + def handle_close(self): + indigo.server.log(self.name + ": Closing socket", level=logging.ERROR) + self.is_connected = False + self.close() + + def _report(self, header, payload, message): + self.last_message = message + if self.messageCallBack: + self.messageCallBack(self.name, str(list(header)), str(list(payload)), message) + + # ######################################################################################## + # ##### mlgw send 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.timeout, e: + indigo.server.log("\tSocket connection to timed out: " + str(e), level=logging.ERROR) + self.handle_close() + except socket.error, e: + indigo.server.log("Error sending data: " + str(e), level=logging.ERROR) + self.handle_close() + else: + self.last_sent = str(bytearray(telegram)) + self.last_sent_at = time.time() + if msg_type != CONST.MLGW_PL.get("PING"): + indigo.server.log( + self.name + " >>-SENT--> " + + self._getpayloadtypestr(msg_type) + + ": " + + str(list(telegram)), + level=logging.INFO) + else: + if self.debug: + indigo.server.log( + self.name + " >>-SENT--> " + + self._getpayloadtypestr(msg_type) + + ": " + + str(list(telegram)), + level=logging.DEBUG + ) + + # Sleep to allow msg to arrive + time.sleep(0.2) + + # Ping the gateway + 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 = [ + mln, # byte[0] MLN + dest, # byte[1] Dest-Sel (0x00, 0x01, 0x05, 0x0f) + cmd, # byte[2] Beo4 Command + sec_source, # byte[3] Sec-Source + 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 = [ + mln, # byte[0] MLN + cmd, # byte[1] Beo4 Command + 0x00, # byte[2] AV (needs to be 0) + 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 = [ + mln, # byte[0] MLN + cmd, # byte[1] Beoremote One Command + unit, # byte[2] Unit + 0x00, # byte[3] AV (needs to be 0) + network_bit] # byte[4] Network_bit (0 = local source, 1 = network source) + self._send_cmd(CONST.MLGW_PL.get("BEOREMOTE ONE SOURCE SELECTION"), payload) + + def send_virtualbutton(self, button, action): + payload = [ + button, # byte[0] Button number + action] # byte[1] Action ID + self._send_cmd(CONST.MLGW_PL.get("MLGW VIRTUAL BUTTON EVENT"), payload) + + # ######################################################################################## + # ##### Utility functions + + @staticmethod + def _hexbyte(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 is 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 is None: + result = "UNKNOWN (type = " + self._hexbyte(payloadtype) + ")" + return str(result) + + def _getbeo4commandstr(self, command): + result = CONST.beo4_commanddict.get(command) + if result is None: + result = "CMD = " + self._hexbyte(command) + return result + + def _getvirtualactionstr(self, action): + result = CONST.mlgw_virtualactiondict.get(action) + if result is None: + result = "Action = " + self._hexbyte(action) + return result + + def _getselectedsourcestr(self, source): + result = CONST.ml_selectedsourcedict.get(source) + if result is None: + result = "SRC = " + self._hexbyte(source) + return result + + def _getspeakermodestr(self, source): + result = CONST.mlgw_speakermodedict.get(source) + if result is None: + result = "mode = " + self._hexbyte(source) + return result + + def _getdictstr(self, mydict, mykey): + result = mydict.get(mykey) + if result is None: + result = self._hexbyte(mykey) + return result + + @staticmethod + def _get_source_name(source, message): + if CONST.available_sources: + for src in CONST.available_sources: + if src[1] == source: + message["State_Update"]["sourceName"] = src[0] + return + # If source list exhausted then return Unknown + message["State_Update"]["sourceName"] = 'Unknown' + + @staticmethod + def _get_device_info(message, payload): + # Loop over the device list + for node in indigo.devices.iter('uk.co.lukes_plugins.BeoGateway.plugin.AVrenderer'): + # Get properties + node_props = node.pluginProps + + if int(node.address) == int(payload[0]): # Match MLN + try: + message["Zone"] = node_props['zone'].upper() + except KeyError: + pass + message["Room"] = node_props['room'].upper() + message["Type"] = "AV RENDERER" + message["Device"] = node.name + break + + def _get_channel_track(self, message): + try: + node = indigo.devices[message['Device']] + # Get properties + node_props = node.pluginProps + source_name = message["State_Update"]["sourceName"].strip().replace(" ", "_") + if self.debug: + indigo.server.log('searching device ' + node.name + ' channel list for source ' + source_name, + level=logging.DEBUG) + if 'channels' in node_props['sources'][source_name]: + for channel in node_props['sources'][source_name]['channels']: + if self.debug: + indigo.server.log(source_name + " Channel " + channel[0][1:] + " = " + channel[1], + level=logging.DEBUG) + if int(channel[0][1:]) == int( + message["State_Update"]['nowPlayingDetails']["channel_track"]): + message["State_Update"]["nowPlaying"] = channel[1] + if self.debug: + indigo.server.log("Current Channel: " + channel[1], level=logging.DEBUG) + return + + # If source list exhausted then return Unknown + message["State_Update"]["nowPlaying"] = 'Unknown' + except KeyError: + message["State_Update"]["nowPlaying"] = 'Unknown' diff --git a/BeoGateway.indigoPlugin/Contents/Server Plugin/Resources/MLtn_CLIENT.py b/BeoGateway.indigoPlugin/Contents/Server Plugin/Resources/MLtn_CLIENT.py new file mode 100644 index 0000000..de1361d --- /dev/null +++ b/BeoGateway.indigoPlugin/Contents/Server Plugin/Resources/MLtn_CLIENT.py @@ -0,0 +1,370 @@ +import indigo +import asynchat +import socket +import time +import logging +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', + debug=False, cb=None): + asynchat.async_chat.__init__(self) + + self.debug = debug + + 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._received_data += data + + def found_terminator(self): + self.last_received = self._received_data + self.last_received_at = time.time() + + 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: + if self.debug: + indigo.server.log("\t" + self._received_data, level=logging.DEBUG) + if self._received_data == 'incorrect password': + self.handle_close() + + else: + try: + self._decode(items) + except IndexError: + pass + + self._received_data = "" + + def _decode(self, items): + header = items[3][:-1] + telegram_starts = len(''.join(items[:4])) + 4 + telegram = self._received_data[telegram_starts:].replace('!', '').split('/') + message = OrderedDict() + + if telegram[0] == 'Monitor events ( keys: M, E, C, (spc), Q ) ----': + self.toggle_commands() + self.toggle_macros() + + 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.destselectordict, 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 + + @staticmethod + def _decode_action(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 + + @staticmethod + def _decode_command(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 + if self.messageCallBack: + self.messageCallBack(self.name, ''.join(header).upper(), ''.join(telegram), message) + + def client_connect(self): + indigo.server.log('Connecting to host at ' + self._host + ', port ' + str(self._port), level=logging.WARNING) + self.set_terminator(b'\r\n') + # Create the socket + try: + self.create_socket(socket.AF_INET, socket.SOCK_STREAM) + except socket.error, e: + indigo.server.log("Error creating socket: " + str(e), level=logging.ERROR) + self.handle_close() + # Now connect + try: + self.connect((self._host, self._port)) + except socket.gaierror, e: + indigo.server.log("\tError with address: " + str(e), level=logging.ERROR) + self.handle_close() + except socket.timeout, e: + indigo.server.log("\tSocket connection timed out: " + str(e), level=logging.ERROR) + self.handle_close() + except socket.error, e: + indigo.server.log("\tError opening connection: " + str(e), level=logging.ERROR) + self.handle_close() + else: + self.is_connected = True + indigo.server.log("\tConnected to B&O Gateway", level=logging.DEBUG) + + def handle_connect(self): + indigo.server.log("\tAttempting to Authenticate...", level=logging.WARNING) + self._send_cmd(self._pwd) + self._send_cmd("MONITOR") + + def handle_close(self): + indigo.server.log(self.name + ": Closing socket", level=logging.ERROR) + self.is_connected = False + self.close() + + def _send_cmd(self, telegram): + try: + self.push(telegram + "\r\n") + except socket.timeout, e: + indigo.server.log("\tSocket connection timed out: " + str(e), level=logging.ERROR) + self.handle_close() + except socket.error, e: + indigo.server.log("Error sending data: " + str(e), level=logging.ERROR) + self.handle_close() + else: + self.last_sent = telegram + self.last_sent_at = time.time() + indigo.server.log(self.name + " >>-SENT--> : " + telegram, level=logging.INFO) + time.sleep(0.2) + + def toggle_events(self): + try: + self.push('e') + except socket.error, e: + indigo.server.log("Error sending data: " + str(e), level=logging.ERROR) + self.handle_close() + + def toggle_macros(self): + try: + self.push('m') + except socket.error, e: + indigo.server.log("Error sending data: " + str(e), level=logging.ERROR) + self.handle_close() + + def toggle_commands(self): + try: + self.push('c') + except socket.error, e: + indigo.server.log("Error sending data: " + str(e), level=logging.ERROR) + self.handle_close() + + def ping(self): + self._send_cmd('') + + # ######################################################################################## + # ##### Utility functions + @staticmethod + def _hexbyte(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 is None: + result = "UNKNOWN (type=" + self._hexbyte(s) + ")" + return str(result) + + @staticmethod + def _srcdictsanitize(d, s): + result = d.get(s) + if result is None: + result = s + return str(result) diff --git a/BeoGateway.indigoPlugin/Contents/Server Plugin/Resources/Notify.app/Contents/Info.plist b/BeoGateway.indigoPlugin/Contents/Server Plugin/Resources/Notify.app/Contents/Info.plist new file mode 100644 index 0000000..429fb28 --- /dev/null +++ b/BeoGateway.indigoPlugin/Contents/Server Plugin/Resources/Notify.app/Contents/Info.plist @@ -0,0 +1,78 @@ + + + + + CFBundleAllowMixedLocalizations + + CFBundleDevelopmentRegion + en + CFBundleExecutable + applet + CFBundleIconFile + applet + CFBundleIdentifier + com.apple.ScriptEditor.id.Notify + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + Notify + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleSignature + aplt + LSMinimumSystemVersionByArchitecture + + x86_64 + 10.6 + + LSRequiresCarbon + + NSAppleEventsUsageDescription + This script needs to control other applications to run. + NSAppleMusicUsageDescription + This script needs access to your music to run. + NSCalendarsUsageDescription + This script needs access to your calendars to run. + NSCameraUsageDescription + This script needs access to your camera to run. + NSContactsUsageDescription + This script needs access to your contacts to run. + NSHomeKitUsageDescription + This script needs access to your HomeKit Home to run. + NSMicrophoneUsageDescription + This script needs access to your microphone to run. + NSPhotoLibraryUsageDescription + This script needs access to your photos to run. + NSRemindersUsageDescription + This script needs access to your reminders to run. + NSSiriUsageDescription + This script needs access to Siri to run. + NSSystemAdministrationUsageDescription + This script needs access to administer this system to run. + OSAAppletShowStartupScreen + + OSAAppletStayOpen + + WindowState + + bundleDividerCollapsed + + bundlePositionOfDivider + 0.0 + dividerCollapsed + + eventLogLevel + 2 + name + ScriptWindowState + positionOfDivider + 419 + savedFrame + 523 274 700 678 0 0 1680 1025 + selectedTab + description + + + diff --git a/BeoGateway.indigoPlugin/Contents/Server Plugin/Resources/Notify.app/Contents/MacOS/applet b/BeoGateway.indigoPlugin/Contents/Server Plugin/Resources/Notify.app/Contents/MacOS/applet new file mode 100644 index 0000000..7927912 Binary files /dev/null and b/BeoGateway.indigoPlugin/Contents/Server Plugin/Resources/Notify.app/Contents/MacOS/applet differ diff --git a/BeoGateway.indigoPlugin/Contents/Server Plugin/Resources/Notify.app/Contents/PkgInfo b/BeoGateway.indigoPlugin/Contents/Server Plugin/Resources/Notify.app/Contents/PkgInfo new file mode 100644 index 0000000..3253614 --- /dev/null +++ b/BeoGateway.indigoPlugin/Contents/Server Plugin/Resources/Notify.app/Contents/PkgInfo @@ -0,0 +1 @@ +APPLaplt \ No newline at end of file diff --git a/BeoGateway.indigoPlugin/Contents/Server Plugin/Resources/Notify.app/Contents/Resources/Scripts/main.scpt b/BeoGateway.indigoPlugin/Contents/Server Plugin/Resources/Notify.app/Contents/Resources/Scripts/main.scpt new file mode 100644 index 0000000..c478d35 Binary files /dev/null and b/BeoGateway.indigoPlugin/Contents/Server Plugin/Resources/Notify.app/Contents/Resources/Scripts/main.scpt differ diff --git a/BeoGateway.indigoPlugin/Contents/Server Plugin/Resources/Notify.app/Contents/Resources/applet.icns b/BeoGateway.indigoPlugin/Contents/Server Plugin/Resources/Notify.app/Contents/Resources/applet.icns new file mode 100644 index 0000000..33f6b86 Binary files /dev/null and b/BeoGateway.indigoPlugin/Contents/Server Plugin/Resources/Notify.app/Contents/Resources/applet.icns differ diff --git a/BeoGateway.indigoPlugin/Contents/Server Plugin/Resources/Notify.app/Contents/Resources/applet.rsrc b/BeoGateway.indigoPlugin/Contents/Server Plugin/Resources/Notify.app/Contents/Resources/applet.rsrc new file mode 100644 index 0000000..9471572 Binary files /dev/null and b/BeoGateway.indigoPlugin/Contents/Server Plugin/Resources/Notify.app/Contents/Resources/applet.rsrc differ diff --git a/BeoGateway.indigoPlugin/Contents/Server Plugin/Resources/Notify.app/Contents/Resources/description.rtfd/TXT.rtf b/BeoGateway.indigoPlugin/Contents/Server Plugin/Resources/Notify.app/Contents/Resources/description.rtfd/TXT.rtf new file mode 100644 index 0000000..4d315a3 --- /dev/null +++ b/BeoGateway.indigoPlugin/Contents/Server Plugin/Resources/Notify.app/Contents/Resources/description.rtfd/TXT.rtf @@ -0,0 +1,5 @@ +{\rtf1\ansi\ansicpg1252\cocoartf2636 +\cocoatextscaling0\cocoaplatform0{\fonttbl} +{\colortbl;\red255\green255\blue255;} +{\*\expandedcolortbl;;} +} \ No newline at end of file diff --git a/BeoGateway.indigoPlugin/Contents/Server Plugin/Resources/Scripts/blue.scpt b/BeoGateway.indigoPlugin/Contents/Server Plugin/Resources/Scripts/blue.scpt new file mode 100644 index 0000000..c68227c Binary files /dev/null and b/BeoGateway.indigoPlugin/Contents/Server Plugin/Resources/Scripts/blue.scpt differ diff --git a/BeoGateway.indigoPlugin/Contents/Server Plugin/Resources/Scripts/green.scpt b/BeoGateway.indigoPlugin/Contents/Server Plugin/Resources/Scripts/green.scpt new file mode 100644 index 0000000..fb4aa4a Binary files /dev/null and b/BeoGateway.indigoPlugin/Contents/Server Plugin/Resources/Scripts/green.scpt differ diff --git a/BeoGateway.indigoPlugin/Contents/Server Plugin/Resources/Scripts/red.scpt b/BeoGateway.indigoPlugin/Contents/Server Plugin/Resources/Scripts/red.scpt new file mode 100644 index 0000000..8fd8a2e Binary files /dev/null and b/BeoGateway.indigoPlugin/Contents/Server Plugin/Resources/Scripts/red.scpt differ diff --git a/BeoGateway.indigoPlugin/Contents/Server Plugin/Resources/Scripts/yellow.scpt b/BeoGateway.indigoPlugin/Contents/Server Plugin/Resources/Scripts/yellow.scpt new file mode 100644 index 0000000..2f082f5 Binary files /dev/null and b/BeoGateway.indigoPlugin/Contents/Server Plugin/Resources/Scripts/yellow.scpt differ diff --git a/BeoGateway.indigoPlugin/Contents/Server Plugin/Resources/__init__.py b/BeoGateway.indigoPlugin/Contents/Server Plugin/Resources/__init__.py new file mode 100644 index 0000000..a6131c1 --- /dev/null +++ b/BeoGateway.indigoPlugin/Contents/Server Plugin/Resources/__init__.py @@ -0,0 +1 @@ +# init diff --git a/BeoGateway.indigoPlugin/Contents/Server Plugin/plugin.py b/BeoGateway.indigoPlugin/Contents/Server Plugin/plugin.py new file mode 100644 index 0000000..184cfcf --- /dev/null +++ b/BeoGateway.indigoPlugin/Contents/Server Plugin/plugin.py @@ -0,0 +1,1277 @@ +import indigo +import asyncore +import json +import time +import logging +from datetime import datetime + +import Resources.CONSTANTS as CONST +import Resources.MLCONFIG as MLCONFIG +import Resources.MLGW_CLIENT as MLGW +import Resources.MLCLI_CLIENT as MLCLI +import Resources.BLHIP_CLIENT as BLHIP +import Resources.MLtn_CLIENT as MLtn +import Resources.ASBridge as ASBridge + + +class Plugin(indigo.PluginBase): + + def __init__(self, pluginId, pluginDisplayName, pluginVersion, pluginPrefs): + indigo.PluginBase.__init__(self, pluginId, pluginDisplayName, pluginVersion, pluginPrefs) + + self.pollinterval = 595 # ping sent out at 9:55, response evaluated at 10:00 + self.trackmode = self.pluginPrefs.get("trackMode") + self.verbose = self.pluginPrefs.get("verboseMode") + self.notifymode = self.pluginPrefs.get("notifyMode") + self.debug = self.pluginPrefs.get("debugMode") + self.default_audio_source = self.pluginPrefs.get("defaultAudio") + self.itunes_control = self.pluginPrefs.get("iTunesControl") + self.itunes_source = self.pluginPrefs.get("iTunesSource") + self.goto_flag = datetime(1982, 04, 01, 13, 30, 00, 342380) + + self.triggers = [] + + self.host = str(self.pluginPrefs.get('address')).encode('ascii') + self.port = [int(self.pluginPrefs.get('mlgw_port')), + int(self.pluginPrefs.get('hip_port')), + 23] # Telnet port - 23 + self.user = str(self.pluginPrefs.get('userID')).encode('ascii') + self.pwd = str(self.pluginPrefs.get('password')).encode('ascii') + + # Instantiate an AppleScriptBridge MusicController for N.MUSIC control of apple Music + self.iTunes = ASBridge.MusicController() + + def triggerStartProcessing(self, trigger): + self.triggers.append(trigger) + + def getDeviceStateList(self, dev): + stateList = indigo.PluginBase.getDeviceStateList(self, dev) + + if stateList is not None: + if dev.deviceTypeId in self.devicesTypeDict and dev.deviceTypeId == u"AVrenderer": + # Add dynamic states onto stateList for devices of type AV renderer + try: + sources = dev.pluginProps['sources'] + for source_name in sources: + stateList.append( + { + "Disabled": False, + "Key": "source." + source_name, + "StateKey": sources[source_name]['source'], + "StateLabel": "Source is " + source_name, + "TriggerLabel": "Source is " + source_name, + "Type": 50 + } + ) + except KeyError: + indigo.server.log("Device " + dev.name + " does not have state key 'sources'\n", + level=logging.WARNING) + pass + return stateList + + def deviceStartComm(self, dev): + dev.stateListOrDisplayStateIdChanged() + + def __del__(self): + indigo.PluginBase.__del__(self) + + # ######################################################################################## + # ##### Indigo UI menu constructors + @staticmethod + def zonelistgenerator(filter="", valuesDict=None, typeId="", targetId=0): + myarray = [("*", "All"), ("Main", "Main")] + for room in CONST.rooms: + if (room['Zone'], room['Zone']) not in myarray: + myarray.append((room['Zone'], room['Zone'])) + return myarray + + @staticmethod + def roomlistgenerator(filter="", valuesDict=None, typeId="", targetId=0): + myarray = [("99", "Any")] + for room in CONST.rooms: + myarray.append((room['Room_Number'], room['Room_Name'])) + return myarray + + @staticmethod + def roomlistgenerator2(filter="", valuesDict=None, typeId="", targetId=0): + myarray = [("*", "All"), ("global", "global")] + for room in CONST.rooms: + myarray.append((room['Room_Name'], room['Room_Name'])) + return myarray + + @staticmethod + def keylistgenerator(filter="", valuesDict=None, typeId="", targetId=0): + myarray = [] + for item in CONST.beo4_commanddict.items(): + myarray.append(item) + return myarray + + @staticmethod + def keylistgenerator2(filter="", valuesDict=None, typeId="", targetId=0): + myarray = [] + for item in CONST.beoremoteone_keydict.items(): + myarray.append(item) + return myarray + + @staticmethod + def beo4sourcelistgenerator(filter="", valuesDict=None, typeId="", targetId=0): + myarray = [] + for item in CONST.beo4_srcdict.items(): + myarray.append(item) + return myarray + + @staticmethod + def beo4sourcelistgenerator2(filter="", valuesDict=None, typeId="", targetId=0): + myarray = [('Any Source', 'Any Source'), ('Any Audio', 'Any Audio'), ('Any Video', 'Any Video')] + for item in CONST.beo4_srcdict.items(): + myarray.append(item) + return myarray + + @staticmethod + def br1sourcelistgenerator(filter="", valuesDict=None, typeId="", targetId=0): + myarray = [] + for item in CONST.available_sources: + myarray.append(item) + return myarray + + @staticmethod + def destinationlistgenerator(filter="", valuesDict=None, typeId="", targetId=0): + myarray = [] + for item in CONST.destselectordict.items(): + myarray.append(item) + return myarray + + @staticmethod + def srcactivitylistgenerator(filter="", valuesDict=None, typeId="", targetId=0): + myarray = [(0x00, "Unknown")] + for item in CONST.sourceactivitydict.items(): + if item not in myarray: + myarray.append(item) + return myarray + + @staticmethod + def hiptypelistgenerator(filter="", valuesDict=None, typeId="", targetId=0): + myarray = [] + for item in CONST.blgw_devtypes.items(): + myarray.append(item) + return myarray + + # ######################################################################################## + # ##### Indigo UI Prefs + def set_login(self, ui): + # If LogIn data is updated in config, update the values in the plugin + self.user = str(ui.get('userID')).encode('ascii') + self.pwd = str(ui.get('password')).encode('ascii') + indigo.server.log("BeoGateway device login details updated!", level=logging.DEBUG) + + def set_gateway(self, ui): + # If gateway network address data is updated in config, update the values in the plugin + self.host = str(ui.get('address')).encode('ascii') + self.port = [int(ui.get('mlgw_port')), + int(ui.get('hip_port')), + 23] # Telnet port - 23 + indigo.server.log("BeoGateway device network address details updated!", level=logging.DEBUG) + + def set_trackmode(self, ui): + # If Track Mode setting is updated in config, update the value in the plugin + self.trackmode = ui.get("trackMode") + indigo.server.log("Track reporting set to " + str(self.trackmode), level=logging.DEBUG) + + def set_verbose(self, ui): + # If Verbose Mode setting is updated in config, update the value in the plugin + self.verbose = ui.get("verboseMode") + indigo.server.log("Verbose Mode set to " + str(self.verbose), level=logging.DEBUG) + + def set_notifymode(self, ui): + # If Notify Mode setting is updated in config, update the value in the plugin + self.notifymode = ui.get("notifyMode") + indigo.server.log("Verbose Mode set to " + str(self.notifymode), level=logging.DEBUG) + + def set_debug(self, ui): + # If Debug Mode setting is updated in config, update the value in the plugin + self.debug = ui.get("debugMode") + + # Set the debug flag for the clients + self.mlcli.debug = self.debug + self.mlgw.debug = self.debug + if self.mlcli.isBLGW: + self.blgw.debug = self.debug + else: + self.mltn.debug = self.debug + + # Report the debug flag change + indigo.server.log("Debug Mode set to " + str(self.debug), level=logging.DEBUG) + + def set_music_control(self, ui): + # If Apple Music Control setting is updated in config, update the value in the plugin + self.itunes_control = ui.get("iTunesControl") + self.itunes_source = ui.get("iTunesSource") + + if self.itunes_control: + indigo.server.log("Apple Music Control enabled on source: " + str(self.itunes_source), level=logging.DEBUG) + else: + indigo.server.log("Apple Music Control set to " + str(self.itunes_control), level=logging.DEBUG) + + def set_default_audio(self, ui): + # Define default audio source for AVrenderers + self.default_audio_source = ui.get("defaultAudio") + indigo.server.log("Default Audio Source set to " + str(self.default_audio_source), level=logging.DEBUG) + + # ######################################################################################## + # ##### Indigo UI Actions + def send_beo_4key(self, action, device): + device_id = int(device.address) + key_code = int(action.props.get("keyCode", 0)) + destination = int(action.props.get("destination", 0)) + link = int(action.props.get("linkcmd", 0)) + if destination == 0x06: + self.mlgw.send_beo4_cmd(device_id, destination, key_code, 0x01, link) + else: + self.mlgw.send_beo4_cmd(device_id, destination, key_code, 0x00, link) + + def send_beo4_src(self, action, device): + device_id = int(device.address) + key_code = int(action.props.get("keyCode", 0)) + destination = int(action.props.get("destination", 0)) + link = int(action.props.get("linkcmd", 0)) + if destination == 0x06: + self.mlgw.send_beo4_cmd(device_id, destination, key_code, 0x01, link) + else: + self.mlgw.send_beo4_cmd(device_id, destination, key_code, 0x00, link) + + def send_br1_key(self, action, device): + device_id = int(device.address) + key_code = int(action.props.get("keyCode", 0)) + network_bit = int(action.props.get("netBit", 0)) + self.mlgw.send_beoremoteone_cmd(device_id, key_code, network_bit) + + def send_br1_src(self, action, device): + device_id = int(device.address) + key_code = str(action.props.get("keyCode", 0)) + network_bit = int(action.props.get("netBit", 0)) + try: + key_code = CONST.beoremoteone_commanddict.get(key_code) + self.mlgw.send_beoremoteone_select_source(device_id, key_code[0], key_code[1], network_bit) + except KeyError: + pass + + def send_hip_cmd(self, action): + zone = str(action.props.get("zone", 0)) + room = str(action.props.get("room", 0)) + device_type = str(action.props.get("devType", 0)) + device_id = str(action.props.get("deviceID", 0)) + hip_command = str(action.props.get("hip_cmd", 0)) + telegram = "c " + zone + "/" + room + "/" + device_type + "/" + device_id + "/" + hip_command + if self.mlcli.isBLGW: + self.blgw.send_cmd(telegram) + + def send_hip_cmd2(self, action): + hip_command = str(action.props.get("hip_cmd", 0)) + if self.mlcli.isBLGW: + self.blgw.send_cmd(hip_command) + + def send_hip_query(self, action): + zone = str(action.props.get("zone", 0)) + room = str(action.props.get("room", 0)) + device_type = str(action.props.get("devType", 0)) + device_id = str(action.props.get("deviceID", 0)) + if self.mlcli.isBLGW: + self.blgw.query(zone, room, device_type, device_id) + + def request_state_update(self, action, device): + action_id = str(action.props.get("id", 0)) + zone = str(device.pluginProps['zone']) + room = str(device.pluginProps['room']) + device_type = "AV renderer" + device_id = str(device.name) + if self.mlcli.isBLGW: + self.blgw.query(zone, room, device_type, device_id) + + def send_virtual_button(self, action): + button_id = int(action.props.get("buttonID", 0)) + button_action = int(action.props.get("action", 0)) + self.mlgw.send_virtualbutton(button_id, button_action) + + def post_notification(self, action): + title = str(action.props.get("title", 0)) + body = str(action.props.get("body", 0)) + self.iTunes.notify(body, title) + + def all_standby(self, action): + self.mlgw.send_beo4_cmd(1, CONST.CMDS_DEST.get("ALL PRODUCTS"), CONST.BEO4_CMDS.get("STANDBY")) + + def request_serial_number(self): + self.mlgw.get_serial() + + def request_device_update(self): + if self.mlcli.isBLGW: + self.blgw.query(dev_type="AV renderer") + + def reset_clients(self): + self.check_connection(self.mlgw) + self.check_connection(self.mlcli) + + if self.mlcli.isBLGW: + self.check_connection(self.blgw) + else: + self.check_connection(self.mltn) + + # ######################################################################################## + # ##### Indigo UI Events + def light_key(self, message): + room = message['room_number'] + key_code = CONST.BEO4_CMDS.get(message['command'].upper()) + + for trigger in self.triggers: + props = trigger.globalProps["uk.co.lukes_plugins.BeoGateway.plugin"] + if trigger.pluginTypeId == "lightKey" and \ + (props["room"] == str(99) or props["room"] == str(room)) and \ + props["keyCode"] == str(key_code): + indigo.trigger.execute(trigger) + break + + def control_key(self, message): + room = message['room_number'] + key_code = CONST.BEO4_CMDS.get(message['command'].upper()) + + for trigger in self.triggers: + props = trigger.globalProps["uk.co.lukes_plugins.BeoGateway.plugin"] + if trigger.pluginTypeId == "controlkey" and \ + (props["room"] == str(99) or props["room"] == str(room)) and \ + props["keyCode"] == str(key_code): + indigo.trigger.execute(trigger) + break + + def beo4_key(self, message): + source = message['State_Update']['source'] + source_type = message['State_Update']['source'] + key_code = CONST.BEO4_CMDS.get(message['State_Update']['command'].upper()) + + for trigger in self.triggers: + props = trigger.globalProps["uk.co.lukes_plugins.BeoGateway.plugin"] + if trigger.pluginTypeId == "beo4Key" and props["keyCode"] == str(key_code): + if props["sourceType"] == "Any Source": + indigo.trigger.execute(trigger) + break + elif props["sourceType"] == "Any Audio" and "AUDIO" in source_type: + indigo.trigger.execute(trigger) + break + elif props["sourceType"] == "Any Audio" and "AUDIO" in source_type: + indigo.trigger.execute(trigger) + break + elif props["sourceType"] == source: + indigo.trigger.execute(trigger) + break + + def virtual_button(self, message): + button_id = message['button'] + action = message['action'] + for trigger in self.triggers: + props = trigger.globalProps["uk.co.lukes_pugins.mlgw.plugin"] + if trigger.pluginTypeId == "virtualButton" and \ + props["buttonID"] == str(button_id) and \ + props["action"] == str(action): + indigo.trigger.execute(trigger) + break + + # ######################################################################################## + # ##### Indigo UI Device Controls + def actionControlDevice(self, action, node): + """ Callback Method to Control a Relay Device. """ + if action.deviceAction == indigo.kDeviceAction.TurnOn: + self._dev_on(node) + elif action.deviceAction == indigo.kDeviceAction.TurnOff: + self._dev_off(node) + elif action.deviceAction == indigo.kDeviceAction.Toggle: + if node.states["onOffState"]: + self._dev_off(node) + else: + self._dev_on(node) + elif action.deviceAction == indigo.kDeviceAction.RequestStatus: + self._status_request(node) + + def _dev_on(self, node): + indigo.server.log(node.name + " turned On") + + # Get a local copy of the gateway states from server + active_renderers = self.gateway.states['AudioRenderers'] + active_source = self.gateway.states['currentAudioSource'] + active_sourceName = self.gateway.states['currentAudioSourceName'] + + if self.debug: + indigo.server.log('Active renderers: ' + active_renderers, level=logging.DEBUG) + indigo.server.log('Active Audio Source: ' + active_source, level=logging.DEBUG) + + # Join if music already playing + if active_renderers != '' and active_source != 'Unknown': + # Send Beo4 command + source = active_source + self.mlgw.send_beo4_cmd( + int(node.address), + int(CONST.CMDS_DEST.get("AUDIO SOURCE")), + int(CONST.BEO4_CMDS.get(source)) + ) + + # Update device states + sourceName = active_sourceName + key_value_list = [ + {'key': 'onOffState', 'value': True}, + {'key': 'playState', 'value': 'Play'}, + {'key': 'source', 'value': sourceName}, + {'key': 'mute', 'value': False}, + ] + + if self.debug: + indigo.server.log( + node.name + " joining current audio experience " + sourceName + " (" + source + + "). Joining active renderer(s): " + active_renderers, + level=logging.DEBUG + ) + + # Otherwise start default music source + else: + # Send Beo4 command + source = self.default_audio_source + self.mlgw.send_beo4_cmd( + int(node.address), + int(CONST.CMDS_DEST.get("AUDIO SOURCE")), + int(CONST.BEO4_CMDS.get(source)) + ) + + # Update device states + sourceName = dict(CONST.available_sources).get(self.default_audio_source) + key_value_list = [ + {'key': 'onOffState', 'value': True}, + {'key': 'playState', 'value': 'Play'}, + {'key': 'source', 'value': sourceName}, + {'key': 'mute', 'value': False}, + ] + + if self.debug: + indigo.server.log( + node.name + " starting audio experience " + sourceName + " (" + source + ").", + level=logging.DEBUG + ) + + # Update states on server + node.updateStatesOnServer(key_value_list) + node.updateStateImageOnServer(indigo.kStateImageSel.AvPlaying) + + # Add device to active renderers lists and update gateway + self.add_to_renderers_list(node.name, 'Audio') + key_value_list = [ + {'key': 'currentAudioSource', 'value': source}, + {'key': 'currentAudioSourceName', 'value': sourceName}, + ] + self.gateway.updateStatesOnServer(key_value_list) + + def _dev_off(self, node): + indigo.server.log(node.name + " turned Off") + + # Send Beo4 command + self.mlgw.send_beo4_cmd( + int(node.address), + int(CONST.CMDS_DEST.get("AUDIO SOURCE")), + int(CONST.BEO4_CMDS.get('STANDBY')) + ) + + # Update states to standby values + node.updateStatesOnServer(CONST.standby_state) + node.updateStateImageOnServer(indigo.kStateImageSel.PowerOff) + + # Remove device from active renderers lists + self.remove_from_renderers_list(node.name, 'All') + + def _status_request(self, node): + if node.pluginProps['serial_no'] == 'NA': # Check if this is a netlink device + indigo.server.log(node.name + " does not support status requests") + else: # If netlink, request a status update + self.blgw.query(dev_type="AV renderer", device=node.name) + + # ######################################################################################## + # Define callback function for message return from B&O Gateway + def cb(self, name, header, payload, message): + # ######################################################################################## + # Message handler + # Handle Beo4 Command Events + try: + if message['payload_type'] == "BEO4_KEY": + self.beo4_key(message) + except KeyError: + pass + + # Handle Light and Command Events + try: + if message['Type'] == "LIGHT COMMAND": + self.light_key(message) + elif message['Type'] == "CONTROL COMMAND": + self.control_key(message) + except KeyError: + pass + + # Handle Virtual Button Events + try: + if message['payload_type'] == "MLGW virtual button event": + self.virtual_button(message) + except KeyError: + pass + + # Handle all standby events + try: + if message["command"] == "All Standby": + for node in indigo.devices.iter('uk.co.lukes_plugins.BeoGateway.plugin.AVrenderer'): + node.updateStatesOnServer(CONST.standby_state) + node.updateStateImageOnServer(indigo.kStateImageSel.PowerOff) + + indigo.devices['Bang and Olufsen Gateway'].updateStatesOnServer(CONST.gw_all_stb) + except KeyError: + pass + + # Handle AV Events + try: + # Use regular incoming messages to sync nowPlaying data + if self.gateway.states['AudioRenderers'] != '' and \ + self.gateway.states['currentAudioSource'] == str(self.itunes_source) and \ + self.itunes_control: + self._get_itunes_track_info(message) + + # For messages of type AV RENDERER, scan keys to update device states + if message["Type"] == "AV RENDERER": + # Tidy up messages + self.av_sanitise(message) + # Filter messages that don't constitute meaningful state updates + actionable = self.filter_messages(message) + if self.debug: + indigo.server.log("Process message: " + str(actionable), level=logging.WARNING) + if actionable: + # Keep track of what sources are playing on the network + self.src_tracking(message) + # Update individual devices based on updates + self.dev_update(message) + except KeyError: + pass + + # Report message content to log + if self.verbose: + if 'State_Update' in message and 'CONNECTION' in message['State_Update']: + # Don't print pong responses from regular client ping to check sockets are open - + # approx every 600 seconds + pass + elif 'Device' in message and message['Device'] == 'Clock': + # Don't print the Clock sync telegrams from the HIP - + # approx every 60 seconds + pass + elif 'payload_type' in message and message['payload_type'] == '0x14': + # Don't print the Clock sync telegrams from the ML - + # approx every 6 seconds + pass + elif 'payload_type' in message and message['payload_type'] == 'CLOCK' and not self.debug: + # Don't print the Clock message telegrams from the ML + pass + else: + self.message_log(name, header, payload, message) + + # ######################################################################################## + # AV Handler Functions + + # #### Message Conditioning + def av_sanitise(self, message): + # Sanitise AV messages + try: # Check for missing source information + if message['State_Update']['source'] in [None, 'None', '']: + message['State_Update']['source'] = 'Unknown' + message['State_Update']['sourceName'] = 'Unknown' + + # Update for standby condition + message['State_Update']['state'] = "Standby" + except KeyError: + pass + + try: # Check for unknown state + if message['State_Update']['state'] in [None, 'None', '']: + message['State_Update']['state'] = 'Unknown' + except KeyError: + pass + + try: # Sanitise unknown Channel/Tracks + if message['State_Update']['nowPlayingDetails']['channel_track'] in [0, 255, '0', '255']: + del message['State_Update']['nowPlayingDetails']['channel_track'] + except KeyError: + pass + + try: # Add sourceName if not in message block + if 'sourceName' not in message['State_Update']: + # Find the sourceName from the source list for this device + if 'Device' in message: # If device known use local source data + self.find_source_name(message['State_Update']['source'], + indigo.devices[message['Device']].pluginProps['sources']) + else: # If device not known use global source data + message['State_Update']['sourceName'] = \ + dict(CONST.available_sources).get(message['State_Update']['source']) + except KeyError: + pass + + try: # Catch GOTO_SOURCE commands and set the goto_flag + if message['payload_type'] == 'GOTO_SOURCE': + self.goto_flag = datetime.now() + if self.debug: + indigo.server.log("GOTO_SOURCE command received - goto_flag set", level=logging.WARNING) + except KeyError: + pass + + def filter_messages(self, message): + # Filter state updates that are: + # 1. Standby states that are received between source changes: + # If device is changing source the state changes as follows [Old Source -> Standby -> New Source]. + # If the standby condition is processed, the New Source state will be filtered by the condition below + # + # 2. Play states that received <1.5 seconds after standby state set: + # Some messages come in on the ML and HIP protocols relating to previous state etc. + # These can be ignored to avoid false states for the indigo devices + try: + for node in indigo.devices.iter('uk.co.lukes_plugins.BeoGateway.plugin.AVrenderer'): + if node.name == message['Device']: + + # Get time since last state update for this device + time_delta1 = datetime.now() - node.lastChanged + time_delta1 = time_delta1.total_seconds() + # Get time since last GOTO_SOURCE command + time_delta2 = datetime.now() - self.goto_flag + time_delta2 = time_delta2.total_seconds() + + # If standby command received <2.0 seconds after GOTO_SOURCE command , ignore + if 'state' in message['State_Update'] and message['State_Update']['state'] == "Standby" \ + and time_delta2 < 2.0: # Condition 1 + if self.debug: + indigo.server.log(message['Device'] + " ignoring Standby: " + str(round(time_delta2, 2)) + + " seconds elapsed since GOTO_STATE command - ignoring message!", + level=logging.DEBUG) + return False + + # If message received <1.5 seconds after standby state, ignore + elif node.states['playState'] == "Standby" and time_delta1 < 1.5: # Condition 2 + if self.debug: + indigo.server.log(message['Device'] + " in Standby: " + str(round(time_delta1, 2)) + + " seconds elapsed since last state update - ignoring message!", + level=logging.DEBUG) + return False + else: + return True + except KeyError: + return False + + # #### State tracking + def src_tracking(self, message): + # Track active renderers via gateway device + try: + # If new source is an audio source then update the gateway accordingly + if message['State_Update']['source'] in CONST.source_type_dict.get('Audio Sources'): + try: + # Keep track of which devices are playing audio sources + if message['Device'] not in self.gateway.states['AudioRenderers'] and \ + message['State_Update']['state'] not in ['Standby', 'Unknown', 'None']: + + # Log current audio source (MasterLink allows a single audio source for distribution) + source = message['State_Update']['source'] + sourceName = dict(CONST.available_sources).get(source) + + self.gateway.updateStateOnServer('currentAudioSource', value=source) + self.gateway.updateStateOnServer('currentAudioSourceName', value=sourceName) + + try: + self.gateway.updateStateOnServer('nowPlaying', value=message['State_Update']['nowPlaying']) + except KeyError: + self.gateway.updateStateOnServer('nowPlaying', value='Unknown') + + self.add_to_renderers_list(message['Device'], 'Audio') + + # Remove device from Video Renderers list if it is on there + if message['Device'] in self.gateway.states['VideoRenderers']: + self.remove_from_renderers_list(message['Device'], 'Video') + + except KeyError: + pass + + # If source is N.Music then control accordingly + if message['State_Update']['source'] == str(self.itunes_source) and self.itunes_control: + self.iTunes_transport_control(message) + + # If new source is an video source then update the gateway accordingly + elif message['State_Update']['source'] in CONST.source_type_dict.get('Video Sources'): + try: + # Keep track of which devices are playing video sources + if message['Device'] not in self.gateway.states['VideoRenderers'] and \ + message['State_Update']['state'] not in ['Standby', 'Unknown', 'None']: + self.add_to_renderers_list(message['Device'], 'Video') + + # Remove device from Audio Renderers list if it is on there + if message['Device'] in self.gateway.states['AudioRenderers']: + self.remove_from_renderers_list(message['Device'], 'Audio') + + except KeyError: + pass + except KeyError: + pass + + def dev_update(self, message): + # Update device states + try: + for node in indigo.devices.iter('uk.co.lukes_plugins.BeoGateway.plugin.AVrenderer'): + if node.name == message['Device']: + # Handle Standby state + if message['State_Update']['state'] == "Standby": + # Update states to standby values + node.updateStatesOnServer(CONST.standby_state) + node.updateStateImageOnServer(indigo.kStateImageSel.PowerOff) + + # Remove the device from active renderers list + self.remove_from_renderers_list(message['Device'], 'All') + + # Post state update in Apple Notification Centre + if self.notifymode: + self.iTunes.notify(node.name + " now in Standby", "Device State Update") + return + + # If device not in standby then update its state information + # get current states as list # Index + last_state = [ + node.states['playState'], # 0 + node.states['source'], # 1 + node.states['nowPlaying'], # 2 + node.states['channelTrack'], # 3 + node.states['volume'], # 4 + node.states['mute'], # 5 + node.states['onOffState'] # 6 + ] + + # Initialise new state list - the device is not in standby so it must be on + new_state = last_state[:] + new_state[5] = False + new_state[6] = True + + # Update device states with values from message + if 'state' in message['State_Update']: + if message['State_Update']['state'] not in ['None', 'Standby', '', None]: + if last_state[0] == 'Standby' and message['State_Update']['state'] == 'Unknown': + new_state[0] = 'Play' + elif last_state[0] != 'Standby' and message['State_Update']['state'] == 'Unknown': + pass + else: + new_state[0] = message['State_Update']['state'] + + if 'sourceName' in message['State_Update'] and message['State_Update']['sourceName'] != 'Unknown': + # Sanitise source name to avoid indigo key errors (remove whitespace) + source_name = message['State_Update']['sourceName'].strip().replace(" ", "_") + new_state[1] = source_name + + if 'nowPlaying' in message['State_Update']: + # Update now playing information unless the state value is empty or unknown + if message['State_Update']['nowPlaying'] not in ['', 'Unknown']: + new_state[2] = message['State_Update']['nowPlaying'] + # If the state value is empty/unknown and the source has not changed then no update required + elif new_state[1] != last_state[1]: + # If the state has changed and the value is unknown, then set as "Unknown" + new_state[2] = 'Unknown' + + if 'nowPlayingDetails' in message['State_Update'] and \ + 'channel_track' in message['State_Update']['nowPlayingDetails']: + new_state[3] = message['State_Update']['nowPlayingDetails']['channel_track'] + elif new_state[1] != last_state[1]: + # If the state has changed and the value is unknown, then set as "Unknown" + new_state[2] = 0 + + if 'volume' in message['State_Update']: + new_state[4] = message['State_Update']['volume'] + + if new_state != last_state: + # Update states on server + key_value_list = [ + {'key': 'playState', 'value': new_state[0]}, + {'key': 'source', 'value': new_state[1]}, + {'key': 'nowPlaying', 'value': new_state[2]}, + {'key': 'channelTrack', 'value': new_state[3]}, + {'key': 'volume', 'value': new_state[4]}, + {'key': 'mute', 'value': new_state[5]}, + {'key': 'onOffState', 'value': new_state[6]}, + ] + node.updateStatesOnServer(key_value_list) + + # Post notifications Notifications + if self.notifymode: + self.notifications(node.name, last_state, new_state) + + # Update state image on server + if new_state[0] == "Stopped": + node.updateStateImageOnServer(indigo.kStateImageSel.AvPaused) + elif new_state[0] not in ['None', 'Unknown', 'Standby', '', None]: + node.updateStateImageOnServer(indigo.kStateImageSel.AvPlaying) + + # If audio source active, update any other active audio renderers accordingly + try: + if new_state[0] not in ['None', 'Unknown', 'Standby', '', None] and \ + message['State_Update']['source'] in CONST.source_type_dict.get('Audio Sources'): + self.all_audio_nodes_update(new_state, node.name, message['State_Update']['source']) + except KeyError: + pass + + break + except KeyError: + pass + + def all_audio_nodes_update(self, new_state, dev, source): + # Loop over all active audio renderers to update them with the latest audio state + for node in indigo.devices.iter('uk.co.lukes_plugins.BeoGateway.plugin.AVrenderer'): + if node.name in self.gateway.states['AudioRenderers'] and node.name != dev: + # Get current state of this node + last_state = [ + node.states['playState'], + node.states['source'], + node.states['nowPlaying'], + node.states['channelTrack'], + node.states['volume'], + node.states['mute'], + ] + + if last_state[:4] != new_state[:4]: + # Update the play state for active Audio renderers if new values are different from current ones + key_value_list = [ + {'key': 'onOffState', 'value': True}, + {'key': 'playState', 'value': new_state[0]}, + {'key': 'source', 'value': new_state[1]}, + {'key': 'nowPlaying', 'value': new_state[2]}, + {'key': 'channelTrack', 'value': new_state[3]}, + {'key': 'mute', 'value': False}, + ] + node.updateStatesOnServer(key_value_list) + node.updateStateImageOnServer(indigo.kStateImageSel.AvPlaying) + + # Update the gateway + if self.gateway.states['currentAudioSourceName'] != new_state[1]: + # If the source has changed, update both source and nowPlaying + sourceName = new_state[1] + + key_value_list = [ + {'key': 'currentAudioSource', 'value': source}, + {'key': 'currentAudioSourceName', 'value': sourceName}, + {'key': 'nowPlaying', 'value': new_state[2]}, + ] + self.gateway.updateStatesOnServer(key_value_list) + + elif self.gateway.states['nowPlaying'] != new_state[2] and new_state[2] not in ['', 'Unknown']: + # If the source has not changed, and nowPlaying is not Unknown, update nowPlaying + self.gateway.updateStateOnServer('nowPlaying', value=new_state[2]) + + # #### Active renderer list maintenance + def add_to_renderers_list(self, dev, av): + if av == "Audio": + renderer_list = 'AudioRenderers' + renderer_count = 'nAudioRenderers' + else: + renderer_list = 'VideoRenderers' + renderer_count = 'nVideoRenderers' + + # Retrieve the renderers and convert from string to list + renderers = self.gateway.states[renderer_list].split(', ') + + # Sanitise the list for stray blanks + if '' in renderers: + renderers.remove('') + + # Add device to list if not already on there + if dev not in renderers: + renderers.append(dev) + self.gateway.updateStateOnServer(renderer_list, value=', '.join(renderers)) + self.gateway.updateStateOnServer(renderer_count, value=len(renderers)) + + def remove_from_renderers_list(self, dev, av): + # Remove devices from renderers lists when the enter standby mode + if av in ['Audio', 'All']: + if dev in self.gateway.states['AudioRenderers']: + renderers = self.gateway.states['AudioRenderers'].split(', ') + renderers.remove(dev) + + self.gateway.updateStateOnServer('AudioRenderers', value=', '.join(renderers)) + self.gateway.updateStateOnServer('nAudioRenderers', value=len(renderers)) + + if av in ['Video', 'All']: + if dev in self.gateway.states['VideoRenderers']: + renderers = self.gateway.states['VideoRenderers'].split(', ') + renderers.remove(dev) + + self.gateway.updateStateOnServer('VideoRenderers', value=', '.join(renderers)) + self.gateway.updateStateOnServer('nVideoRenderers', value=len(renderers)) + + # If no audio sources are playing then update the gateway states + if self.gateway.states['AudioRenderers'] == '': + key_value_list = [ + {'key': 'AudioRenderers', 'value': ''}, + {'key': 'nAudioRenderers', 'value': 0}, + {'key': 'currentAudioSource', 'value': 'Unknown'}, + {'key': 'currentAudioSourceName', 'value': 'Unknown'}, + {'key': 'nowPlaying', 'value': 'Unknown'}, + ] + self.gateway.updateStatesOnServer(key_value_list) + + # If no AV renderers are playing N.Music, stop iTunes playback + if self.itunes_control: + self.iTunes.stop() + + # #### Helper functions + @staticmethod + def find_source_name(source, sources): + # Get the sourceName for source + for source_name in sources: + if sources[source_name]['source'] == str(source): + return str(sources[source_name]).split()[0] + + # if source list exhausted and no valid name found return Unknown + return 'Unknown' + + @staticmethod + # Get the source ID for sourceName + def get_source(sourceName, sources): + for source_name in sources: + if source_name == sourceName: + return str(sources[source_name]['source']) + + # if source list exhausted and no valid name found return Unknown + return 'Unknown' + + # ######################################################################################## + # Apple Music Control and feedback + def iTunes_transport_control(self, message): + # Transport controls for iTunes + try: # If N.MUSIC command, trigger appropriate self.iTunes control + if message['State_Update']['state'] not in ["", "Standby"]: + self.iTunes.play() + except KeyError: + pass + + try: # If N.MUSIC selected and Beo4 command received then run appropriate transport commands + if message['State_Update']['command'] == "Go/Play": + self.iTunes.play() + + elif message['State_Update']['command'] == "Stop": + self.iTunes.pause() + + elif message['State_Update']['command'] == "Exit": + self.iTunes.stop() + + elif message['State_Update']['command'] == "Step Up": + self.iTunes.next_track() + + elif message['State_Update']['command'] == "Step Down": + self.iTunes.previous_track() + + elif message['State_Update']['command'] == "Wind": + self.iTunes.wind(15) + + elif message['State_Update']['command'] == "Rewind": + self.iTunes.rewind(-15) + + elif message['State_Update']['command'] == "Shift-1/Random": + self.iTunes.shuffle() + + # If 'Info' pressed - update track info + elif message['State_Update']['command'] == "Info": + track_info = self.iTunes.get_current_track_info() + if track_info[0] not in [None, 'None']: + indigo.server.log( + "\n\t----------------------------------------------------------------------------" + "\n\tiTUNES CURRENT TRACK INFO:" + "\n\t============================================================================" + "\n\tNow playing: '" + track_info[0] + "'" + "\n\t by " + track_info[2] + + "\n\t from the album '" + track_info[1] + "'" + "\n\t----------------------------------------------------------------------------" + "\n\tACTIVE AUDIO RENDERERS: " + str(self.gateway.states['AudioRenderers']) + "\n\n", + level=logging.DEBUG + ) + + self.iTunes.notify( + "Now playing: '" + track_info[0] + + "' by " + track_info[2] + + "from the album '" + track_info[1] + "'", + "Apple Music Track Info:" + ) + + # If 'Guide' pressed - print instructions to indigo log + elif message['State_Update']['command'] == "Guide": + indigo.server.log( + "\n\t----------------------------------------------------------------------------" + "\n\tBeo4/BeoRemote One Control of Apple Music" + "\n\tKey mapping guide: [Key : Action]" + "\n\t============================================================================" + "\n\n\t** BASIC TRANSPORT CONTROLS **" + "\n\tGO/PLAY : Play" + "\n\tSTOP/Pause : Pause" + "\n\tEXIT : Stop" + "\n\tStep Up/P+ : Next Track" + "\n\tStep Down/P- : Previous Track" + "\n\tWind : Scan Forwards 15 Seconds" + "\n\tRewind : Scan Backwards 15 Seconds" + "\n\n\t** FUNCTIONS **" + "\n\tShift-1/Random : Toggle Shuffle" + "\n\tINFO : Display Track Info for Current Track" + "\n\tGUIDE : This Guide" + "\n\n\t** ADVANCED CONTROLS **" + "\n\tGreen : Shuffle Playlist 'Recently Played'" + "\n\tYellow : Play Digital Radio Stations from Playlist Radio" + "\n\tRed : More of the Same" + "\n\tBlue : Play the Album that the Current Track Resides On\n\n", + level=logging.DEBUG + ) + + # If colour key pressed, execute the appropriate applescript + elif message['State_Update']['command'] == "Green": + # Play a specific playlist - defaults to Recently Played + script = ASBridge.__file__[:-12] + '/Scripts/green.scpt' + self.iTunes.run_script(script, self.debug) + + elif message['State_Update']['command'] == "Yellow": + # Play a specific playlist - defaults to URL Radio stations + script = ASBridge.__file__[:-12] + '/Scripts/yellow.scpt' + self.iTunes.run_script(script, self.debug) + + elif message['State_Update']['command'] == "Blue": + # Play the current album + script = ASBridge.__file__[:-12] + '/Scripts/blue.scpt' + self.iTunes.run_script(script, self.debug) + + elif message['State_Update']['command'] in ["0xf2", "Red", "MOTS"]: + # More of the same (start a playlist with just current track and let autoplay find similar tunes) + script = ASBridge.__file__[:-12] + '/Scripts/red.scpt' + self.iTunes.run_script(script, self.debug) + except KeyError: + pass + + def _get_itunes_track_info(self, message): + track_info = self.iTunes.get_current_track_info() + if track_info[0] not in [None, 'None']: + # Construct track info string + track_info_ = "'" + track_info[0] + "' by " + track_info[2] + " from the album '" + track_info[1] + "'" + + # Add now playing info to the message block + if 'Type' in message and message['Type'] == "AV RENDERER" and 'source' in message['State_Update'] \ + and message['State_Update']['source'] == str(self.itunes_source) and \ + 'nowPlaying' in message['State_Update']: + message['State_Update']['nowPlaying'] = track_info_ + message['State_Update']['nowPlayingDetails']['channel_track'] = int(track_info[3]) + + # Print track info to log if trackmode is set to true (via config UI) + src = dict(CONST.available_sources).get(self.itunes_source) + if self.gateway.states['currentAudioSource'] == str(self.itunes_source) and \ + track_info_ != self.gateway.states['nowPlaying'] and self.trackmode: + indigo.server.log("\n\t----------------------------------------------------------------------------" + "\n\tiTUNES CURRENT TRACK INFO:" + "\n\t============================================================================" + "\n\tNow playing: '" + track_info[0] + "'" + "\n\t by " + track_info[2] + + "\n\t from the album '" + track_info[1] + "'" + "\n\t----------------------------------------------------------------------------" + "\n\tACTIVE AUDIO RENDERERS: " + str(self.gateway.states['AudioRenderers']) + "\n\n") + + if self.notifymode: + # Post track information to Apple Notification Centre + self.iTunes.notify(track_info_ + " from source " + src, + "Now Playing:") + + # Update nowPlaying on the gateway device + if track_info_ != self.gateway.states['nowPlaying'] and \ + self.gateway.states['currentAudioSource'] == str(self.itunes_source): + self.gateway.updateStateOnServer('nowPlaying', value=track_info_) + + # Update info on active Audio Renderers + for node in indigo.devices.iter('uk.co.lukes_plugins.BeoGateway.plugin.AVrenderer'): + if node.name in self.gateway.states['AudioRenderers']: + key_value_list = [ + {'key': 'onOffState', 'value': True}, + {'key': 'playState', 'value': 'Play'}, + {'key': 'source', 'value': src}, + {'key': 'nowPlaying', 'value': track_info_}, + {'key': 'channelTrack', 'value': int(track_info[3])}, + ] + node.updateStatesOnServer(key_value_list) + node.updateStateImageOnServer(indigo.kStateImageSel.AvPlaying) + + # ######################################################################################## + # Message Reporting + @staticmethod + def message_log(name, header, payload, message): + # Set reporting level for message logging + try: # CLOCK messages are filtered except in debug mode + if message['payload_type'] == 'CLOCK': + debug_level = logging.DEBUG + else: # Everything else is for INFO + debug_level = logging.INFO + except KeyError: + debug_level = logging.INFO + + # Pretty formatting - convert to JSON format then remove braces + message = json.dumps(message, indent=4) + for r in (('"', ''), (',', ''), ('{', ''), ('}', '')): + message = str(message).replace(*r) + + # Print message data + if len(payload) + 9 < 73: + indigo.server.log("\n\t----------------------------------------------------------------------------" + + "\n\t" + name + ": <--DATA-RECEIVED!-<< " + + datetime.now().strftime("on %d/%m/%y at %H:%M:%S") + + "\n\t============================================================================" + + "\n\tHeader: " + header + + "\n\tPayload: " + payload + + "\n\t----------------------------------------------------------------------------" + + message, level=debug_level) + elif 73 < len(payload) + 9 < 137: + indigo.server.log("\n\t----------------------------------------------------------------------------" + + "\n\t" + name + ": <--DATA-RECEIVED!-<< " + + datetime.now().strftime("on %d/%m/%y at %H:%M:%S") + + "\n\t============================================================================" + + "\n\tHeader: " + header + + "\n\tPayload: " + payload[:66] + "\n\t\t" + payload[66:137] + + "\n\t----------------------------------------------------------------------------" + + message, level=debug_level) + else: + indigo.server.log("\n\t----------------------------------------------------------------------------" + + "\n\t" + name + ": <--DATA-RECEIVED!-<< " + + datetime.now().strftime("on %d/%m/%y at %H:%M:%S") + + "\n\t============================================================================" + + "\n\tHeader: " + header + + "\n\tPayload: " + payload[:66] + "\n\t\t" + payload[66:137] + "\n\t\t" + payload[137:] + + "\n\t----------------------------------------------------------------------------" + + message, level=debug_level) + + def notifications(self, name, last_state, new_state): + # Post state information to the Apple Notification Centre + # Information index: + # node.states['playState'], # 0 + # node.states['source'], # 1 + # node.states['nowPlaying'], # 2 + # node.states['channelTrack'], # 3 + # node.states['volume'], # 4 + # node.states['mute'], # 5 + # node.states['onOffState'] # 6 + + # Don't post notification if nothing has changed + if last_state == new_state: + return + + # Source status information + if last_state[0] != new_state[0] and new_state[0] == "Standby": # Power off + self.iTunes.notify( + name + " now in Standby", + "Device State Update" + ) + return + elif last_state[1] != new_state[1] and new_state[0] != "Standby": # Source Update + self.iTunes.notify( + name + " now playing from source " + new_state[1], + "Device State Update" + ) + return + elif last_state[0] != new_state[0] and new_state[0] == "Play": # Power on + self.iTunes.notify( + name + " Active", + "Device State Update" + ) + return + + # Channel/Track information + if new_state[2] not in [None, 'None', '', 0, '0', 'Unknown']: # Now Playing Update + self.iTunes.notify( + new_state[2] + " from source " + new_state[1], + name + " Now Playing:" + ) + elif last_state[3] != new_state[3] and new_state[3] not in [0, 255, '0', '255']: # Channel/Track Update + self.iTunes.notify( + name + " now playing channel/track " + new_state[3] + " from source " + new_state[1], + "Device Channel/Track Information" + ) + + # ######################################################################################## + # Indigo Server Methods + def startup(self): + indigo.server.log(u"Startup called") + + # Download the config file from the gateway and initialise the devices + config = MLCONFIG.MLConfig(self.host, self.user, self.pwd, self.debug) + self.gateway = indigo.devices['Bang and Olufsen Gateway'] + + # Create MLGW Protocol and ML_CLI Protocol clients (basic command listening) + indigo.server.log('Creating MLGW Protocol Client...', level=logging.WARNING) + self.mlgw = MLGW.MLGWClient(self.host, self.port[0], self.user, self.pwd, 'MLGW protocol', self.debug, self.cb) + asyncore.loop(count=10, timeout=0.2) + + indigo.server.log('Creating ML Command Line Protocol Client...', level=logging.WARNING) + self.mlcli = MLCLI.MLCLIClient(self.host, self.port[2], self.user, self.pwd, + 'ML command line interface', self.debug, self.cb) + # Log onto the MLCLI client and ascertain the gateway model + asyncore.loop(count=10, timeout=0.2) + + # Now MLGW and MasterLink Command Line Client are set up, retrieve MasterLink IDs of products + config.get_masterlink_id(self.mlgw, self.mlcli) + + # If the gateway is a BLGW use the BLHIP protocol, else use the legacy MLHIP protocol + if self.mlcli.isBLGW: + indigo.server.log('Creating BLGW Home Integration Protocol Client...', level=logging.WARNING) + self.blgw = BLHIP.BLHIPClient(self.host, self.port[1], self.user, self.pwd, + 'BLGW Home Integration Protocol', self.debug, self.cb) + self.mltn = None + else: + indigo.server.log('Creating MLGW Home Integration Protocol Client...', level=logging.WARNING) + self.mltn = MLtn.MLtnClient(self.host, self.port[2], self.user, self.pwd, 'ML telnet client', + self.debug, self.cb) + self.blgw = None + + # Connection polling + def check_connection(self, client): + last = round(time.time() - client.last_received_at, 2) + # Reconnect if socket has disconnected, or if no response received to last ping + if not client.is_connected or last > 60: + indigo.server.log("\t" + client.name + ": Reconnecting!", level=logging.WARNING) + client.handle_close() + self.sleep(0.5) + client.client_connect() + + # Indigo main program loop + def runConcurrentThread(self): + try: + while True: + # Ping all connections every 10 minutes to prompt messages on the network + asyncore.loop(count=self.pollinterval, timeout=1) + if self.mlgw.is_connected: + self.mlgw.ping() + if self.mlcli.is_connected: + self.mlcli.ping() + if self.mlcli.isBLGW: + if self.blgw.is_connected: + self.blgw.ping() + else: + if self.mltn.is_connected: + self.mltn.ping() + + # Check the connections approximately every 10 minutes to keep sockets open + asyncore.loop(count=5, timeout=1) + self.check_connection(self.mlgw) + self.check_connection(self.mlcli) + if self.mlcli.isBLGW: + self.check_connection(self.blgw) + else: + self.check_connection(self.mltn) + + self.sleep(0.5) + + except self.StopThread: + raise asyncore.ExitNow('Server is quitting!') + + # Tidy up on shutdown + def shutdown(self): + indigo.server.log("Shutdown plugin") + del self.mlgw + del self.mlcli + if self.mlcli.isBLGW: + del self.blgw + else: + del self.mltn + del self.iTunes + raise asyncore.ExitNow('Server is quitting!')