From 73a79139ec26ddd1e2c429f88f48352338cf034b Mon Sep 17 00:00:00 2001 From: LukeSpad <64772822+LukeSpad@users.noreply.github.com> Date: Sat, 5 Feb 2022 20:38:57 +0000 Subject: [PATCH] Add files via upload --- .../Contents/Server Plugin/Actions.xml | 203 +++ .../Contents/Server Plugin/Devices.xml | 157 ++ .../Contents/Server Plugin/Events.xml | 66 + .../Contents/Server Plugin/MenuItems.xml | 15 + .../Contents/Server Plugin/PluginConfig.xml | 93 ++ .../Server Plugin/Resources/ASBridge.py | 140 ++ .../Server Plugin/Resources/BLHIP_CLIENT.py | 282 ++++ .../Server Plugin/Resources/CONSTANTS.py | 683 +++++++++ .../Server Plugin/Resources/MLCLI_CLIENT.py | 462 ++++++ .../Server Plugin/Resources/MLCONFIG.py | 298 ++++ .../Server Plugin/Resources/MLGW_CLIENT.py | 431 ++++++ .../Server Plugin/Resources/MLtn_CLIENT.py | 370 +++++ .../Resources/Notify.app/Contents/Info.plist | 78 + .../Notify.app/Contents/MacOS/applet | Bin 0 -> 99064 bytes .../Resources/Notify.app/Contents/PkgInfo | 1 + .../Contents/Resources/Scripts/main.scpt | Bin 0 -> 1116 bytes .../Notify.app/Contents/Resources/applet.icns | Bin 0 -> 64857 bytes .../Notify.app/Contents/Resources/applet.rsrc | Bin 0 -> 388 bytes .../Resources/description.rtfd/TXT.rtf | 5 + .../Server Plugin/Resources/Scripts/blue.scpt | Bin 0 -> 6180 bytes .../Resources/Scripts/green.scpt | Bin 0 -> 1570 bytes .../Server Plugin/Resources/Scripts/red.scpt | Bin 0 -> 5048 bytes .../Resources/Scripts/yellow.scpt | Bin 0 -> 1802 bytes .../Server Plugin/Resources/__init__.py | 1 + .../Contents/Server Plugin/plugin.py | 1277 +++++++++++++++++ 25 files changed, 4562 insertions(+) create mode 100644 BeoGateway.indigoPlugin/Contents/Server Plugin/Actions.xml create mode 100644 BeoGateway.indigoPlugin/Contents/Server Plugin/Devices.xml create mode 100644 BeoGateway.indigoPlugin/Contents/Server Plugin/Events.xml create mode 100644 BeoGateway.indigoPlugin/Contents/Server Plugin/MenuItems.xml create mode 100644 BeoGateway.indigoPlugin/Contents/Server Plugin/PluginConfig.xml create mode 100644 BeoGateway.indigoPlugin/Contents/Server Plugin/Resources/ASBridge.py create mode 100644 BeoGateway.indigoPlugin/Contents/Server Plugin/Resources/BLHIP_CLIENT.py create mode 100644 BeoGateway.indigoPlugin/Contents/Server Plugin/Resources/CONSTANTS.py create mode 100644 BeoGateway.indigoPlugin/Contents/Server Plugin/Resources/MLCLI_CLIENT.py create mode 100644 BeoGateway.indigoPlugin/Contents/Server Plugin/Resources/MLCONFIG.py create mode 100644 BeoGateway.indigoPlugin/Contents/Server Plugin/Resources/MLGW_CLIENT.py create mode 100644 BeoGateway.indigoPlugin/Contents/Server Plugin/Resources/MLtn_CLIENT.py create mode 100644 BeoGateway.indigoPlugin/Contents/Server Plugin/Resources/Notify.app/Contents/Info.plist create mode 100644 BeoGateway.indigoPlugin/Contents/Server Plugin/Resources/Notify.app/Contents/MacOS/applet create mode 100644 BeoGateway.indigoPlugin/Contents/Server Plugin/Resources/Notify.app/Contents/PkgInfo create mode 100644 BeoGateway.indigoPlugin/Contents/Server Plugin/Resources/Notify.app/Contents/Resources/Scripts/main.scpt create mode 100644 BeoGateway.indigoPlugin/Contents/Server Plugin/Resources/Notify.app/Contents/Resources/applet.icns create mode 100644 BeoGateway.indigoPlugin/Contents/Server Plugin/Resources/Notify.app/Contents/Resources/applet.rsrc create mode 100644 BeoGateway.indigoPlugin/Contents/Server Plugin/Resources/Notify.app/Contents/Resources/description.rtfd/TXT.rtf create mode 100644 BeoGateway.indigoPlugin/Contents/Server Plugin/Resources/Scripts/blue.scpt create mode 100644 BeoGateway.indigoPlugin/Contents/Server Plugin/Resources/Scripts/green.scpt create mode 100644 BeoGateway.indigoPlugin/Contents/Server Plugin/Resources/Scripts/red.scpt create mode 100644 BeoGateway.indigoPlugin/Contents/Server Plugin/Resources/Scripts/yellow.scpt create mode 100644 BeoGateway.indigoPlugin/Contents/Server Plugin/Resources/__init__.py create mode 100644 BeoGateway.indigoPlugin/Contents/Server Plugin/plugin.py 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 0000000000000000000000000000000000000000..792791262270b07af372dbea68d4be2bb805af53 GIT binary patch literal 99064 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 UH~-N5eZBked)pWOe9^<=-_hmt&;S4c literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..c478d35fc04038aeb4ea0260d1a41cc1a2a6454b GIT binary patch literal 1116 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
s1!LEFHY_z24&=in|^D;_;8Zgvk?Zy zLRIUMZ8J~BTkGSWgMG6E(>Os%#RVem^TyM`wFpg*vSSzAij27}k_-|FzM?#Es8~!w zCBT#@hT;}(f+oItbko+6P_4FgX;-ZzDJasF4?0&V|FPP%_$i=@+Vc;SjB~*fYFIJ)@$Q^ zhaTl#vX75F;Sfw0q z9qo0cKTz-3_Z4@$1+6`xHoo1>1(|qbGBRuKdU(Bg*Or2Mfj*a&*Iz0lXK$7;Ft{}J zSTWR|p&*F9obGPwgW<;-E;|7b77ZTa(CVB z)~TxRiK2|!7UVPyohQ5zApU)i1X(M5<&qxtbx7u>4)iW-0?Mo+chA;ob571162uN) z45^#mW3~%_FM+RFE7ahAK=W~7kb%6(`zdSsh0gsBTPpqH+4||!bA(AaE3g$Aphz#VPTmo8^lTKeGKj-V30)Xr% z{-HFIEnN7D@nMC$MHzpvw931B;$!prw$l@#v{Dq=0E83&4J`nu7!&lhwcCcs`LJ8m zTBhcuD+!ZB6^kG8y$@t@Qa`F_98#4YEwB@0i9ph9N4N^UoWhW&`%wspLpSw!z&c*+ zt7me8z;LIWX$649xKxvag{4RG8L-hBzQ7kJzKkLKqjE!i52AmJt&Rl4pX8XjZUlpD z-o^K<461H`V^^~;B`K%fN=lz>j`j-qL`@fs4#B29AV#XIjvHX2w<@rlMj=bo3dCLL#`UH`%V^EHZ0pF&CI=U&Q zPPM7s&9$vy?)Pn~*e;xd8bAEZqbP|sXJ@u3IiZ#87@kFaA`LL>;SlaFDyfMNGTUQ5 z@UmxA$6^&MnHng)tBqjJ&1im>me?Dqfho{_`~vZ-9n5J!oaJRQ%0H;aC!rr&hh>O1 zd-qR(vB;8?Z1D^rRQWNbDI zLt8txh8OkpGWK>O@r=dq+q6}1OCaxnq}mC8Fp)bzU8+*(kkrjja0bn3intgn3(K5v zajxiLd$$;9`Id$*v~Ua%+33IFCiYYB1&4a@Z$7m!BH6=Lo-hn~@e9CA5;_ZSjUfYV zUWz+<`h>EB<@zT!88mA~dY3t^$F9o`=RE5@$q+fl=XW~K!Z6hw*wy7wE16E+gjmBW*N03D97c!-XWsH_a~LdLaj<%mX( zd;zgK5M3B#X8R*rUkKA?OY3w&P91E%_8^$2-0c&ey13|_`|b?(KaZeAXG3wiMKfxi zol=bsyO9(XwMVW)*&Z%uL0Ztvf7q#gBVEJuQ-HRsvSVPA<^_KN9 zCK*%K%y)HpR5P8$*LTyJA#$PtLs-O@&jO#2J%Tb>l@U^arYPsTNWXEBKSuK_zMh3w|D?WYu)4wLFu5xNd12H99TYNFPX^mW_ zG@f-v>pc<+$g+l?D-&=b@c&5-fPj>PA`R}_Y(r?m#{ni`{X|r$g zsPlQQLS^A)q^n{#=jYn-eM?NZt7pX($`>*h^4hmWy}bGE-||F#={r#*Y?o+<96AA) z!N4L5l*tu5r-8-C8bjK~=7<%cHH>4p2y27bHR%5WXP&BZk#)Xpb8Aj4!=?&7V|vBI zr%v0RcOq`7(Bxu|-JhUQVl7M^xp|Y>fu1)7<=ecP@KfGTPDQ&obQtKErE$+5MD(0& z6i;j;e+FB^1XMFP1ac0sLqRMFLFl`7(RSV;@8wmJ74G7tTEAJiq*AXlaIV`G76xFUvk~&_Tg%LMkT3BJpF^v{dLG zOr44cpZBMCu$#G`(LnkjW5XFnUgV@_Y#Fj(p|&mdRqOGuM-fvR+@GkUnf8Cpj{?{P z43{R%3^{y-rl4{0rQJ5f(~!Lxvy*?p_=Ou1O#E%$I76+BX0_?v=!Dt2zcCX(`<*W5 zmSO=}I7}{GS^rYh{BZOlUnS60*i<*ycvk(e+{0S~ai>ODWLWEVkR0e|y03m1$P|(J zaUd1JaU`(v^_XfAoaU-_5x4DcDZRyD(5EHO;zg_(Amf6WtlhYZ+!aHx@@Uh)sOkOK ze;tD9cR$Kxv^JZ?3VbV)wEEkqf6nzwEl68kDX6;t3lTn~Ijf7_R1vaCv+GhG)-gl# z=SjM_5}8biR9@&12IIC_)@000G7fKrwExa%vsO~YxHpV`rYp(wHmA#f=CT&iK+VZX z$l(wzBJ^%9x${lj1yeayre?N(3;rEN|f06w!vj0W)zsUX<+5aN@Uu6G_ z?0=E{FS7qd_P@yf7uo+J`(I@Li|l`q{V%frMfShQ{ukN*BKu!t|7+R*TK2z|{jX*J zYuW!=_P>_>uVw#h+5cMhzn1;4W&dm0|62CHmi@0~|7+R*TK2z|{jX*JYuW!=_P>_> z|6$qtpZjjG`;G=ZJ70fr_5WFy^qoGxZSt*a4Ef4T_+Y*HVA*^*8%et=XuWddH(U9h zX~KP0Vqb66e*Oc0Rq(?vLf@*wZx-M?nJ4VS*7I%)ezgw2x%knOHG93H|9mz5xY}zv z|G`r9yn()0+4n-n`hZ+-yS40bMW&O~AW0oA@!7!vSS(GqJf^>@hTw7H5XbPB!g4bs z$;bM2pYZBx$x|^CGW?2-Z;A57$K_PDXro@&zNjnI5y+(1t{X+yJDe-750A-+(n7ot zY{MltWEbj)<(&N?o%a~YSgsG9h2Qon9B$pH*7a#^F;$5sU&Lcr=3y4d*`M?k1q*IY z?Ky6p%VJF=1+tP!^xb8D0P%5|u~s=IDbrGQYaMDT*v{0A6KcwHbaQUJxcbg)^l6|J zgbSIyLqGfF{DdzfHABLs0ZF_HfZ&ZI@y5;x1gs?ud-rR(-7yJvMqox+gzhA^XuzjH zzb>1dpa?xDJG$2d^YAULB3#^lumci@?;4k4?8@dl*Jt!%ojFq_eH|dr^ajph@N}N- z^D8GOIsbxJDByUc)hLM!bx2p%kG$64_?bZB`zQnND z%q_N`dC_Z_bK^Gf`M)e=9dQ69#QYuHOJjS>ya%TvAp~~4ew~FwKgbo_H)s0elCI6q ze|(R2lCzTqSHhX<3yoEtMJ`mi1KEJ5h%#wF;KYdBOC=HGv?Jz3JN+UjRA?#!@t)o) zY}Cq%M2G&C4+kM!HIp2@*qxJlPLvrmoaXm)l861To<=esF3SCwkEbH$92^(7K1dXH zyG7<~;npjkM;}-0)AO#`@+Z#o8RrnW#h)}IqW$%N|MtT>8Si*HnE+5g!U~UyD!$dG z^628mqU2M>!V)@kUCAi5*y=V){J(j)gy$$E|K>Z`5>nBAzTrx$zLRlF{n@xlbM|+0 z%@}Se$!yek++7~b987;Tu9%F3)E^dLI~VQCtj=_aXTp~t!rw8Y`6way(*2eY5c_R6?h zz#jPK=r3!1#+-udM~{*>Oz2-QvtfP18?z9zI*JBHA|R!XWouO($!z!49UnIp=|Ez} z!Gu_Pq`V2o@U1LfIn9ao-&I+aLav^z1X;VK^gYM>656rx;kDm~ELQgatlvz#=MF*y zg=plPBx50=j)1>sP$CZeD^Vw$i3No}{xZjx3=sf!ua(N$6NSKKMi1> zH&%M*qIhiQ?ToqURZfSewtvw}-M6(xy!pt$NBy;=R^xk_*MVa@@*E`|oUTXF`vY0G zeGnw<(7HZ+Y9^pm_(PIzGSL^LBaw-R^Yl=S4a39~L%dYV*lQDu7}f+F?3W$_WNtnm z$wv0D1(1PBs;8-tl(ipu7p&!1pRF0CV$O&(6KohBeTagEtzJhLzdlU3l2Hx@Qu`)) z;rdR~LJrCxiH~do8prasT~d^U(s)-PP~xu8IvVQ);boTZVdvrxV{^$o*=w=T#iu+{ z?(-dl`$j+a9&=CP4m(PGZMa;;M#rQPpCpE2bEolH&84?M2yW8V!fO}8(>j7$)j#=C zd2?qCz-RBtn2(!@4t@`^R$pH4q=a6lNC>;pzEHEBWt;tQR;)?Uym{JyDmN;DCzeIa zyhnGVLk0xUWLgB`e}BMh8vsWEVb?b$0H$ckKo9}>a$bLYp*AgGt`Awpf^P`Ohn>-3 zZzN15k@Wn*Wl?7&fUyK~4LVK|)uzA(>Uz`rb~INJkfMzVE1nRuFE5`qIlIZl2!$e3 z01H&S+R+tUdi58LvPO{KLUs)ff@u}0w~^nF0T|#zdup_x54Z+(vQ%8^@pA*?wa$bxC73YYT zcZ=8b-3`;9oyfZU>P{8vf~>Zl*e;MhqRqwCN z_X=-WHIxmBor(VU5OGQ0QV%$mp0mjT`h>d)azmX!XaLzZDbmRDZLPh720_PHT!ZkL z@m9z@1O$Y`eVcqaY#6xcx{jFJoTjaFw-G_wyw^@DU&m750gOrF1yVEc1VTp*L%BR5 zBgbG?FmWa`gU*_WQy*4`Xlpx8fp1CDuTt#NqqqoV3asL5y7OiV?sCQDSWFuWuatL# zWq@|YAbR{mA*!la2t$F7@bG?W7AYtEtpKBl-Wnl!h$A%e)c*>3vcWEuQVbm_wTDur zoA=iBjK%>gx-F2D+oFs4xGeb>PbBr`^@eYnxyEInnt<44aSLNd7AU|Pk2%+J;vFA? z`v}7}+xM<_K<1mfR9=4yPpL_#H2qChm6+jbW?eQtRS8}7x`f^;wOeSmkziQ|jg%zk z?TekW`~{&b)_C^25*g8|?RxckOUO9a86|~}~*+}%nfeHDH;8lZaeQ?>MB3*(E*~8vyMuhK9 z#`Ul1+mqBnbV=!0D0+(MO*C7zWN38>Rs+OKqh|f&V%2VncdF4iJy+mQ=$sq409$z6 z(tUQ(H?|_wf;5 za%F>pk(Dc8YgBrX-87t|wqt_#ORj#S^3 zh&RSJmo7AwhSZsHoNno3mH^g%=z}LA?ZLyLJ&aB*;RlE*6I?s_Q1u?*uAGoO?!Wbj zhwTjAEz zrfJz~^UUx7P&FFXUqX8RWxW%!L1nxc?F zP;}*~uGayJ7IEI?e_F0k#`;R^vqyzC7D1@cvq#8!%~fQo2Wbih_8cvx_Y z8=6ZjT)ynUN9(Gl=XfMefPnL27|Pb6=~=W6(UNlYq7~*T4l%V!f%7N|jyKZ6tSDm{ zPGsG86&yUSl3m04y6SeIk7Q)8KZ-zC(Z z=4Q#a9yO(mcWc3H{0le%8{JYoo)3xRjq1;1VFc-cLK?)jBNltb3gwp3d_^HJZh`4K zSLKM&aSNc!&Bt|&SUv3~T-*yYVjJDTFRNSDfkt=BN@^o=jQmHKkaexr&O#cyI8N+q zg0{iX-K^u2pmr!1$fy$;OO=_%!p*|5L!}r=Eb=-L_R?_Si*2*m!1M+R#Pz)-47=0i zLeKv`TESh1ato!OC6^%jb&qeeJnJEaNGF&ohqwh;soV4nmQpb-tGyM!x0^lLVS&Kh zjaLUf1lr!w0#(T91C2BUSfvup1LQeowBZ0g-C9kg2Z3)TXTs5>3%3wi9wEkTS*zk? z_lm-}BQnM~e}NXDeHk0aGa;ql?K zrl$+O4jI`!e7WiBP$xWnkUgLvRDtqbm6ZWBr^-JuaL?tpjytCg_Xwp0QNz_FV9GzD z1-CmA?_5lhLI*a?&DbhZ#RJSW(zBQW&Dr+yU72a-Wgv{sU;`xuxNG5);^s-^`QdRu zdbmt`&S8-(&RQ-_Gz$ju!Z@49n{a(3_{JNCQw!*{7d%Pl{N0!NBR*EHL3wnPB*7p` zlSl0kvlWC^L?Fid3Bhf7U4mB0K+17-$^sL$$#?vtibQn0^noK46KFmDDMUmdVhnyf zIV^J3(H_^!bn-ZK1>jEb;}A?U6EnvDZO19Wf^Kmjnm5QGDaVp+8GOt?K5Me>UXy5Z zG^vC+4w*F0kJ-)iW0=jVhF#k=@AfDB2K-5 zK^daH6kszodV{w%Kd(4UCU*F@?i9e$_Wc~{xL|O6M6sszTp8pbtmdFAx6*fSu>i4h z2G@^z2q+Jqlhu75^8qF}t_(oB2pUY}b-O&^ zvrD1-VE3RUk>HMejBg!XOm5t*Mz}9;cDIudBJW(#ad{k!3(=N9_(bot1wJ&Wucf8J zDZZOJGU<`yMFbUQXCkRXtxSsz?;sumk$s)VC>7ft@GWSGc8*-oX|ah)*N=Iu8$|6&lLw2C3Z459zDw{yBgOgn{FW-%3BKc zLl7Nzaz2P(M-biB>Vq?OjV&*HIyGBdM91&$y{XW$M~P+~QUI{EeIJPbQs}Z9{6&6z z6gvy8wtJ9oZb68YA3^2Q+S^N@+rQY6Ma#tL@F&D$R^h@S#$g>dWTZW0_N;Sj^AGx~ ze7kj7R3Q_14&9hPO!n|!eYYN0d>K~HJU&S_!Xd)E=3W;)c*K>hn!2&fyniT#JVX=v zEokI{!P3TuP${Ijrbj*>r=D2K-_-cIa$k@TToz#Gp*UcWSQj;=qPW-zF6=vqn;%!R z(8^Km$q69h-M;RvPvSa%VKaf{0*I_(dO((%QH8TjYwE&$FGsD-9L+D*Y3o#%INIWd zHyc-ZLYHVMI&uWXDPb9_CZNz3m5@wb($Hb$993ZToYL+)0+t73o6O4QS!x&Pt(615 zs96T$G5!pR%pZ=dWHa*U&j$<~{r3l*tY86;LuLn%{0sT7Rsuqv)i3+_k-0KoV2?vm{}2#YL2E8eAZwdl#J>#5fy! z%-1F@)udFPE<=DR6K2&HW%DE83dR3HUAV%O)FIx4T7UWDBTQ`(7lF=XM zuajx1sU3H^5~LIZyAD%cNmjfE^3Z5^3jh$jc4Gk1p&3K2>=En$BmD>fhG&OD+}Gkd zOhr_M)&RGf^ZhlQJp=@};aaw?dq#}+%ukAYsLLcWW%JqA5R6JQ`II;WED{kN?5(F~ z7e30Yzt@HEyss1@GWL-iL~i-G@e9S!H2#BJ>kXD6!b(oFxOB+SMSDM&F`i60@PqK7 z`+jp2nP1ha9GjDVk6t?_lmBES*ZZoBmcZ_y8`O)T#}N%^UX&df)>`&BfA_;=Fwv@fF5S>n~>kS6V9-l?4vobN~ynp*{4vDT(pK z^kF7vx#NqR2L2C41o9)LS+e`$5@o8xjq00P+EpWlF!R@6cQ!t)@Q3siHQSrOXvi*+ zuFdWA;5sp7Tw;KD5)t}kj`P}%orX1@&ifGXY(5UHDldH;;Rj8^p-;IyT)Lrb*Ob0V z1>&A^P6wIDqewtu@x~y9*hAjE4+`&qIeu@sFMO*haTI!WO1n;0wcN1$`*S*;$|UTw zNs!rTJuIxU&351ysW&Ys&ucr?v&ppb#rR5p!&QLPMGFnYUBcK%B3nqYHuiR%hPW|o zl-$nM;)7g(=n2uWLN}31+~<({q53w8I#8F zR9@Tc(nk@<&`M2s6C<)XLy%nU)C-?FnyR;{aBcB4!+%v5kIh38KVC`#TXavy!+GMl zWr}=(Rd@VwDxp2!{Der!k?J-9`VKPAu~t%0^_1?Bn-BhB&UyvJrP6G+Kq?ek{c z`)b68LnC@)Q2f~cWZA3vh_65egRme`I5v3+GN}1o256s5ibs zx+)qwwYVkMulz2pwYD>9-!IsJtBgvghywrk+?P(@ot0pL)gZ#}K49hZ5_#fi@+f)! z;|w{3Ml(Qxy<|;ks~HiEZXF8$fc4@e*z*bb@y^uw@3#!0A7O2PRpaj#H^&jE#*pOqx1__eO zZ)p#M#H})mLBs2N>Z9wvgaS zR5}UXwTdx0X9)x#BKN8$mGf=!X?x z$SCfWQ*dM{ZQ9LU+G(KFx|?jrmP|I=Mkd;4SD-*QeJ-egrJBaIw{Do#;ZP#&!bDc@ zx6IMnPx1ImT^{X3QMsI;SA$Mt?7bxCwj0n64EqUkd-%8_(Ct%3k~I49?x%}PTvVKb zuKuH^6#$h7Y*xF(Tb->nt_Dn^s+6FwJaO;|u*o^c2X~bv?ho<9U7lkN4{>4vVIfy`npQ{J6L8QH}FJJ+Q%?Bo=CBlk)r3*W#r zTCjw&M_lZeM-5qHAPwN@W}WdMc|Z&&0s&`Lgr2l*D3}jzk)k#`B_I)T;Z4UPa3o zUC@^^;~3dEif<-Yqc;6p3VEM{yJ(^qA0(Jh^2ytp7SZ)46zK&CZRAaX8l}UsyvBn@ zAJAPjDhHBMEN}8jugy5Co_UV`r942H;6(kpF&b)YC=b0V5Q>EkH}~@EcYynXH!e%dF(Cp07sDwR4@eUHz4lc$<`y6LG17>6LIa%e+g2P^$rt;Lhpj zyT`xI_XkOM_LL-7lor=v`1*G9J7A>~OwwT94+|W9W@qDdz*-S0)3m$9soy&>N@#{R zb*p`;c()&Osd5#G5xD9=KNP--r$em>#*bY2^A2k>yc*5t@!uhcB@gPVd#gTHalof+ z=PF)NrW3mux0yDEaI#?!t(bBVWSHv|%VJ>~ZnvEi=$8@G*tD8#tdHNoXSMo-2bt>J zG^|UcSG99hWXQdzd5|HhNAqLHQToZmP0j?4mnLiLC|Xp-Wn`+^`Q3oMj|~q`R!qlg z{zl{A+ZsjS9cb+Gz&Gp5#LLzik8t6bhP^t1-3dOo89p;b=FQj{YwmgF-Nt&zM+Sjn z@=!!v@&qkG-V`+Xri_3;z~;T@@8Fo%aIF?PaC&~IL*s_-M@+wLVF}2sfy>xMESgAg zmXMkyjvp(Y&xba){VC&6l@LBM2A9G)5hg*3Z&@DCOy6vOQwHIo6q#AkS5}FY7k!1wJc{-59OF-grIi2aFtd@h9^|A%*dBa`q0D=;45jL1B>V9czDyW$ERJM_ z3Ob3@?2Kkk>8whvdudY_91q$E`q&4Q6-K-;DO~(>&IHyA*gKuli0xSuZM-upK{b{L zlqD2jYT&E6C0{^9&|_YHl2e;~919+uuLxZmXujI&(llb@DiWwr96SwApsX`rwb*V! zZl|mIP>&$W*TBYMK4#ZgYiQ=>DryTTsy29uG_^k_VD5M_?6`NAWz!Mnac=qz#^5}@ zgvmPDt_&p@sHtEGw?At{8U6}oE#SM>)R+)aTPO}jRCjkAZ z3A71E4>JFWus~hm1phfj6zbY$7rCaJ`?GsCG35?~k?Lh{mQ05bXFET90kq++~k=$wtPS$R8tTe5dW5YfSpT*LXe^C9MgI znk!#8=ak)aeN-q8Oq10URVQRX#$D?4&zQgI+&}#G<>kjTH9uFF&(P!BD7L#q9(Kh? zcqpoHuXF#7+Qw(qNI$8(q55rujFGanta;Y0j4pkRjj?Y|t$skKY zXk79|!7ZrC*50JCR()m`RPLhN`4GC4`ua_O~T_^Pt5e)qo3Thm}q_mq;lG9MoVPzIfa?EC9PmQ{XY?l$L3o?JcC|hqcZ`}O-EQ+b2 z{9A@&->y)F-!|v$NryC>I=RgOqwMpy;A|6W1G4`<`Vr`o#6NjkX>~(gzR~!fJo+;^ z{?0XgslOErnMH+rOxQ}=; zhH-{IAWE!O>_mK=?|H#& zB7(kOritu}H@H5+#*JhdqD1-T4Lw|^r zfgjg^(y3$iU(tX*y5*U$d_Q~VgBaOzj3#XPoHM$m=5y#}g?1I}(T{Z7T5tMiw18Xt z0S67~?Tc_1>{!S;DD8Sy%tRRLeCupZYgzvgOw8z-o!yelX--wq=ft}Qbx?V9de=ON zGN`X;WHw(CFWw~G7%NjV>9V4{hW?Gj*QyS%Tuv*#$7ByVj} z%PDp3mNYH0^ZfEJ7wuWFvOqWK3i+qi=dC704t^-^d31mm3FXQ5=^WxW=FS=pLl+Td z@pmPb&JyzD4-IS%sE7^@>UmG@E!?ud1z7*m!M;6kfdokNDvMYl`hT_H{+nq{=A;+- zrtaF0c88tnT0yVE11Y$mfofEYO5y}5BR4^@&s7UZCZ^xb9ctufB@3{e6L*Rw8U+NY z63mI?>IQeO$7IjkmRfu+-7m951etPNGANSiOXcEeAQx<3(Yih#Oif_x%W}RK4OnwKErx6cA_X9)`YDw(`1U0vca<@bJ!Z$inln+ zq&TGX_^YBCmPHD`@|N;jDEcXUM4UD*>H4;kCPBgCvY#TS!6OQf&= zjof)d=hXLm1p*_RqEt}l4Hjo7o)h~TQ zm`&DNNzzlb=xvnyhZX$rw)txCOT|j+%e|xkfxIK_Y1e!!N-!tHu3$u8Y<`A1?rLqS zyh74(e(I<1)y9NcbnV|+SC5|Kjoh@^My22jXpu8{L_+JrqFnp_zkE&|_)3y!Rjdg4>aQ=~C|DCNr|9`1(vL5Z2P5Sj9<6KdXQlf zeCfx>&0K+mT5>>m^*KtNXQsu~*7oS_hsyd;Pb(obp&>i=rxxv0RP$)NNn!By+B2U7_Dt z%6{rrOxG0sS%tEf)G>W@zq0TA+m-22;YFDOho=t@AbwiT7Nx=ajwk)&Ikm;_ zjP3~t6!Mw$#*R1Y+xYXCH+Tk*1yXRHOP*KJ?&#ZQ*>9YCk1o_b!0P^Mg?L38j#Pjs zVa8WNK|VA%Cz(OaJ075}JLE*U}`1SNfUTaTQ0D z%Uuz*5{_SCjZE?~g{N64_Vd|~-{BAXVl9J~V&qr4Por#--V`Nnr^D^<1UYPUw>yg`u2lodY z%cuHCb*nk{!VItDT`dy$;el1QxVqAzvvK~|*0cgKqiDQQX8{^%kbh&2u!ZNw5zup8 zPuANPP%jEw#Z2kp=AmEYm2vHV|FrzI28N8#IvV$~?sagmgi?Wo zOi@t^Ca468VuFVy7t$och2#@w%in2M34~;3e0)IBz{=}&ioEWr6g3~d;4mQL)5_)O z#(!$U5v;Js#iZXPq)Gf#iypq^xHr=3w0`{_wxcxyIvbefjrE^`2vIJ63E zXTz!Gpx`By)>klieeia1MePz~Q_x+?IS-lTVj1goT$$V#KDPxW8J;NnMOyph`iA4I zsC{{GNnNtRe~bM2JkDCbP3Ber$_EZ9bos#?uPFNceQ37I?=^-=vX$ID{p!YN(jdYS zwMQol+V4_(ak21gwLukvb zNU2L-mjbT&Nn%-BDGjUN@3nM-T(bhKd=Au4#@2l3a?dyNb%!qNWVs_}l1PaPn}uQW z8#KGzC&TLX3Y9cn|0rDM!Tw^X^gN}LN$C=l8@WV#cqWDSiWIXLjMIf^Ehv&#qvROR+euOT(|1n38kc^FAV(*Z#QOH2W{}5*z+Y^ysRHAm7YQHloWf6QFi}a!GLIo~DDYZh6PlGy|-P%=~LM0cUb+(ArxxV;ei7(oCPFVg=gy(aBQz}|-uTH~?}u>B9q^cZ2!o@>N`WuXr4*T$cPN%U{TtT#R%Oh zr2g`Hn9U=T@d88L)4uPJS%m;19|X4|)Oto*Q`_`e>9bboXE0B(sNTXd_=TXunE#d$ zk8^AML6{CJ^aWe;&_)qyPF+{ zLWT1%G#&3~{3@(t{-AL1<&FDw!E17j0qck{IlMJZLXH!Pu)H0pX#a3XA~7H5C+^pukPE)LTu4` zb`LM!ujra4Z|20sL7w%mCccelbareGGVd0=##J>>nQTuqUPKF|pLz>vSbZ{-qRuu~ z%2E+l`rLAoT*4W}aCj5pe_N^kv1a@o;Aa6p3;0>U&jNlH@UwuQ1^g`FX8}J8_*uZu z0)7_ovw)uk{4C&S0Y3})S-{T%eirbvfS(2YEZ}DWKMVL-z|R7H7Vxuxp9TCZ;Aa6p z3;0>U&jNlH@UwuQ1^g`FX8}J8_*vke1^!v!p9TI|;GYHlS>T@q{#oFk1^!v!p9TI| z;GYHlS>T@q{#oFk1^!v!p9TI|;GYHlS>T@q{#oFk1^!v!p9TI|;GYHlS>T@q{#oFk j1^!v!p9TI|;GYHlS>T@q{#oFk1^(IppZ~1i!3RE2 zL41LV;tNm&A59fdQ9%R*wY{~i*0$Puebno-wzg_*Yg>Q!`>}-ewMR1LShLL!Ki9tsK~!J^hgH2qfK; z&lUypPx;cJ0A{owVdWqEM-SUFv$fI4KgmtLR4MaeQXIjb{5>Ln$AB{Q$S7kaql{WG>W%`9OJ+zbQ z=MniFhm@hWGHqz0QP`NtMu7fgIS7T0Ko|*8^K9N`v&8140VK{*^eni6vG_B+h`yPOW!WbMC;3&#D z97j7s&S`8H;TZWNua7Nng`?uB)L)D0T!hhQo*9NcqsphmPdgMNID!2|7$zU!I7{H0>B)r-$Ax+frKlx&C{aB| z7GPvodKmeG+WI5fT0T~TAI)o&yzek7RAdCk$a^S7ndL21Bu+hw`BNo;8=-B-AwJwj z;8=M#BJXw+ZoN>39Qj|2mUk>~J#0gBy53=Qs7oig=C1I7bLhL=JD?#(!bFBls8@iwXE9COCqRk@1)yZ(Dhre8U)- z5cZG7czMgpTiQQeV`O|N&jfr6-^wFUo^KbBco#7eU>v15QQmYoF$|W8ScH=ToW$;l zIN1?=#!bXjl;adf@WM^RsrU{iIRag8P=QnB4J&V`QM~IXh31-w3i%&YSVGoZ6`^?L zjM2$?n5^QRT0nALi05!hXR^K?k=N0y3_*r?g-w!6=0yr?5*@%kRlas-Yv(2+mI*_ds3LoQpRaa^># zwl*3|CZoQvr=xYPsir6^E&FQP*h)nkX0|ma($Pt$lN_u4Yz>NY3UCg|Mj3Lo{EH%n zB>$qwpq1@78hxd!<5LPvzQ3N{49BVO!@d-I{-ZVqIE$R-;9N)W0lt`3;`=zy5q!;B zj4S28QEds?z^x7g_cAQO+&s)x8Dj;Mq|3lPFANVYKa3qm@Zqr>H^@({{6xdU7s5E% z8Q@ARmmj0n63Fh9R$8<+WVa00$O}300!)B&DKrDg5xmeVa5v_G#c3XbI61Ar?bLO? zBlzfEfmO6p(h)rOl~^UuTX|l&mnUl+&FL%R3apgpBJy0f!8t#aWffM-UMqV!e`dwm zGaX~!&nIiy6OldH`QER70VkKXrT}#mya~;Y;00fUAJS9f5!PjEFJZt6IQ1Eic81RhOV6E(q$nGrT19lFMv0-$*T#t>i%gU~Og12-EzB3{_ zF|Z7M8IiHpOhY>3o8`V4a77ImOA8#q=R!N$MquID%KQ z9j{^`7C8cE_Cz~g!G*G2wmDpgU>v=!Js06(NAPO5V=FfgiygsN)h*a6Pg!}2V#g;Z zY462haJFNMJc&y!fp4ZYuzp<{D!K`q<%t}50>n(2w+c8`hit#BvlZPYnFou*NH{RCT znrP01p_YB}()7ah+&TP+Dc){*%kCU*2%YvhzLX#0MoY+6lp8~*eSv>qMIN)46=h`s zR(4fUdP%$5W>lUnX1dcR*PC6#W-G^8|Z zxscqZCS6^C)hv`zOlQ4|F&m>SAK_Bb($SM-#bmwV8^svQMut7jtgBJht^*gCIBP*G z{}LRbb2=9!U3|ixkp6)qv;3lHVd+bghw@3GIbfv@SiSFX1g#!4(m>payBtAF7@zay zUMu&aPb4v;CDS;grPARppK>PO94hx%xkpou?*iOGVs~SWBY0wi%;8vzb&lYP4KjtY z&dNFxt4XC6)Wwo%hjk&bLQ^Dbt*ljIRrM7C){xjexYrRhd(2RCtgt}K-Ilk)QT3@* zqxvp;aL~g;cyL(n1E1rE%U!t760(7RUns^fa}+k@VS{>UV*xgH75IVNtub^julwCabNiR^50k zba*%vq|A(#+j8VKO|kaSWMj<)JOb8?M|?(@v1Xjy8j)MOJTE$I3K@(u-;`BWR&_84 zi`SN3erh0 zOs6uLR66SgEfL`wy#+xHsZ23v;ZZ#12p!K=cpO_Dq2sv{PvA*M@F6nAOvO`qTs`QU zZ3WndrQD1sFp|+)&zL`fR}HhLjeSw>FA_elT6|Wd8QBT$Uq3zLS^p3%fe%N7_^ify+|Nl-73}P&!O4;a<+4P;()|g0FS5-~wn%gOpC?kJ;&uB|G z#7D(iTDl(hb-hYs|1sgSjmF#d??rBTOG~mL9@A=_rhL)T2~`WznMBis{a;MjCrl~p z`2d>l491*R&N$;M#yZ-;M!r(` zw`N)|Ue5^k56}38O@)?avQ(}%vP=gpRk&KcdX26zTxj~`^ZcG8S79f18Q%CgYA45B zY2``{jl_aXiq_QB1UG8g4-$FUh2HtH1iR%5xg5`8k6dQ?*!>KL-5uZc__=OxfW2hi zQ!Wiyamnrq73p$i{wy1d<&uaj&K~|Ea>>^${rT9#?=U;$V!22zltr>oF2E7;1C+=D zX_E|&mo&d6OqNzTUs@!Exza36l9Wc7FAY*J^P~>XNdhaRR^n14F`0`EQq2OgUCy&| zu~tNW8HmV5*>nweQ2N2o@1Cq&*ikhjvZ%`{Yh|Gyk&IaiM|%8JW+`I0s#sZ|E%LC^ zMk99jLCaE^aBF6%Qun*Rh_voIAzL}0a~f0eq?H!s!UfLK2pzMVGl|ZvSHAz+WTjch z@g!%Zi7h?tSxIV(XCf<&Y-xRt$o#B54%lE@X$VJ`N2I>n%4B68N9h^B(gAGooRG&I zKKe)KjE-ZCw{&{HZL^VD4Hljmt<>zdqp%WFp4>xNx{obx04$x@{VH$eJihI2^(m0c zOx;_Dn3@4*cJbRK6)#s7zf)52N@el8CGVBIZ;CLu_=68iKN>kd7m>={9~Xb{$I@gj z@@8YIAMpu4lAT=qK@9mEQ!y6>m4h&{8U=HB=#HU3hYo10KMYv=UditFN=VMGRZ6pw zQ$MG2w!E*6N}YdbX>kk#8%tvtR5H18cMOFkF?{1_k{qm0hh?9JRB~LAjyrs03`6IT zQDMos=Tu|x_;cAl%(sV(FCWdBQ3^Ar8qouUF#}Mrj+(?U%E%u)UND?-J0~x2Y-Nn z#0P(bKfzZOM8y03hL__vTQq$@d~r7O&73o{-<&yf&N*8N7cNeg^W%l_lcuKuBr_tg zU?X910?m2CqX>*3DL>_hMiO?3ve9&4mnK+sN=Xc((?%yZcOj(-_HwLFLBY`k>sl;# z$af{*(dD?aK~$d&N*WHVajtcsOTH=jhNKF(W+O$iZfH%2$rLh3Bclm18Hr_6$tKC> zegAs2+;BCw#AFWA@>R)KLsnj#ws{vDJ?Pbhn5+kTup4_cAtvJ*vT0;t-ND{o?8N|P z;iAgEga{s*$YT-<{9G(({%!d*noYhq@&)}T;Y5pW;8mJlEsSDeW=pWiZDSwyYeMYM zEVA-hKFLQ*J_D9~GVl@HwV%BY2Q(pZj`J)A%1M)`G3$YbB49jce%D8adeW6moRdAsp6(*tg|XtP2rRUO<7G8NNh?Yc=k>(k6S;JdYwuK5~#4uZAFc<#Ksy`_s-$F*?{%7aC!$ zTBroU_WS;&H5~1GTC&Pl8|_+jPX>Wkt5%|#zZBBGd~u@O3?uj2MEkHdE-1**Iq2N!D#yH`Y8gL9<~FA2o24W<;5WMwOh6)FFo{YoC}kd?ji1fvgkV`yb;_4wJ6JNAgUHhr%&wtlyG awHuwKjk_ZpQx`L+gwJl)Jn%$7U3XbsMMb>t^{V}SHGiP)mQcE)vN9mPK+7Y zBR|wJt7DEnE`ZcVB5)8uio*sh+fE6`^_LnzDrZhWJxh`jWgFO!H2Fn-wn#%D&tuz< z`e5{Q#AjU$cF*pg863G5rB%a8JdMLL|0 z9*2>C^B<|Ed_cU!kskp^eo&N+;lw=Ukne-?J(}g9ennBFA|5U*@md446@W{nP0-Ym zcuKX=3@2I=PpL|3F5k(w7R`O79FcE~e4|SH#}bZwr%Jz7r6(R!`gKsgMw1+*snX)o zh}RgvMgUEz@+4$f5>I7QoQxJY*^&gxh`A{;&=RLu63@!!$dIp$e5DG@!lf3c_~T6p z>`NnG9<|<5Ow#5rg7O9292n2wkz5u5)n@Q%h1QnDQ{57$;&%vH5>ItYgz$TuYDqlR ztr3#XjeM>udc-R%*}d_WXf2-^`7EhAbWHW9LHQJobI{ON9gbHNCn{8VYd&ponkAuY zwcSQOk&oqwBcG@XKUQ)?IZxx$7VRua>M(kw4IalIa5~PAk1QL3u8xki=iix@#Dm_3 z^Bs_BNj!+{aJn2eayaR=OncXX0G>0B6bjhOdmQ!dN^qCK8R6RYs#C zGpPiKjUG>TpXCQvCL3qVA$iZ@Y~KnU`6JG;Bu*!?ccip;jl4^=g^X~Ea}YS2v%=qc~u z0$gbL@(n4Z9qsv3BY-8)wk3-fa-DIWyd9Lc>o{iha3NCUpXe$F4PU9IxGYg*(bc!5 z3%bic(9Muk+tSUqF*4ULA^ zRc;@oT%r35$XUNN$l-U}xRYmNpg^9pDDV$=6&}X$0ETn; zN{p~1UaqXde2l~>OX7j6!gUB^v?XymacgJeI(gQ}vjpKE)zN-rRbjR~gF-`69a)7w zyqUNLV_ZfA;YHG!W@=42qmHH4-9g!nddxZ%ydMJ?!Dp=OvKWh$A>6VB1U^o7f=6qd zpEx(+erB=p^0Yi<+3*tQCfte=L@kMD$0lrHn3h=*&&ivyMRpk3k#usI4`35E%acKQ z5+~%qVM4cv`V(7li);_d_FvsM7UTWHxD|KHUlEfh3}3&M7h}G}t++$B1!Y^cs(?J9 zG4r%W&r@LSu(Nu`qR3ye2utPhpgexeJ+e#JJf~|uQg5~6Q;rFi#JhgBVVgWAkIEyC zJf=*KDtSaXC-8~O!M~Xt2Un%@42)qGi!jH>XkCa`Lx@Ns z91U^LBACK=xi^xaMjg-MNPliem~+Lvu*R{U;A(B@)s^6ADIevCvtPlvkR`L1G`8bO zc`zss*0O(-e1JQ!6VqHwQ-IUcF&*_VnyVw0*P|z57k0}7L3yCIdtV;XeRx;{CU`8n z+%NaZy+-c;&FpfYuDnPAsv4onnO$F9|NsAW4>$-%ixVo@(XeqE^sOw3 zBu3`v_pDvpMLmg;zXts(6UBvD;qvm@`Tp0a%BacX_f4p8Qs^9`=zl^$cPCuhRRu5>v&&|Yh)#u+w%F>lbNWM zFEP8O#O$O*tx}zAC|XB;QS!5%oO*m_$UPP_{4++>a<|+icRF&nGTo)*PTkde;FaEJ z)R=kS$e%G2vv4&N;%r>YM0$s8bvVf4dnW43?Q)ykD!0fMxmh;LCL_0N*hD5)#OP@~ zh9FyYQ4*Bf>L^2ka%-&@V^D7SRqO|4OD(}1l$(&9p?r}`^uKjsH`v+yfQ)&^v)irkWp7PT)>Nmv#+b)ay-IG9)pDb( zl9jSTmdi4^0a>yXon?tEmPIV5u17CfC<|mh@4hiIPv*)TxsC~Dnp|t-Ce7?DCWErN z4o4ZeQF&N=1Z7pNE;F+7==~6s6~A(xk>xteFNUBjtHq6mfK`4pYzW)G6^$&>7I7I_ zOxM*_JR2J57m$W#)?J$#S;*PC>OMynsFUaGWil_hrAFpbc1f%-YGjV0VTowyxT9Li zDk8@!pvGPWY~)(b)^#JK%Q_r*y9xF-!*lK5>5#iOul>6na`)x2eW*k30eyeaA@_~E z_J`U3$Z3V%?T>t%{YmEdhH&$q`mFttFdAn^8zMbVn@zGg-ZZZnnp4X7Kr5J?Z1ft6 zl%m0?KSV!Ui}LypMITRGnBS|d&{Y?nM3K3pkdb#1&K!x1f-qWyaWdt$EGcRUY&_Iq zH+7qhs#c{H+QzYu zn3ZW{#&Ha@m7!O3K$=`bhUU5N7`eIzLw)9s`rV6GW@@H%nMQb5ElS5Ces*5Oi?7m4 zZy{CzAr^dbUVxRn!=i||%r|6RoQ}o((723wQ8QtPwS1V@^pR?1YFB9mS4B(=aQ znIsdX(#0y@WfhW;xRHva%M$9ccnyP%OiH?JVztXEk7JmcblH?@mrbt0;1U-B-ROeH zy#-dw1Syx8i`Ck@Ny?;DqAr*&bPr2pyc8QLNdg+LfQoB~Gg6iWR9X!vdK|+9Z*>v( zuv`HxrG!`w2A6IkpfxVmsM57kB;#bPi?!NYClMJVg)Y|lfJRGLMj08M1Qb?4qiTpV z5=jCYQw^x_IEJDmAXdz1e!~_cV{0(DSgwH9yI8MEH^@jCAy>NCpuLSUTnc2Ei;X^@ zp)y1U8yT7eG(-XM%+!eT;YmOR)qsW_$1pMpXoLbmN*)`_srjNMPtB%J%zfyF<vd1Y@S+xRdmSP>t>|tMjl6rGLJe*~nvl!z`@2-1HZ}pho z^a#F7E6t~FD)iaFCvX!IW0-V8YP?7KJB*~o3EzK$C0v#zCCj|)PX1!WWs1yQ2QKks zKO?~wl%*Z6ONg$TBXSo< 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!')