diff --git a/BeoGateway.indigoPlugin.zip b/BeoGateway.indigoPlugin.zip
new file mode 100644
index 0000000..b01a9b6
Binary files /dev/null and b/BeoGateway.indigoPlugin.zip differ
diff --git a/Info.plist b/Info.plist
new file mode 100644
index 0000000..9bcb9d4
--- /dev/null
+++ b/Info.plist
@@ -0,0 +1,25 @@
+
+
+
+
+ PluginVersion
+ 1.0.0
+ ServerApiVersion
+ 1.0.0
+ IwsApiVersion
+ 1.0.0
+ CFBundleDisplayName
+ BeoGateway Plugin
+ CFBundleIdentifier
+ uk.co.lukes_plugins.BeoGateway.plugin
+ CFBundleVersion
+ 1.0.0
+ CFBundleURLTypes
+
+
+ CFBundleURLName
+ https://github.com/LukeSpad/BeoGateway
+
+
+
+
diff --git a/ReadMe b/ReadMe
index 612a3bc..f7970bf 100644
--- a/ReadMe
+++ b/ReadMe
@@ -1,36 +1,204 @@
-THIS APPLICATION IS NOT AFFILIATED WITH OR ENDORSED BY BANG & OLUFSEN IN ANY WAY
+#BeoGateway: *Indigo Server Plugin for Bang and Olufsen AV Systems*
-Only compatible with Apple Mac computers
+
-Basic monitoring and reporting of traffic on the Bang and Olufsen MasterLink network, including basic MasterLink packet
-information, the MLGW protocol, and the Home Automation Protocol
+*Please note this plugin is not endorsed or affiliated with Bang & Olufsen in any way*
+##Introduction
+Plugin for the [Indigo Domotics](https://www.indigodomo.com) Home Automation software allowing integration and control of Bang and Olufsen AV systems.
+The plugin provides the following functionality:
+
+- Defines an Indigo AV Renderer device type with state monitoring of
+ - Current Source ID and Source Name,
+ - PlayState,
+ - Current Channel/Track number and Name, and
+ - Volume
+- Light and appliance control via Beo4/BeoRemote One LIGHT and CONTOL commands
+- Native control of Apple Music via user defined audio source (*N.MUSIC by default*), using Beo4/BeoRemote One to control transport
+- One touch activation of audio experiences via Indigo UI, any Indigo trigger, or Apple Home app. [^1]
+ - If all sources are in standby, a single press starts a pre-defined default audio experience (*RADIO by default*)
+ - If sources are already playing, a single press joins the current audio experience
+- Send Home Integration Protocol commands to the gateway (BLGW only)
+- State change reporting in the Apple Notification Center
+- Basic monitoring and reporting of traffic on the Bang and Olufsen MasterLink network, including:
+ - MasterLink packet information,
+ - MLGW protocol data, and
+ - Home Automation Protocol data
+
+[1]: Via the [HomeKit Bridge](https://www.indigodomo.com/pluginstore/172/) Plugin
+
+##Requirements
+Compatible with legacy DataLink (via 1611 converter), MasterLink, and modern NetworkLink products, including the BeoLink Converter ML/NL.
The code requires a MasterLink or BeoLink Gateway to run - it is untested with the BeoLiving Intelligence
-The code draws heavily on the existing MLGW project by user Giachello: https://github.com/giachello/mlgw,
-but is recoded for Python 2.7
+##Basic Setup and Use
+1. Install the plugin from the Indigo Plugin Store.
+2. Configure the plugin providing the following information for your gateway:
+ 1. IP address,
+ 2. port,
+ 3. login and password
+3. Select the default audio source for devices. You can toggle Apple Music control On/Off and assign it to a particular source
+4. The plugin will download the configuration from the gateway and create AV renderer devices accordingly
+5. Devices can only access sources defined in their config on the gateway and will recognise at least the channel/tracks included in their favourites lists. Some sources (e.g. NetworkLink sources) may recognise additional channels and track data.
+6. Devices can be switched On/Off and toggled via the indigo UI
-The repository includes:
+##Advanced Functions
+###Plugin Events/Triggers
+The plugin can respond to various events on the B&O network including:
+- All Standby Events
+- Light Commands
+- Control Commands
+- Beo4/BeoRemote One Commands to a Specific Source
+- Virtual Button presses
-main.py Test programme to monitor events on the Bang and Olufsen Network
+These events can be found under the 'BeoGateway Plugin Event' list in Indigo's Create New Trigger dialogue
-Resources Folder:
-CONSTANTS.py Dictionary Constants for packet/telegram decoding
-MLCONFIG.py Module to download JSON config data from the gateway and initialise serialisable dictionaries of rooms
- and devices on the network
-MLCLICLIENT.py Asynchat client to monitor basic Masterlink packets on the network
-MLGWCLIENT.py Asynchat client to monitor events on the MLGW protocol
-BLHIPCLIENT.py Asynchat client to monitor traffic on the BeolinkGateway Home Integration Protocol -
- only works with BLGW
-MLtn_CLIENT.py Asynchat client to monitor traffic on the MasterlinkGateway
- telnet events monitor (precursor of the HIP) - works with MLGW and BLGW
-ASBridge.py Apple Scripting Bridge module to allow direct control of the Apple Music App via the N.Music source
+###Plugin Actions
+Indigo Events can trigger a number of actions on the B&O Network including:
+####BeoGateway Plugin Actions:
+- Request BLGW Home Integration Protocol State Updates for all devices
+- Send a BLGW Home Integration Protocol Command (*only on BLGW - see below*)
+- Send Free Text Integration Protocol Command (*for advanced users - see below*)
+- Send Virtual Button
+- Post a Message to the Notification Centre (*see below*)
+- Send All Standby Command
-notify.scpt Apple script to allow notifications in the notification centre. The script needs exporting to the
- Resources folder as an application, with the "Stay Open After Run Handler" option checked
+####BeoGateway Device Actions:
+- Send Beo4 Source Selection Command
+- Send Beo4 Key
+- Send BeoRemote One Source Selection Command
+- Send BeoRemote One Key
+- Request Device State Update
-Scripts Folder:
-Green.scpt Applescripts that are triggered when colour keys are pressed on a Beo4/Beoremote One when controlling N.Music
-Blue.scpt
-Yellow.scpt
-Red.scpt
+###Protocol Monitoring
+Network traffic monitoring to the Indigo log be toggled in the plugin config menu:
+- [x] Verbose Mode
+
+A typical message is formatted as follows:
+```
+BeoGateway Plugin
+ ----------------------------------------------------------------------------
+ BLGW Home Integration Protocol: <--DATA-RECEIVED!-<< on 05/02/22 at 17:13:32
+ ============================================================================
+ Header: ['s Downstairs', 'Dining Room', 'AV renderer', 'BeoMaster 7000']
+ Payload: ['nowPlaying=', 'nowPlayingDetails=type: Legacy; track number: 2',
+ 'online=Yes', 'sourceName=RADIO', 'sourceUniqueId=RADIO:1790.1179011.2
+ 6002135@products.bang-olufsen.com', 'state=Play', 'volume=26']
+ ----------------------------------------------------------------------------
+ Zone: DOWNSTAIRS
+ Room: DINING ROOM
+ Type: AV RENDERER
+ Device: BeoMaster 7000
+ State_Update:
+ nowPlaying: BBC Radio 2
+ nowPlayingDetails:
+ type: Legacy
+ channel_track: 2
+
+ online: Yes
+ sourceName: RADIO
+ source: RADIO
+ sourceUniqueId: RADIO:1790.1179011.26002135@products.bang-olufsen.com
+ state: Play
+ volume: 26
+```
+
+###Apple Music Control
+Apple Music control can be toggled in the plugin config menu:
+- [x] Control Apple Music
+
+It will also report the current track information to the indigo log if 'post notifications' is toggled:
+- [x] post notifications
+
+If the plugin is configured to map a source to Apple Music, selection of that source on your remote will initiate playback immediately, shuffling from the entire library.
+If other AV renderers join the music experience playback is unaffected. Apple Music will stop playing when all active audio renderers go into a Standby state.
+The controls are mapped as follows:
+
+| Beo4 Key | BeoRemote One Key | Apple Music Action |
+| ----------- | ----------- | ----------- |
+| Go | Play | Play
+| Stop | Pause | Pause
+| Wind | Wind | Scan Forwards 15 Seconds
+| Rewind | Rewind | Scan Backwards 15 Seconds
+| Step Up | P+ | Next Track
+| Step Down | P- | Previous Track
+| Shift-1/Random | Random | Toggle Shuffle
+| - | Info | Produce Notification of Current Track Info and Print to Indigo Log
+| - | Guide | Print this table to Indigo Log
+| Green | Green | Shuffle Playlist 'Recently Played'
+| Yellow | Yellow | Play Digital Radio Stations from Playlist 'Radio'
+| Red | Red | More of the Same
+| Blue | Blue | Play the Album that the Current Track Resides On
+
+###State Reporting in Apple Notification Centre
+State reports in the notification centre can be toggled in the config menu
+- [x] Post Notifications
+
+![](Resources/Notification.png)
+
+It is also possible to trigger notifications via any Indigo event using the action:
+"BeoGateway Plugin Actions/Post Message in Notification Centre"
+
+###Home Integration Protocol Commands
+The BeoLink Gateway (BLGW) provides a Home Integration Protocol for external automation services to communicate with devices ont he B&O Network.
+A copy of the documentation for the protocol is included in the plugin's Resources/Documentation folder, and the overview below is lifted from that document.
+>With the introduction of BLGW, there is a standard way of identifying resources and specifying
+activity and state in the connected systems. Such activity can be represented unambiguously in
+the form of a text string.
+Home Integration Protocol allows external applications to directly interact with BLGW. This is done
+by means of a minimalist line-based protocol that directly transports the text representation of all
+activity.
+
+>A resource is uniquely identified by the combination of area, zone, type and name, and is represented
+uniquely in string form as a path with the form zone/room/type/name.
+>
+>For example:
+
+```
+Guest house/Kitchen/AV_RENDERER/BeoVision/
+```
+>An event or command is represented by a resource path followed by an action (event or command),
+optionally followed by attributes and values.
+>
+>Example of a simple command, and a command with 2 attributes:
+```
+Guest house/Kitchen/BUTTON/Lights ON/PRESS
+```
+```
+Guest house/Kitchen/AV_RENDERER/BeoVision/Beo4 command?Command=TV&Destination selector=Video_source
+```
+>Example state change event, with 1 attribute.
+```
+Guest house/Kitchen/BUTTON/Lights ON/STATE_UPDATE?STATE=1
+```
+>Example generic event matching all state updates (see documentation for generic programming):
+```
+*/*/*/*/STATE_UPDATE
+```
+The following commands are supported on HIP:
+
+| Command | Arguments | Description |
+| ----------- | ----------- | ----------- |
+| c | Generic ID | Command, from client to server.
+| f | Generic resource | State filter request, client to server.
+| e | Code, message | Error code, server to client.
+| q | Generic resource | State query, client to server.
+| s | Specific ID | State update, server to client.
+| r | Specific ID | State response, client to server.
+
+>All commands from client to server take a single argument, which is an identifier for resources,
+commands or events.
+
+>A complete message consists of:
+>1. The command (1 character)
+>2. Space (ASCII 0x20)
+>3. The argument, which is an encoded string
+>4. Line termination, consisting of CR+LF (the server will also accept a single CR)
+
+>For example, to press all buttons in the installation, the client sends (do not try this at home):
+```
+c */*/BUTTON/*/PRESS" + CR + LF
+```
+##Credits
+The code draws heavily on the existing [MLGW](https://github.com/giachello/mlgw) project by user Giachello,
+but is recoded for Python 2.7. His work underpins the decoding of raw MasterLink packets for the ML Command Line Protocol.
\ No newline at end of file
diff --git a/Resources/Bang-Olufsen-Logo.png b/Resources/Bang-Olufsen-Logo.png
new file mode 100644
index 0000000..ff55ed5
Binary files /dev/null and b/Resources/Bang-Olufsen-Logo.png differ
diff --git a/Resources/BnO_Resources/BO_Background.png b/Resources/BnO_Resources/BO_Background.png
new file mode 100644
index 0000000..939ded2
Binary files /dev/null and b/Resources/BnO_Resources/BO_Background.png differ
diff --git a/Resources/BnO_Resources/BO_Beolab4000.png b/Resources/BnO_Resources/BO_Beolab4000.png
new file mode 100644
index 0000000..0b45afa
Binary files /dev/null and b/Resources/BnO_Resources/BO_Beolab4000.png differ
diff --git a/Resources/BnO_Resources/BO_Beolab6000.png b/Resources/BnO_Resources/BO_Beolab6000.png
new file mode 100644
index 0000000..a3d5cac
Binary files /dev/null and b/Resources/BnO_Resources/BO_Beolab6000.png differ
diff --git a/Resources/BnO_Resources/BO_Beomaster7000_2x2.png b/Resources/BnO_Resources/BO_Beomaster7000_2x2.png
new file mode 100644
index 0000000..5153b03
Binary files /dev/null and b/Resources/BnO_Resources/BO_Beomaster7000_2x2.png differ
diff --git a/Resources/BnO_Resources/BO_Beovision6.png b/Resources/BnO_Resources/BO_Beovision6.png
new file mode 100644
index 0000000..352376d
Binary files /dev/null and b/Resources/BnO_Resources/BO_Beovision6.png differ
diff --git a/Resources/BnO_Resources/BO_Beovision7_32.png b/Resources/BnO_Resources/BO_Beovision7_32.png
new file mode 100644
index 0000000..c71c15a
Binary files /dev/null and b/Resources/BnO_Resources/BO_Beovision7_32.png differ
diff --git a/Resources/BnO_Resources/BO_Beovision7_40.png b/Resources/BnO_Resources/BO_Beovision7_40.png
new file mode 100644
index 0000000..f98cc52
Binary files /dev/null and b/Resources/BnO_Resources/BO_Beovision7_40.png differ
diff --git a/Resources/BnO_Resources/BO_Cam_DR.png b/Resources/BnO_Resources/BO_Cam_DR.png
new file mode 100644
index 0000000..78225dc
Binary files /dev/null and b/Resources/BnO_Resources/BO_Cam_DR.png differ
diff --git a/Resources/BnO_Resources/BO_Cam_Hall.png b/Resources/BnO_Resources/BO_Cam_Hall.png
new file mode 100644
index 0000000..9fd96c0
Binary files /dev/null and b/Resources/BnO_Resources/BO_Cam_Hall.png differ
diff --git a/Resources/BnO_Resources/BO_Cameras.png b/Resources/BnO_Resources/BO_Cameras.png
new file mode 100644
index 0000000..beaee95
Binary files /dev/null and b/Resources/BnO_Resources/BO_Cameras.png differ
diff --git a/Resources/BnO_Resources/BO_Device_Icons.png b/Resources/BnO_Resources/BO_Device_Icons.png
new file mode 100644
index 0000000..5bd04e4
Binary files /dev/null and b/Resources/BnO_Resources/BO_Device_Icons.png differ
diff --git a/Resources/BnO_Resources/BO_Inset_Speaker.png b/Resources/BnO_Resources/BO_Inset_Speaker.png
new file mode 100644
index 0000000..af5cd82
Binary files /dev/null and b/Resources/BnO_Resources/BO_Inset_Speaker.png differ
diff --git a/Resources/BnO_Resources/BO_Lights.png b/Resources/BnO_Resources/BO_Lights.png
new file mode 100644
index 0000000..0fdadfe
Binary files /dev/null and b/Resources/BnO_Resources/BO_Lights.png differ
diff --git a/Resources/BnO_Resources/BO_Logo.png b/Resources/BnO_Resources/BO_Logo.png
new file mode 100644
index 0000000..a3a50c8
Binary files /dev/null and b/Resources/BnO_Resources/BO_Logo.png differ
diff --git a/Resources/BnO_Resources/BO_Logo_Small.png b/Resources/BnO_Resources/BO_Logo_Small.png
new file mode 100644
index 0000000..4ead5c2
Binary files /dev/null and b/Resources/BnO_Resources/BO_Logo_Small.png differ
diff --git a/Resources/BnO_Resources/BO_Logo_Tiny.png b/Resources/BnO_Resources/BO_Logo_Tiny.png
new file mode 100644
index 0000000..893095a
Binary files /dev/null and b/Resources/BnO_Resources/BO_Logo_Tiny.png differ
diff --git a/Resources/BnO_Resources/BO_Numeric.png b/Resources/BnO_Resources/BO_Numeric.png
new file mode 100644
index 0000000..cfc1a8a
Binary files /dev/null and b/Resources/BnO_Resources/BO_Numeric.png differ
diff --git a/Resources/BnO_Resources/BO_Scenes.png b/Resources/BnO_Resources/BO_Scenes.png
new file mode 100644
index 0000000..4af9e6e
Binary files /dev/null and b/Resources/BnO_Resources/BO_Scenes.png differ
diff --git a/Resources/BnO_Resources/BO_Scenes_Long.png b/Resources/BnO_Resources/BO_Scenes_Long.png
new file mode 100644
index 0000000..388cdd9
Binary files /dev/null and b/Resources/BnO_Resources/BO_Scenes_Long.png differ
diff --git a/Resources/BnO_Resources/BO_Speaker_Icons.png b/Resources/BnO_Resources/BO_Speaker_Icons.png
new file mode 100644
index 0000000..80946ba
Binary files /dev/null and b/Resources/BnO_Resources/BO_Speaker_Icons.png differ
diff --git a/Resources/BnO_Resources/BO_Transport1.png b/Resources/BnO_Resources/BO_Transport1.png
new file mode 100644
index 0000000..b6085e5
Binary files /dev/null and b/Resources/BnO_Resources/BO_Transport1.png differ
diff --git a/Resources/BnO_Resources/BO_Transport2.png b/Resources/BnO_Resources/BO_Transport2.png
new file mode 100644
index 0000000..57663a8
Binary files /dev/null and b/Resources/BnO_Resources/BO_Transport2.png differ
diff --git a/Resources/BnO_Resources/BO_Transport3.png b/Resources/BnO_Resources/BO_Transport3.png
new file mode 100644
index 0000000..8006c30
Binary files /dev/null and b/Resources/BnO_Resources/BO_Transport3.png differ
diff --git a/Resources/BnO_Resources/BO_Transport_Basic.png b/Resources/BnO_Resources/BO_Transport_Basic.png
new file mode 100644
index 0000000..fa5135d
Binary files /dev/null and b/Resources/BnO_Resources/BO_Transport_Basic.png differ
diff --git a/Resources/BnO_Resources/BO_Transport_Basic_Long.png b/Resources/BnO_Resources/BO_Transport_Basic_Long.png
new file mode 100644
index 0000000..34ae801
Binary files /dev/null and b/Resources/BnO_Resources/BO_Transport_Basic_Long.png differ
diff --git a/Resources/BnO_Resources/Icons.png b/Resources/BnO_Resources/Icons.png
new file mode 100644
index 0000000..ac6d783
Binary files /dev/null and b/Resources/BnO_Resources/Icons.png differ
diff --git a/Resources/BnO_Resources/Icons2.png b/Resources/BnO_Resources/Icons2.png
new file mode 100644
index 0000000..91b6536
Binary files /dev/null and b/Resources/BnO_Resources/Icons2.png differ
diff --git a/Resources/Documentation/EN-BeoLink-handbook-v1-9.pdf b/Resources/Documentation/EN-BeoLink-handbook-v1-9.pdf
new file mode 100644
index 0000000..c096781
Binary files /dev/null and b/Resources/Documentation/EN-BeoLink-handbook-v1-9.pdf differ
diff --git a/Resources/Documentation/HIP_Protocol/BLGW_Home_Integration_Protocol.pdf b/Resources/Documentation/HIP_Protocol/BLGW_Home_Integration_Protocol.pdf
new file mode 100644
index 0000000..6766d23
Binary files /dev/null and b/Resources/Documentation/HIP_Protocol/BLGW_Home_Integration_Protocol.pdf differ
diff --git a/Resources/Documentation/HIP_Protocol/driverDevelopmentGuide.pdf b/Resources/Documentation/HIP_Protocol/driverDevelopmentGuide.pdf
new file mode 100644
index 0000000..d95351a
Binary files /dev/null and b/Resources/Documentation/HIP_Protocol/driverDevelopmentGuide.pdf differ
diff --git a/Resources/Documentation/Legacy_IR/MCL_service_technical-eng.pdf b/Resources/Documentation/Legacy_IR/MCL_service_technical-eng.pdf
new file mode 100644
index 0000000..4a2e71c
Binary files /dev/null and b/Resources/Documentation/Legacy_IR/MCL_service_technical-eng.pdf differ
diff --git a/Resources/Documentation/Legacy_IR/infrared_and_homelink.pdf b/Resources/Documentation/Legacy_IR/infrared_and_homelink.pdf
new file mode 100644
index 0000000..7393e4d
Binary files /dev/null and b/Resources/Documentation/Legacy_IR/infrared_and_homelink.pdf differ
diff --git a/Resources/Documentation/MLGW_Protocol/MlgwProto0240.pdf b/Resources/Documentation/MLGW_Protocol/MlgwProto0240.pdf
new file mode 100644
index 0000000..e32fbd8
Binary files /dev/null and b/Resources/Documentation/MLGW_Protocol/MlgwProto0240.pdf differ
diff --git a/Resources/Documentation/ML_NL_Converter/BLC_NL-ML Installation Guide Ver. 1.pdf b/Resources/Documentation/ML_NL_Converter/BLC_NL-ML Installation Guide Ver. 1.pdf
new file mode 100644
index 0000000..d38927d
Binary files /dev/null and b/Resources/Documentation/ML_NL_Converter/BLC_NL-ML Installation Guide Ver. 1.pdf differ
diff --git a/Resources/Documentation/ML_NL_Converter/beolink_nlml.pdf b/Resources/Documentation/ML_NL_Converter/beolink_nlml.pdf
new file mode 100644
index 0000000..636bdee
Binary files /dev/null and b/Resources/Documentation/ML_NL_Converter/beolink_nlml.pdf differ
diff --git a/Resources/Notification.png b/Resources/Notification.png
new file mode 100644
index 0000000..25bf10b
Binary files /dev/null and b/Resources/Notification.png differ
diff --git a/Server Plugin/Actions.xml b/Server Plugin/Actions.xml
new file mode 100644
index 0000000..b807e45
--- /dev/null
+++ b/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/Server Plugin/Devices.xml b/Server Plugin/Devices.xml
new file mode 100644
index 0000000..97e5f01
--- /dev/null
+++ b/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/Server Plugin/Events.xml b/Server Plugin/Events.xml
new file mode 100644
index 0000000..66473d6
--- /dev/null
+++ b/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/Server Plugin/MenuItems.xml b/Server Plugin/MenuItems.xml
new file mode 100644
index 0000000..d8b9ee9
--- /dev/null
+++ b/Server Plugin/MenuItems.xml
@@ -0,0 +1,15 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/Server Plugin/PluginConfig.xml b/Server Plugin/PluginConfig.xml
new file mode 100644
index 0000000..93cb36a
--- /dev/null
+++ b/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/Server Plugin/Resources/ASBridge.py b/Server Plugin/Resources/ASBridge.py
new file mode 100644
index 0000000..31a6fd4
--- /dev/null
+++ b/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/Server Plugin/Resources/ASBridge.pyc b/Server Plugin/Resources/ASBridge.pyc
new file mode 100644
index 0000000..0fa8ae4
Binary files /dev/null and b/Server Plugin/Resources/ASBridge.pyc differ
diff --git a/Server Plugin/Resources/BLHIP_CLIENT.py b/Server Plugin/Resources/BLHIP_CLIENT.py
new file mode 100644
index 0000000..0ec9dbb
--- /dev/null
+++ b/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/Server Plugin/Resources/BLHIP_CLIENT.pyc b/Server Plugin/Resources/BLHIP_CLIENT.pyc
new file mode 100644
index 0000000..d7ec4e8
Binary files /dev/null and b/Server Plugin/Resources/BLHIP_CLIENT.pyc differ
diff --git a/Server Plugin/Resources/CONSTANTS.py b/Server Plugin/Resources/CONSTANTS.py
new file mode 100644
index 0000000..7ea7e41
--- /dev/null
+++ b/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/Server Plugin/Resources/CONSTANTS.pyc b/Server Plugin/Resources/CONSTANTS.pyc
new file mode 100644
index 0000000..3583052
Binary files /dev/null and b/Server Plugin/Resources/CONSTANTS.pyc differ
diff --git a/Server Plugin/Resources/MLCLI_CLIENT.py b/Server Plugin/Resources/MLCLI_CLIENT.py
new file mode 100644
index 0000000..3ceef3a
--- /dev/null
+++ b/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/Server Plugin/Resources/MLCLI_CLIENT.pyc b/Server Plugin/Resources/MLCLI_CLIENT.pyc
new file mode 100644
index 0000000..b9b8d8d
Binary files /dev/null and b/Server Plugin/Resources/MLCLI_CLIENT.pyc differ
diff --git a/Server Plugin/Resources/MLCONFIG.py b/Server Plugin/Resources/MLCONFIG.py
new file mode 100644
index 0000000..c1fc0bc
--- /dev/null
+++ b/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/Server Plugin/Resources/MLCONFIG.pyc b/Server Plugin/Resources/MLCONFIG.pyc
new file mode 100644
index 0000000..d8c3b25
Binary files /dev/null and b/Server Plugin/Resources/MLCONFIG.pyc differ
diff --git a/Server Plugin/Resources/MLGW_CLIENT.py b/Server Plugin/Resources/MLGW_CLIENT.py
new file mode 100644
index 0000000..e10f46c
--- /dev/null
+++ b/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/Server Plugin/Resources/MLGW_CLIENT.pyc b/Server Plugin/Resources/MLGW_CLIENT.pyc
new file mode 100644
index 0000000..d6c86c8
Binary files /dev/null and b/Server Plugin/Resources/MLGW_CLIENT.pyc differ
diff --git a/Server Plugin/Resources/MLtn_CLIENT.py b/Server Plugin/Resources/MLtn_CLIENT.py
new file mode 100644
index 0000000..de1361d
--- /dev/null
+++ b/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/Server Plugin/Resources/MLtn_CLIENT.pyc b/Server Plugin/Resources/MLtn_CLIENT.pyc
new file mode 100644
index 0000000..7be73e3
Binary files /dev/null and b/Server Plugin/Resources/MLtn_CLIENT.pyc differ
diff --git a/Server Plugin/Resources/Notify.app/Contents/Info.plist b/Server Plugin/Resources/Notify.app/Contents/Info.plist
new file mode 100644
index 0000000..429fb28
--- /dev/null
+++ b/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/Server Plugin/Resources/Notify.app/Contents/MacOS/applet b/Server Plugin/Resources/Notify.app/Contents/MacOS/applet
new file mode 100644
index 0000000..7927912
Binary files /dev/null and b/Server Plugin/Resources/Notify.app/Contents/MacOS/applet differ
diff --git a/Server Plugin/Resources/Notify.app/Contents/PkgInfo b/Server Plugin/Resources/Notify.app/Contents/PkgInfo
new file mode 100644
index 0000000..3253614
--- /dev/null
+++ b/Server Plugin/Resources/Notify.app/Contents/PkgInfo
@@ -0,0 +1 @@
+APPLaplt
\ No newline at end of file
diff --git a/Server Plugin/Resources/Notify.app/Contents/Resources/Scripts/main.scpt b/Server Plugin/Resources/Notify.app/Contents/Resources/Scripts/main.scpt
new file mode 100644
index 0000000..c478d35
Binary files /dev/null and b/Server Plugin/Resources/Notify.app/Contents/Resources/Scripts/main.scpt differ
diff --git a/Server Plugin/Resources/Notify.app/Contents/Resources/applet.icns b/Server Plugin/Resources/Notify.app/Contents/Resources/applet.icns
new file mode 100644
index 0000000..33f6b86
Binary files /dev/null and b/Server Plugin/Resources/Notify.app/Contents/Resources/applet.icns differ
diff --git a/Server Plugin/Resources/Notify.app/Contents/Resources/applet.rsrc b/Server Plugin/Resources/Notify.app/Contents/Resources/applet.rsrc
new file mode 100644
index 0000000..9471572
Binary files /dev/null and b/Server Plugin/Resources/Notify.app/Contents/Resources/applet.rsrc differ
diff --git a/Server Plugin/Resources/Notify.app/Contents/Resources/description.rtfd/TXT.rtf b/Server Plugin/Resources/Notify.app/Contents/Resources/description.rtfd/TXT.rtf
new file mode 100644
index 0000000..4d315a3
--- /dev/null
+++ b/Server Plugin/Resources/Notify.app/Contents/Resources/description.rtfd/TXT.rtf
@@ -0,0 +1,5 @@
+{\rtf1\ansi\ansicpg1252\cocoartf2636
+\cocoatextscaling0\cocoaplatform0{\fonttbl}
+{\colortbl;\red255\green255\blue255;}
+{\*\expandedcolortbl;;}
+}
\ No newline at end of file
diff --git a/Server Plugin/Resources/Scripts/blue.scpt b/Server Plugin/Resources/Scripts/blue.scpt
new file mode 100644
index 0000000..c68227c
Binary files /dev/null and b/Server Plugin/Resources/Scripts/blue.scpt differ
diff --git a/Server Plugin/Resources/Scripts/green.scpt b/Server Plugin/Resources/Scripts/green.scpt
new file mode 100644
index 0000000..fb4aa4a
Binary files /dev/null and b/Server Plugin/Resources/Scripts/green.scpt differ
diff --git a/Server Plugin/Resources/Scripts/red.scpt b/Server Plugin/Resources/Scripts/red.scpt
new file mode 100644
index 0000000..8fd8a2e
Binary files /dev/null and b/Server Plugin/Resources/Scripts/red.scpt differ
diff --git a/Server Plugin/Resources/Scripts/yellow.scpt b/Server Plugin/Resources/Scripts/yellow.scpt
new file mode 100644
index 0000000..2f082f5
Binary files /dev/null and b/Server Plugin/Resources/Scripts/yellow.scpt differ
diff --git a/Server Plugin/Resources/__init__.py b/Server Plugin/Resources/__init__.py
new file mode 100644
index 0000000..a6131c1
--- /dev/null
+++ b/Server Plugin/Resources/__init__.py
@@ -0,0 +1 @@
+# init
diff --git a/Server Plugin/Resources/__init__.pyc b/Server Plugin/Resources/__init__.pyc
new file mode 100644
index 0000000..c3063b0
Binary files /dev/null and b/Server Plugin/Resources/__init__.pyc differ
diff --git a/Server Plugin/plugin.py b/Server Plugin/plugin.py
new file mode 100644
index 0000000..184cfcf
--- /dev/null
+++ b/Server Plugin/plugin.py
@@ -0,0 +1,1277 @@
+import indigo
+import asyncore
+import json
+import time
+import logging
+from datetime import datetime
+
+import Resources.CONSTANTS as CONST
+import Resources.MLCONFIG as MLCONFIG
+import Resources.MLGW_CLIENT as MLGW
+import Resources.MLCLI_CLIENT as MLCLI
+import Resources.BLHIP_CLIENT as BLHIP
+import Resources.MLtn_CLIENT as MLtn
+import Resources.ASBridge as ASBridge
+
+
+class Plugin(indigo.PluginBase):
+
+ def __init__(self, pluginId, pluginDisplayName, pluginVersion, pluginPrefs):
+ indigo.PluginBase.__init__(self, pluginId, pluginDisplayName, pluginVersion, pluginPrefs)
+
+ self.pollinterval = 595 # ping sent out at 9:55, response evaluated at 10:00
+ self.trackmode = self.pluginPrefs.get("trackMode")
+ self.verbose = self.pluginPrefs.get("verboseMode")
+ self.notifymode = self.pluginPrefs.get("notifyMode")
+ self.debug = self.pluginPrefs.get("debugMode")
+ self.default_audio_source = self.pluginPrefs.get("defaultAudio")
+ self.itunes_control = self.pluginPrefs.get("iTunesControl")
+ self.itunes_source = self.pluginPrefs.get("iTunesSource")
+ self.goto_flag = datetime(1982, 04, 01, 13, 30, 00, 342380)
+
+ self.triggers = []
+
+ self.host = str(self.pluginPrefs.get('address')).encode('ascii')
+ self.port = [int(self.pluginPrefs.get('mlgw_port')),
+ int(self.pluginPrefs.get('hip_port')),
+ 23] # Telnet port - 23
+ self.user = str(self.pluginPrefs.get('userID')).encode('ascii')
+ self.pwd = str(self.pluginPrefs.get('password')).encode('ascii')
+
+ # Instantiate an AppleScriptBridge MusicController for N.MUSIC control of apple Music
+ self.iTunes = ASBridge.MusicController()
+
+ def triggerStartProcessing(self, trigger):
+ self.triggers.append(trigger)
+
+ def getDeviceStateList(self, dev):
+ stateList = indigo.PluginBase.getDeviceStateList(self, dev)
+
+ if stateList is not None:
+ if dev.deviceTypeId in self.devicesTypeDict and dev.deviceTypeId == u"AVrenderer":
+ # Add dynamic states onto stateList for devices of type AV renderer
+ try:
+ sources = dev.pluginProps['sources']
+ for source_name in sources:
+ stateList.append(
+ {
+ "Disabled": False,
+ "Key": "source." + source_name,
+ "StateKey": sources[source_name]['source'],
+ "StateLabel": "Source is " + source_name,
+ "TriggerLabel": "Source is " + source_name,
+ "Type": 50
+ }
+ )
+ except KeyError:
+ indigo.server.log("Device " + dev.name + " does not have state key 'sources'\n",
+ level=logging.WARNING)
+ pass
+ return stateList
+
+ def deviceStartComm(self, dev):
+ dev.stateListOrDisplayStateIdChanged()
+
+ def __del__(self):
+ indigo.PluginBase.__del__(self)
+
+ # ########################################################################################
+ # ##### Indigo UI menu constructors
+ @staticmethod
+ def zonelistgenerator(filter="", valuesDict=None, typeId="", targetId=0):
+ myarray = [("*", "All"), ("Main", "Main")]
+ for room in CONST.rooms:
+ if (room['Zone'], room['Zone']) not in myarray:
+ myarray.append((room['Zone'], room['Zone']))
+ return myarray
+
+ @staticmethod
+ def roomlistgenerator(filter="", valuesDict=None, typeId="", targetId=0):
+ myarray = [("99", "Any")]
+ for room in CONST.rooms:
+ myarray.append((room['Room_Number'], room['Room_Name']))
+ return myarray
+
+ @staticmethod
+ def roomlistgenerator2(filter="", valuesDict=None, typeId="", targetId=0):
+ myarray = [("*", "All"), ("global", "global")]
+ for room in CONST.rooms:
+ myarray.append((room['Room_Name'], room['Room_Name']))
+ return myarray
+
+ @staticmethod
+ def keylistgenerator(filter="", valuesDict=None, typeId="", targetId=0):
+ myarray = []
+ for item in CONST.beo4_commanddict.items():
+ myarray.append(item)
+ return myarray
+
+ @staticmethod
+ def keylistgenerator2(filter="", valuesDict=None, typeId="", targetId=0):
+ myarray = []
+ for item in CONST.beoremoteone_keydict.items():
+ myarray.append(item)
+ return myarray
+
+ @staticmethod
+ def beo4sourcelistgenerator(filter="", valuesDict=None, typeId="", targetId=0):
+ myarray = []
+ for item in CONST.beo4_srcdict.items():
+ myarray.append(item)
+ return myarray
+
+ @staticmethod
+ def beo4sourcelistgenerator2(filter="", valuesDict=None, typeId="", targetId=0):
+ myarray = [('Any Source', 'Any Source'), ('Any Audio', 'Any Audio'), ('Any Video', 'Any Video')]
+ for item in CONST.beo4_srcdict.items():
+ myarray.append(item)
+ return myarray
+
+ @staticmethod
+ def br1sourcelistgenerator(filter="", valuesDict=None, typeId="", targetId=0):
+ myarray = []
+ for item in CONST.available_sources:
+ myarray.append(item)
+ return myarray
+
+ @staticmethod
+ def destinationlistgenerator(filter="", valuesDict=None, typeId="", targetId=0):
+ myarray = []
+ for item in CONST.destselectordict.items():
+ myarray.append(item)
+ return myarray
+
+ @staticmethod
+ def srcactivitylistgenerator(filter="", valuesDict=None, typeId="", targetId=0):
+ myarray = [(0x00, "Unknown")]
+ for item in CONST.sourceactivitydict.items():
+ if item not in myarray:
+ myarray.append(item)
+ return myarray
+
+ @staticmethod
+ def hiptypelistgenerator(filter="", valuesDict=None, typeId="", targetId=0):
+ myarray = []
+ for item in CONST.blgw_devtypes.items():
+ myarray.append(item)
+ return myarray
+
+ # ########################################################################################
+ # ##### Indigo UI Prefs
+ def set_login(self, ui):
+ # If LogIn data is updated in config, update the values in the plugin
+ self.user = str(ui.get('userID')).encode('ascii')
+ self.pwd = str(ui.get('password')).encode('ascii')
+ indigo.server.log("BeoGateway device login details updated!", level=logging.DEBUG)
+
+ def set_gateway(self, ui):
+ # If gateway network address data is updated in config, update the values in the plugin
+ self.host = str(ui.get('address')).encode('ascii')
+ self.port = [int(ui.get('mlgw_port')),
+ int(ui.get('hip_port')),
+ 23] # Telnet port - 23
+ indigo.server.log("BeoGateway device network address details updated!", level=logging.DEBUG)
+
+ def set_trackmode(self, ui):
+ # If Track Mode setting is updated in config, update the value in the plugin
+ self.trackmode = ui.get("trackMode")
+ indigo.server.log("Track reporting set to " + str(self.trackmode), level=logging.DEBUG)
+
+ def set_verbose(self, ui):
+ # If Verbose Mode setting is updated in config, update the value in the plugin
+ self.verbose = ui.get("verboseMode")
+ indigo.server.log("Verbose Mode set to " + str(self.verbose), level=logging.DEBUG)
+
+ def set_notifymode(self, ui):
+ # If Notify Mode setting is updated in config, update the value in the plugin
+ self.notifymode = ui.get("notifyMode")
+ indigo.server.log("Verbose Mode set to " + str(self.notifymode), level=logging.DEBUG)
+
+ def set_debug(self, ui):
+ # If Debug Mode setting is updated in config, update the value in the plugin
+ self.debug = ui.get("debugMode")
+
+ # Set the debug flag for the clients
+ self.mlcli.debug = self.debug
+ self.mlgw.debug = self.debug
+ if self.mlcli.isBLGW:
+ self.blgw.debug = self.debug
+ else:
+ self.mltn.debug = self.debug
+
+ # Report the debug flag change
+ indigo.server.log("Debug Mode set to " + str(self.debug), level=logging.DEBUG)
+
+ def set_music_control(self, ui):
+ # If Apple Music Control setting is updated in config, update the value in the plugin
+ self.itunes_control = ui.get("iTunesControl")
+ self.itunes_source = ui.get("iTunesSource")
+
+ if self.itunes_control:
+ indigo.server.log("Apple Music Control enabled on source: " + str(self.itunes_source), level=logging.DEBUG)
+ else:
+ indigo.server.log("Apple Music Control set to " + str(self.itunes_control), level=logging.DEBUG)
+
+ def set_default_audio(self, ui):
+ # Define default audio source for AVrenderers
+ self.default_audio_source = ui.get("defaultAudio")
+ indigo.server.log("Default Audio Source set to " + str(self.default_audio_source), level=logging.DEBUG)
+
+ # ########################################################################################
+ # ##### Indigo UI Actions
+ def send_beo_4key(self, action, device):
+ device_id = int(device.address)
+ key_code = int(action.props.get("keyCode", 0))
+ destination = int(action.props.get("destination", 0))
+ link = int(action.props.get("linkcmd", 0))
+ if destination == 0x06:
+ self.mlgw.send_beo4_cmd(device_id, destination, key_code, 0x01, link)
+ else:
+ self.mlgw.send_beo4_cmd(device_id, destination, key_code, 0x00, link)
+
+ def send_beo4_src(self, action, device):
+ device_id = int(device.address)
+ key_code = int(action.props.get("keyCode", 0))
+ destination = int(action.props.get("destination", 0))
+ link = int(action.props.get("linkcmd", 0))
+ if destination == 0x06:
+ self.mlgw.send_beo4_cmd(device_id, destination, key_code, 0x01, link)
+ else:
+ self.mlgw.send_beo4_cmd(device_id, destination, key_code, 0x00, link)
+
+ def send_br1_key(self, action, device):
+ device_id = int(device.address)
+ key_code = int(action.props.get("keyCode", 0))
+ network_bit = int(action.props.get("netBit", 0))
+ self.mlgw.send_beoremoteone_cmd(device_id, key_code, network_bit)
+
+ def send_br1_src(self, action, device):
+ device_id = int(device.address)
+ key_code = str(action.props.get("keyCode", 0))
+ network_bit = int(action.props.get("netBit", 0))
+ try:
+ key_code = CONST.beoremoteone_commanddict.get(key_code)
+ self.mlgw.send_beoremoteone_select_source(device_id, key_code[0], key_code[1], network_bit)
+ except KeyError:
+ pass
+
+ def send_hip_cmd(self, action):
+ zone = str(action.props.get("zone", 0))
+ room = str(action.props.get("room", 0))
+ device_type = str(action.props.get("devType", 0))
+ device_id = str(action.props.get("deviceID", 0))
+ hip_command = str(action.props.get("hip_cmd", 0))
+ telegram = "c " + zone + "/" + room + "/" + device_type + "/" + device_id + "/" + hip_command
+ if self.mlcli.isBLGW:
+ self.blgw.send_cmd(telegram)
+
+ def send_hip_cmd2(self, action):
+ hip_command = str(action.props.get("hip_cmd", 0))
+ if self.mlcli.isBLGW:
+ self.blgw.send_cmd(hip_command)
+
+ def send_hip_query(self, action):
+ zone = str(action.props.get("zone", 0))
+ room = str(action.props.get("room", 0))
+ device_type = str(action.props.get("devType", 0))
+ device_id = str(action.props.get("deviceID", 0))
+ if self.mlcli.isBLGW:
+ self.blgw.query(zone, room, device_type, device_id)
+
+ def request_state_update(self, action, device):
+ action_id = str(action.props.get("id", 0))
+ zone = str(device.pluginProps['zone'])
+ room = str(device.pluginProps['room'])
+ device_type = "AV renderer"
+ device_id = str(device.name)
+ if self.mlcli.isBLGW:
+ self.blgw.query(zone, room, device_type, device_id)
+
+ def send_virtual_button(self, action):
+ button_id = int(action.props.get("buttonID", 0))
+ button_action = int(action.props.get("action", 0))
+ self.mlgw.send_virtualbutton(button_id, button_action)
+
+ def post_notification(self, action):
+ title = str(action.props.get("title", 0))
+ body = str(action.props.get("body", 0))
+ self.iTunes.notify(body, title)
+
+ def all_standby(self, action):
+ self.mlgw.send_beo4_cmd(1, CONST.CMDS_DEST.get("ALL PRODUCTS"), CONST.BEO4_CMDS.get("STANDBY"))
+
+ def request_serial_number(self):
+ self.mlgw.get_serial()
+
+ def request_device_update(self):
+ if self.mlcli.isBLGW:
+ self.blgw.query(dev_type="AV renderer")
+
+ def reset_clients(self):
+ self.check_connection(self.mlgw)
+ self.check_connection(self.mlcli)
+
+ if self.mlcli.isBLGW:
+ self.check_connection(self.blgw)
+ else:
+ self.check_connection(self.mltn)
+
+ # ########################################################################################
+ # ##### Indigo UI Events
+ def light_key(self, message):
+ room = message['room_number']
+ key_code = CONST.BEO4_CMDS.get(message['command'].upper())
+
+ for trigger in self.triggers:
+ props = trigger.globalProps["uk.co.lukes_plugins.BeoGateway.plugin"]
+ if trigger.pluginTypeId == "lightKey" and \
+ (props["room"] == str(99) or props["room"] == str(room)) and \
+ props["keyCode"] == str(key_code):
+ indigo.trigger.execute(trigger)
+ break
+
+ def control_key(self, message):
+ room = message['room_number']
+ key_code = CONST.BEO4_CMDS.get(message['command'].upper())
+
+ for trigger in self.triggers:
+ props = trigger.globalProps["uk.co.lukes_plugins.BeoGateway.plugin"]
+ if trigger.pluginTypeId == "controlkey" and \
+ (props["room"] == str(99) or props["room"] == str(room)) and \
+ props["keyCode"] == str(key_code):
+ indigo.trigger.execute(trigger)
+ break
+
+ def beo4_key(self, message):
+ source = message['State_Update']['source']
+ source_type = message['State_Update']['source']
+ key_code = CONST.BEO4_CMDS.get(message['State_Update']['command'].upper())
+
+ for trigger in self.triggers:
+ props = trigger.globalProps["uk.co.lukes_plugins.BeoGateway.plugin"]
+ if trigger.pluginTypeId == "beo4Key" and props["keyCode"] == str(key_code):
+ if props["sourceType"] == "Any Source":
+ indigo.trigger.execute(trigger)
+ break
+ elif props["sourceType"] == "Any Audio" and "AUDIO" in source_type:
+ indigo.trigger.execute(trigger)
+ break
+ elif props["sourceType"] == "Any Audio" and "AUDIO" in source_type:
+ indigo.trigger.execute(trigger)
+ break
+ elif props["sourceType"] == source:
+ indigo.trigger.execute(trigger)
+ break
+
+ def virtual_button(self, message):
+ button_id = message['button']
+ action = message['action']
+ for trigger in self.triggers:
+ props = trigger.globalProps["uk.co.lukes_pugins.mlgw.plugin"]
+ if trigger.pluginTypeId == "virtualButton" and \
+ props["buttonID"] == str(button_id) and \
+ props["action"] == str(action):
+ indigo.trigger.execute(trigger)
+ break
+
+ # ########################################################################################
+ # ##### Indigo UI Device Controls
+ def actionControlDevice(self, action, node):
+ """ Callback Method to Control a Relay Device. """
+ if action.deviceAction == indigo.kDeviceAction.TurnOn:
+ self._dev_on(node)
+ elif action.deviceAction == indigo.kDeviceAction.TurnOff:
+ self._dev_off(node)
+ elif action.deviceAction == indigo.kDeviceAction.Toggle:
+ if node.states["onOffState"]:
+ self._dev_off(node)
+ else:
+ self._dev_on(node)
+ elif action.deviceAction == indigo.kDeviceAction.RequestStatus:
+ self._status_request(node)
+
+ def _dev_on(self, node):
+ indigo.server.log(node.name + " turned On")
+
+ # Get a local copy of the gateway states from server
+ active_renderers = self.gateway.states['AudioRenderers']
+ active_source = self.gateway.states['currentAudioSource']
+ active_sourceName = self.gateway.states['currentAudioSourceName']
+
+ if self.debug:
+ indigo.server.log('Active renderers: ' + active_renderers, level=logging.DEBUG)
+ indigo.server.log('Active Audio Source: ' + active_source, level=logging.DEBUG)
+
+ # Join if music already playing
+ if active_renderers != '' and active_source != 'Unknown':
+ # Send Beo4 command
+ source = active_source
+ self.mlgw.send_beo4_cmd(
+ int(node.address),
+ int(CONST.CMDS_DEST.get("AUDIO SOURCE")),
+ int(CONST.BEO4_CMDS.get(source))
+ )
+
+ # Update device states
+ sourceName = active_sourceName
+ key_value_list = [
+ {'key': 'onOffState', 'value': True},
+ {'key': 'playState', 'value': 'Play'},
+ {'key': 'source', 'value': sourceName},
+ {'key': 'mute', 'value': False},
+ ]
+
+ if self.debug:
+ indigo.server.log(
+ node.name + " joining current audio experience " + sourceName + " (" + source +
+ "). Joining active renderer(s): " + active_renderers,
+ level=logging.DEBUG
+ )
+
+ # Otherwise start default music source
+ else:
+ # Send Beo4 command
+ source = self.default_audio_source
+ self.mlgw.send_beo4_cmd(
+ int(node.address),
+ int(CONST.CMDS_DEST.get("AUDIO SOURCE")),
+ int(CONST.BEO4_CMDS.get(source))
+ )
+
+ # Update device states
+ sourceName = dict(CONST.available_sources).get(self.default_audio_source)
+ key_value_list = [
+ {'key': 'onOffState', 'value': True},
+ {'key': 'playState', 'value': 'Play'},
+ {'key': 'source', 'value': sourceName},
+ {'key': 'mute', 'value': False},
+ ]
+
+ if self.debug:
+ indigo.server.log(
+ node.name + " starting audio experience " + sourceName + " (" + source + ").",
+ level=logging.DEBUG
+ )
+
+ # Update states on server
+ node.updateStatesOnServer(key_value_list)
+ node.updateStateImageOnServer(indigo.kStateImageSel.AvPlaying)
+
+ # Add device to active renderers lists and update gateway
+ self.add_to_renderers_list(node.name, 'Audio')
+ key_value_list = [
+ {'key': 'currentAudioSource', 'value': source},
+ {'key': 'currentAudioSourceName', 'value': sourceName},
+ ]
+ self.gateway.updateStatesOnServer(key_value_list)
+
+ def _dev_off(self, node):
+ indigo.server.log(node.name + " turned Off")
+
+ # Send Beo4 command
+ self.mlgw.send_beo4_cmd(
+ int(node.address),
+ int(CONST.CMDS_DEST.get("AUDIO SOURCE")),
+ int(CONST.BEO4_CMDS.get('STANDBY'))
+ )
+
+ # Update states to standby values
+ node.updateStatesOnServer(CONST.standby_state)
+ node.updateStateImageOnServer(indigo.kStateImageSel.PowerOff)
+
+ # Remove device from active renderers lists
+ self.remove_from_renderers_list(node.name, 'All')
+
+ def _status_request(self, node):
+ if node.pluginProps['serial_no'] == 'NA': # Check if this is a netlink device
+ indigo.server.log(node.name + " does not support status requests")
+ else: # If netlink, request a status update
+ self.blgw.query(dev_type="AV renderer", device=node.name)
+
+ # ########################################################################################
+ # Define callback function for message return from B&O Gateway
+ def cb(self, name, header, payload, message):
+ # ########################################################################################
+ # Message handler
+ # Handle Beo4 Command Events
+ try:
+ if message['payload_type'] == "BEO4_KEY":
+ self.beo4_key(message)
+ except KeyError:
+ pass
+
+ # Handle Light and Command Events
+ try:
+ if message['Type'] == "LIGHT COMMAND":
+ self.light_key(message)
+ elif message['Type'] == "CONTROL COMMAND":
+ self.control_key(message)
+ except KeyError:
+ pass
+
+ # Handle Virtual Button Events
+ try:
+ if message['payload_type'] == "MLGW virtual button event":
+ self.virtual_button(message)
+ except KeyError:
+ pass
+
+ # Handle all standby events
+ try:
+ if message["command"] == "All Standby":
+ for node in indigo.devices.iter('uk.co.lukes_plugins.BeoGateway.plugin.AVrenderer'):
+ node.updateStatesOnServer(CONST.standby_state)
+ node.updateStateImageOnServer(indigo.kStateImageSel.PowerOff)
+
+ indigo.devices['Bang and Olufsen Gateway'].updateStatesOnServer(CONST.gw_all_stb)
+ except KeyError:
+ pass
+
+ # Handle AV Events
+ try:
+ # Use regular incoming messages to sync nowPlaying data
+ if self.gateway.states['AudioRenderers'] != '' and \
+ self.gateway.states['currentAudioSource'] == str(self.itunes_source) and \
+ self.itunes_control:
+ self._get_itunes_track_info(message)
+
+ # For messages of type AV RENDERER, scan keys to update device states
+ if message["Type"] == "AV RENDERER":
+ # Tidy up messages
+ self.av_sanitise(message)
+ # Filter messages that don't constitute meaningful state updates
+ actionable = self.filter_messages(message)
+ if self.debug:
+ indigo.server.log("Process message: " + str(actionable), level=logging.WARNING)
+ if actionable:
+ # Keep track of what sources are playing on the network
+ self.src_tracking(message)
+ # Update individual devices based on updates
+ self.dev_update(message)
+ except KeyError:
+ pass
+
+ # Report message content to log
+ if self.verbose:
+ if 'State_Update' in message and 'CONNECTION' in message['State_Update']:
+ # Don't print pong responses from regular client ping to check sockets are open -
+ # approx every 600 seconds
+ pass
+ elif 'Device' in message and message['Device'] == 'Clock':
+ # Don't print the Clock sync telegrams from the HIP -
+ # approx every 60 seconds
+ pass
+ elif 'payload_type' in message and message['payload_type'] == '0x14':
+ # Don't print the Clock sync telegrams from the ML -
+ # approx every 6 seconds
+ pass
+ elif 'payload_type' in message and message['payload_type'] == 'CLOCK' and not self.debug:
+ # Don't print the Clock message telegrams from the ML
+ pass
+ else:
+ self.message_log(name, header, payload, message)
+
+ # ########################################################################################
+ # AV Handler Functions
+
+ # #### Message Conditioning
+ def av_sanitise(self, message):
+ # Sanitise AV messages
+ try: # Check for missing source information
+ if message['State_Update']['source'] in [None, 'None', '']:
+ message['State_Update']['source'] = 'Unknown'
+ message['State_Update']['sourceName'] = 'Unknown'
+
+ # Update for standby condition
+ message['State_Update']['state'] = "Standby"
+ except KeyError:
+ pass
+
+ try: # Check for unknown state
+ if message['State_Update']['state'] in [None, 'None', '']:
+ message['State_Update']['state'] = 'Unknown'
+ except KeyError:
+ pass
+
+ try: # Sanitise unknown Channel/Tracks
+ if message['State_Update']['nowPlayingDetails']['channel_track'] in [0, 255, '0', '255']:
+ del message['State_Update']['nowPlayingDetails']['channel_track']
+ except KeyError:
+ pass
+
+ try: # Add sourceName if not in message block
+ if 'sourceName' not in message['State_Update']:
+ # Find the sourceName from the source list for this device
+ if 'Device' in message: # If device known use local source data
+ self.find_source_name(message['State_Update']['source'],
+ indigo.devices[message['Device']].pluginProps['sources'])
+ else: # If device not known use global source data
+ message['State_Update']['sourceName'] = \
+ dict(CONST.available_sources).get(message['State_Update']['source'])
+ except KeyError:
+ pass
+
+ try: # Catch GOTO_SOURCE commands and set the goto_flag
+ if message['payload_type'] == 'GOTO_SOURCE':
+ self.goto_flag = datetime.now()
+ if self.debug:
+ indigo.server.log("GOTO_SOURCE command received - goto_flag set", level=logging.WARNING)
+ except KeyError:
+ pass
+
+ def filter_messages(self, message):
+ # Filter state updates that are:
+ # 1. Standby states that are received between source changes:
+ # If device is changing source the state changes as follows [Old Source -> Standby -> New Source].
+ # If the standby condition is processed, the New Source state will be filtered by the condition below
+ #
+ # 2. Play states that received <1.5 seconds after standby state set:
+ # Some messages come in on the ML and HIP protocols relating to previous state etc.
+ # These can be ignored to avoid false states for the indigo devices
+ try:
+ for node in indigo.devices.iter('uk.co.lukes_plugins.BeoGateway.plugin.AVrenderer'):
+ if node.name == message['Device']:
+
+ # Get time since last state update for this device
+ time_delta1 = datetime.now() - node.lastChanged
+ time_delta1 = time_delta1.total_seconds()
+ # Get time since last GOTO_SOURCE command
+ time_delta2 = datetime.now() - self.goto_flag
+ time_delta2 = time_delta2.total_seconds()
+
+ # If standby command received <2.0 seconds after GOTO_SOURCE command , ignore
+ if 'state' in message['State_Update'] and message['State_Update']['state'] == "Standby" \
+ and time_delta2 < 2.0: # Condition 1
+ if self.debug:
+ indigo.server.log(message['Device'] + " ignoring Standby: " + str(round(time_delta2, 2)) +
+ " seconds elapsed since GOTO_STATE command - ignoring message!",
+ level=logging.DEBUG)
+ return False
+
+ # If message received <1.5 seconds after standby state, ignore
+ elif node.states['playState'] == "Standby" and time_delta1 < 1.5: # Condition 2
+ if self.debug:
+ indigo.server.log(message['Device'] + " in Standby: " + str(round(time_delta1, 2)) +
+ " seconds elapsed since last state update - ignoring message!",
+ level=logging.DEBUG)
+ return False
+ else:
+ return True
+ except KeyError:
+ return False
+
+ # #### State tracking
+ def src_tracking(self, message):
+ # Track active renderers via gateway device
+ try:
+ # If new source is an audio source then update the gateway accordingly
+ if message['State_Update']['source'] in CONST.source_type_dict.get('Audio Sources'):
+ try:
+ # Keep track of which devices are playing audio sources
+ if message['Device'] not in self.gateway.states['AudioRenderers'] and \
+ message['State_Update']['state'] not in ['Standby', 'Unknown', 'None']:
+
+ # Log current audio source (MasterLink allows a single audio source for distribution)
+ source = message['State_Update']['source']
+ sourceName = dict(CONST.available_sources).get(source)
+
+ self.gateway.updateStateOnServer('currentAudioSource', value=source)
+ self.gateway.updateStateOnServer('currentAudioSourceName', value=sourceName)
+
+ try:
+ self.gateway.updateStateOnServer('nowPlaying', value=message['State_Update']['nowPlaying'])
+ except KeyError:
+ self.gateway.updateStateOnServer('nowPlaying', value='Unknown')
+
+ self.add_to_renderers_list(message['Device'], 'Audio')
+
+ # Remove device from Video Renderers list if it is on there
+ if message['Device'] in self.gateway.states['VideoRenderers']:
+ self.remove_from_renderers_list(message['Device'], 'Video')
+
+ except KeyError:
+ pass
+
+ # If source is N.Music then control accordingly
+ if message['State_Update']['source'] == str(self.itunes_source) and self.itunes_control:
+ self.iTunes_transport_control(message)
+
+ # If new source is an video source then update the gateway accordingly
+ elif message['State_Update']['source'] in CONST.source_type_dict.get('Video Sources'):
+ try:
+ # Keep track of which devices are playing video sources
+ if message['Device'] not in self.gateway.states['VideoRenderers'] and \
+ message['State_Update']['state'] not in ['Standby', 'Unknown', 'None']:
+ self.add_to_renderers_list(message['Device'], 'Video')
+
+ # Remove device from Audio Renderers list if it is on there
+ if message['Device'] in self.gateway.states['AudioRenderers']:
+ self.remove_from_renderers_list(message['Device'], 'Audio')
+
+ except KeyError:
+ pass
+ except KeyError:
+ pass
+
+ def dev_update(self, message):
+ # Update device states
+ try:
+ for node in indigo.devices.iter('uk.co.lukes_plugins.BeoGateway.plugin.AVrenderer'):
+ if node.name == message['Device']:
+ # Handle Standby state
+ if message['State_Update']['state'] == "Standby":
+ # Update states to standby values
+ node.updateStatesOnServer(CONST.standby_state)
+ node.updateStateImageOnServer(indigo.kStateImageSel.PowerOff)
+
+ # Remove the device from active renderers list
+ self.remove_from_renderers_list(message['Device'], 'All')
+
+ # Post state update in Apple Notification Centre
+ if self.notifymode:
+ self.iTunes.notify(node.name + " now in Standby", "Device State Update")
+ return
+
+ # If device not in standby then update its state information
+ # get current states as list # Index
+ last_state = [
+ node.states['playState'], # 0
+ node.states['source'], # 1
+ node.states['nowPlaying'], # 2
+ node.states['channelTrack'], # 3
+ node.states['volume'], # 4
+ node.states['mute'], # 5
+ node.states['onOffState'] # 6
+ ]
+
+ # Initialise new state list - the device is not in standby so it must be on
+ new_state = last_state[:]
+ new_state[5] = False
+ new_state[6] = True
+
+ # Update device states with values from message
+ if 'state' in message['State_Update']:
+ if message['State_Update']['state'] not in ['None', 'Standby', '', None]:
+ if last_state[0] == 'Standby' and message['State_Update']['state'] == 'Unknown':
+ new_state[0] = 'Play'
+ elif last_state[0] != 'Standby' and message['State_Update']['state'] == 'Unknown':
+ pass
+ else:
+ new_state[0] = message['State_Update']['state']
+
+ if 'sourceName' in message['State_Update'] and message['State_Update']['sourceName'] != 'Unknown':
+ # Sanitise source name to avoid indigo key errors (remove whitespace)
+ source_name = message['State_Update']['sourceName'].strip().replace(" ", "_")
+ new_state[1] = source_name
+
+ if 'nowPlaying' in message['State_Update']:
+ # Update now playing information unless the state value is empty or unknown
+ if message['State_Update']['nowPlaying'] not in ['', 'Unknown']:
+ new_state[2] = message['State_Update']['nowPlaying']
+ # If the state value is empty/unknown and the source has not changed then no update required
+ elif new_state[1] != last_state[1]:
+ # If the state has changed and the value is unknown, then set as "Unknown"
+ new_state[2] = 'Unknown'
+
+ if 'nowPlayingDetails' in message['State_Update'] and \
+ 'channel_track' in message['State_Update']['nowPlayingDetails']:
+ new_state[3] = message['State_Update']['nowPlayingDetails']['channel_track']
+ elif new_state[1] != last_state[1]:
+ # If the state has changed and the value is unknown, then set as "Unknown"
+ new_state[2] = 0
+
+ if 'volume' in message['State_Update']:
+ new_state[4] = message['State_Update']['volume']
+
+ if new_state != last_state:
+ # Update states on server
+ key_value_list = [
+ {'key': 'playState', 'value': new_state[0]},
+ {'key': 'source', 'value': new_state[1]},
+ {'key': 'nowPlaying', 'value': new_state[2]},
+ {'key': 'channelTrack', 'value': new_state[3]},
+ {'key': 'volume', 'value': new_state[4]},
+ {'key': 'mute', 'value': new_state[5]},
+ {'key': 'onOffState', 'value': new_state[6]},
+ ]
+ node.updateStatesOnServer(key_value_list)
+
+ # Post notifications Notifications
+ if self.notifymode:
+ self.notifications(node.name, last_state, new_state)
+
+ # Update state image on server
+ if new_state[0] == "Stopped":
+ node.updateStateImageOnServer(indigo.kStateImageSel.AvPaused)
+ elif new_state[0] not in ['None', 'Unknown', 'Standby', '', None]:
+ node.updateStateImageOnServer(indigo.kStateImageSel.AvPlaying)
+
+ # If audio source active, update any other active audio renderers accordingly
+ try:
+ if new_state[0] not in ['None', 'Unknown', 'Standby', '', None] and \
+ message['State_Update']['source'] in CONST.source_type_dict.get('Audio Sources'):
+ self.all_audio_nodes_update(new_state, node.name, message['State_Update']['source'])
+ except KeyError:
+ pass
+
+ break
+ except KeyError:
+ pass
+
+ def all_audio_nodes_update(self, new_state, dev, source):
+ # Loop over all active audio renderers to update them with the latest audio state
+ for node in indigo.devices.iter('uk.co.lukes_plugins.BeoGateway.plugin.AVrenderer'):
+ if node.name in self.gateway.states['AudioRenderers'] and node.name != dev:
+ # Get current state of this node
+ last_state = [
+ node.states['playState'],
+ node.states['source'],
+ node.states['nowPlaying'],
+ node.states['channelTrack'],
+ node.states['volume'],
+ node.states['mute'],
+ ]
+
+ if last_state[:4] != new_state[:4]:
+ # Update the play state for active Audio renderers if new values are different from current ones
+ key_value_list = [
+ {'key': 'onOffState', 'value': True},
+ {'key': 'playState', 'value': new_state[0]},
+ {'key': 'source', 'value': new_state[1]},
+ {'key': 'nowPlaying', 'value': new_state[2]},
+ {'key': 'channelTrack', 'value': new_state[3]},
+ {'key': 'mute', 'value': False},
+ ]
+ node.updateStatesOnServer(key_value_list)
+ node.updateStateImageOnServer(indigo.kStateImageSel.AvPlaying)
+
+ # Update the gateway
+ if self.gateway.states['currentAudioSourceName'] != new_state[1]:
+ # If the source has changed, update both source and nowPlaying
+ sourceName = new_state[1]
+
+ key_value_list = [
+ {'key': 'currentAudioSource', 'value': source},
+ {'key': 'currentAudioSourceName', 'value': sourceName},
+ {'key': 'nowPlaying', 'value': new_state[2]},
+ ]
+ self.gateway.updateStatesOnServer(key_value_list)
+
+ elif self.gateway.states['nowPlaying'] != new_state[2] and new_state[2] not in ['', 'Unknown']:
+ # If the source has not changed, and nowPlaying is not Unknown, update nowPlaying
+ self.gateway.updateStateOnServer('nowPlaying', value=new_state[2])
+
+ # #### Active renderer list maintenance
+ def add_to_renderers_list(self, dev, av):
+ if av == "Audio":
+ renderer_list = 'AudioRenderers'
+ renderer_count = 'nAudioRenderers'
+ else:
+ renderer_list = 'VideoRenderers'
+ renderer_count = 'nVideoRenderers'
+
+ # Retrieve the renderers and convert from string to list
+ renderers = self.gateway.states[renderer_list].split(', ')
+
+ # Sanitise the list for stray blanks
+ if '' in renderers:
+ renderers.remove('')
+
+ # Add device to list if not already on there
+ if dev not in renderers:
+ renderers.append(dev)
+ self.gateway.updateStateOnServer(renderer_list, value=', '.join(renderers))
+ self.gateway.updateStateOnServer(renderer_count, value=len(renderers))
+
+ def remove_from_renderers_list(self, dev, av):
+ # Remove devices from renderers lists when the enter standby mode
+ if av in ['Audio', 'All']:
+ if dev in self.gateway.states['AudioRenderers']:
+ renderers = self.gateway.states['AudioRenderers'].split(', ')
+ renderers.remove(dev)
+
+ self.gateway.updateStateOnServer('AudioRenderers', value=', '.join(renderers))
+ self.gateway.updateStateOnServer('nAudioRenderers', value=len(renderers))
+
+ if av in ['Video', 'All']:
+ if dev in self.gateway.states['VideoRenderers']:
+ renderers = self.gateway.states['VideoRenderers'].split(', ')
+ renderers.remove(dev)
+
+ self.gateway.updateStateOnServer('VideoRenderers', value=', '.join(renderers))
+ self.gateway.updateStateOnServer('nVideoRenderers', value=len(renderers))
+
+ # If no audio sources are playing then update the gateway states
+ if self.gateway.states['AudioRenderers'] == '':
+ key_value_list = [
+ {'key': 'AudioRenderers', 'value': ''},
+ {'key': 'nAudioRenderers', 'value': 0},
+ {'key': 'currentAudioSource', 'value': 'Unknown'},
+ {'key': 'currentAudioSourceName', 'value': 'Unknown'},
+ {'key': 'nowPlaying', 'value': 'Unknown'},
+ ]
+ self.gateway.updateStatesOnServer(key_value_list)
+
+ # If no AV renderers are playing N.Music, stop iTunes playback
+ if self.itunes_control:
+ self.iTunes.stop()
+
+ # #### Helper functions
+ @staticmethod
+ def find_source_name(source, sources):
+ # Get the sourceName for source
+ for source_name in sources:
+ if sources[source_name]['source'] == str(source):
+ return str(sources[source_name]).split()[0]
+
+ # if source list exhausted and no valid name found return Unknown
+ return 'Unknown'
+
+ @staticmethod
+ # Get the source ID for sourceName
+ def get_source(sourceName, sources):
+ for source_name in sources:
+ if source_name == sourceName:
+ return str(sources[source_name]['source'])
+
+ # if source list exhausted and no valid name found return Unknown
+ return 'Unknown'
+
+ # ########################################################################################
+ # Apple Music Control and feedback
+ def iTunes_transport_control(self, message):
+ # Transport controls for iTunes
+ try: # If N.MUSIC command, trigger appropriate self.iTunes control
+ if message['State_Update']['state'] not in ["", "Standby"]:
+ self.iTunes.play()
+ except KeyError:
+ pass
+
+ try: # If N.MUSIC selected and Beo4 command received then run appropriate transport commands
+ if message['State_Update']['command'] == "Go/Play":
+ self.iTunes.play()
+
+ elif message['State_Update']['command'] == "Stop":
+ self.iTunes.pause()
+
+ elif message['State_Update']['command'] == "Exit":
+ self.iTunes.stop()
+
+ elif message['State_Update']['command'] == "Step Up":
+ self.iTunes.next_track()
+
+ elif message['State_Update']['command'] == "Step Down":
+ self.iTunes.previous_track()
+
+ elif message['State_Update']['command'] == "Wind":
+ self.iTunes.wind(15)
+
+ elif message['State_Update']['command'] == "Rewind":
+ self.iTunes.rewind(-15)
+
+ elif message['State_Update']['command'] == "Shift-1/Random":
+ self.iTunes.shuffle()
+
+ # If 'Info' pressed - update track info
+ elif message['State_Update']['command'] == "Info":
+ track_info = self.iTunes.get_current_track_info()
+ if track_info[0] not in [None, 'None']:
+ indigo.server.log(
+ "\n\t----------------------------------------------------------------------------"
+ "\n\tiTUNES CURRENT TRACK INFO:"
+ "\n\t============================================================================"
+ "\n\tNow playing: '" + track_info[0] + "'"
+ "\n\t by " + track_info[2] +
+ "\n\t from the album '" + track_info[1] + "'"
+ "\n\t----------------------------------------------------------------------------"
+ "\n\tACTIVE AUDIO RENDERERS: " + str(self.gateway.states['AudioRenderers']) + "\n\n",
+ level=logging.DEBUG
+ )
+
+ self.iTunes.notify(
+ "Now playing: '" + track_info[0] +
+ "' by " + track_info[2] +
+ "from the album '" + track_info[1] + "'",
+ "Apple Music Track Info:"
+ )
+
+ # If 'Guide' pressed - print instructions to indigo log
+ elif message['State_Update']['command'] == "Guide":
+ indigo.server.log(
+ "\n\t----------------------------------------------------------------------------"
+ "\n\tBeo4/BeoRemote One Control of Apple Music"
+ "\n\tKey mapping guide: [Key : Action]"
+ "\n\t============================================================================"
+ "\n\n\t** BASIC TRANSPORT CONTROLS **"
+ "\n\tGO/PLAY : Play"
+ "\n\tSTOP/Pause : Pause"
+ "\n\tEXIT : Stop"
+ "\n\tStep Up/P+ : Next Track"
+ "\n\tStep Down/P- : Previous Track"
+ "\n\tWind : Scan Forwards 15 Seconds"
+ "\n\tRewind : Scan Backwards 15 Seconds"
+ "\n\n\t** FUNCTIONS **"
+ "\n\tShift-1/Random : Toggle Shuffle"
+ "\n\tINFO : Display Track Info for Current Track"
+ "\n\tGUIDE : This Guide"
+ "\n\n\t** ADVANCED CONTROLS **"
+ "\n\tGreen : Shuffle Playlist 'Recently Played'"
+ "\n\tYellow : Play Digital Radio Stations from Playlist Radio"
+ "\n\tRed : More of the Same"
+ "\n\tBlue : Play the Album that the Current Track Resides On\n\n",
+ level=logging.DEBUG
+ )
+
+ # If colour key pressed, execute the appropriate applescript
+ elif message['State_Update']['command'] == "Green":
+ # Play a specific playlist - defaults to Recently Played
+ script = ASBridge.__file__[:-12] + '/Scripts/green.scpt'
+ self.iTunes.run_script(script, self.debug)
+
+ elif message['State_Update']['command'] == "Yellow":
+ # Play a specific playlist - defaults to URL Radio stations
+ script = ASBridge.__file__[:-12] + '/Scripts/yellow.scpt'
+ self.iTunes.run_script(script, self.debug)
+
+ elif message['State_Update']['command'] == "Blue":
+ # Play the current album
+ script = ASBridge.__file__[:-12] + '/Scripts/blue.scpt'
+ self.iTunes.run_script(script, self.debug)
+
+ elif message['State_Update']['command'] in ["0xf2", "Red", "MOTS"]:
+ # More of the same (start a playlist with just current track and let autoplay find similar tunes)
+ script = ASBridge.__file__[:-12] + '/Scripts/red.scpt'
+ self.iTunes.run_script(script, self.debug)
+ except KeyError:
+ pass
+
+ def _get_itunes_track_info(self, message):
+ track_info = self.iTunes.get_current_track_info()
+ if track_info[0] not in [None, 'None']:
+ # Construct track info string
+ track_info_ = "'" + track_info[0] + "' by " + track_info[2] + " from the album '" + track_info[1] + "'"
+
+ # Add now playing info to the message block
+ if 'Type' in message and message['Type'] == "AV RENDERER" and 'source' in message['State_Update'] \
+ and message['State_Update']['source'] == str(self.itunes_source) and \
+ 'nowPlaying' in message['State_Update']:
+ message['State_Update']['nowPlaying'] = track_info_
+ message['State_Update']['nowPlayingDetails']['channel_track'] = int(track_info[3])
+
+ # Print track info to log if trackmode is set to true (via config UI)
+ src = dict(CONST.available_sources).get(self.itunes_source)
+ if self.gateway.states['currentAudioSource'] == str(self.itunes_source) and \
+ track_info_ != self.gateway.states['nowPlaying'] and self.trackmode:
+ indigo.server.log("\n\t----------------------------------------------------------------------------"
+ "\n\tiTUNES CURRENT TRACK INFO:"
+ "\n\t============================================================================"
+ "\n\tNow playing: '" + track_info[0] + "'"
+ "\n\t by " + track_info[2] +
+ "\n\t from the album '" + track_info[1] + "'"
+ "\n\t----------------------------------------------------------------------------"
+ "\n\tACTIVE AUDIO RENDERERS: " + str(self.gateway.states['AudioRenderers']) + "\n\n")
+
+ if self.notifymode:
+ # Post track information to Apple Notification Centre
+ self.iTunes.notify(track_info_ + " from source " + src,
+ "Now Playing:")
+
+ # Update nowPlaying on the gateway device
+ if track_info_ != self.gateway.states['nowPlaying'] and \
+ self.gateway.states['currentAudioSource'] == str(self.itunes_source):
+ self.gateway.updateStateOnServer('nowPlaying', value=track_info_)
+
+ # Update info on active Audio Renderers
+ for node in indigo.devices.iter('uk.co.lukes_plugins.BeoGateway.plugin.AVrenderer'):
+ if node.name in self.gateway.states['AudioRenderers']:
+ key_value_list = [
+ {'key': 'onOffState', 'value': True},
+ {'key': 'playState', 'value': 'Play'},
+ {'key': 'source', 'value': src},
+ {'key': 'nowPlaying', 'value': track_info_},
+ {'key': 'channelTrack', 'value': int(track_info[3])},
+ ]
+ node.updateStatesOnServer(key_value_list)
+ node.updateStateImageOnServer(indigo.kStateImageSel.AvPlaying)
+
+ # ########################################################################################
+ # Message Reporting
+ @staticmethod
+ def message_log(name, header, payload, message):
+ # Set reporting level for message logging
+ try: # CLOCK messages are filtered except in debug mode
+ if message['payload_type'] == 'CLOCK':
+ debug_level = logging.DEBUG
+ else: # Everything else is for INFO
+ debug_level = logging.INFO
+ except KeyError:
+ debug_level = logging.INFO
+
+ # Pretty formatting - convert to JSON format then remove braces
+ message = json.dumps(message, indent=4)
+ for r in (('"', ''), (',', ''), ('{', ''), ('}', '')):
+ message = str(message).replace(*r)
+
+ # Print message data
+ if len(payload) + 9 < 73:
+ indigo.server.log("\n\t----------------------------------------------------------------------------" +
+ "\n\t" + name + ": <--DATA-RECEIVED!-<< " +
+ datetime.now().strftime("on %d/%m/%y at %H:%M:%S") +
+ "\n\t============================================================================" +
+ "\n\tHeader: " + header +
+ "\n\tPayload: " + payload +
+ "\n\t----------------------------------------------------------------------------" +
+ message, level=debug_level)
+ elif 73 < len(payload) + 9 < 137:
+ indigo.server.log("\n\t----------------------------------------------------------------------------" +
+ "\n\t" + name + ": <--DATA-RECEIVED!-<< " +
+ datetime.now().strftime("on %d/%m/%y at %H:%M:%S") +
+ "\n\t============================================================================" +
+ "\n\tHeader: " + header +
+ "\n\tPayload: " + payload[:66] + "\n\t\t" + payload[66:137] +
+ "\n\t----------------------------------------------------------------------------" +
+ message, level=debug_level)
+ else:
+ indigo.server.log("\n\t----------------------------------------------------------------------------" +
+ "\n\t" + name + ": <--DATA-RECEIVED!-<< " +
+ datetime.now().strftime("on %d/%m/%y at %H:%M:%S") +
+ "\n\t============================================================================" +
+ "\n\tHeader: " + header +
+ "\n\tPayload: " + payload[:66] + "\n\t\t" + payload[66:137] + "\n\t\t" + payload[137:] +
+ "\n\t----------------------------------------------------------------------------" +
+ message, level=debug_level)
+
+ def notifications(self, name, last_state, new_state):
+ # Post state information to the Apple Notification Centre
+ # Information index:
+ # node.states['playState'], # 0
+ # node.states['source'], # 1
+ # node.states['nowPlaying'], # 2
+ # node.states['channelTrack'], # 3
+ # node.states['volume'], # 4
+ # node.states['mute'], # 5
+ # node.states['onOffState'] # 6
+
+ # Don't post notification if nothing has changed
+ if last_state == new_state:
+ return
+
+ # Source status information
+ if last_state[0] != new_state[0] and new_state[0] == "Standby": # Power off
+ self.iTunes.notify(
+ name + " now in Standby",
+ "Device State Update"
+ )
+ return
+ elif last_state[1] != new_state[1] and new_state[0] != "Standby": # Source Update
+ self.iTunes.notify(
+ name + " now playing from source " + new_state[1],
+ "Device State Update"
+ )
+ return
+ elif last_state[0] != new_state[0] and new_state[0] == "Play": # Power on
+ self.iTunes.notify(
+ name + " Active",
+ "Device State Update"
+ )
+ return
+
+ # Channel/Track information
+ if new_state[2] not in [None, 'None', '', 0, '0', 'Unknown']: # Now Playing Update
+ self.iTunes.notify(
+ new_state[2] + " from source " + new_state[1],
+ name + " Now Playing:"
+ )
+ elif last_state[3] != new_state[3] and new_state[3] not in [0, 255, '0', '255']: # Channel/Track Update
+ self.iTunes.notify(
+ name + " now playing channel/track " + new_state[3] + " from source " + new_state[1],
+ "Device Channel/Track Information"
+ )
+
+ # ########################################################################################
+ # Indigo Server Methods
+ def startup(self):
+ indigo.server.log(u"Startup called")
+
+ # Download the config file from the gateway and initialise the devices
+ config = MLCONFIG.MLConfig(self.host, self.user, self.pwd, self.debug)
+ self.gateway = indigo.devices['Bang and Olufsen Gateway']
+
+ # Create MLGW Protocol and ML_CLI Protocol clients (basic command listening)
+ indigo.server.log('Creating MLGW Protocol Client...', level=logging.WARNING)
+ self.mlgw = MLGW.MLGWClient(self.host, self.port[0], self.user, self.pwd, 'MLGW protocol', self.debug, self.cb)
+ asyncore.loop(count=10, timeout=0.2)
+
+ indigo.server.log('Creating ML Command Line Protocol Client...', level=logging.WARNING)
+ self.mlcli = MLCLI.MLCLIClient(self.host, self.port[2], self.user, self.pwd,
+ 'ML command line interface', self.debug, self.cb)
+ # Log onto the MLCLI client and ascertain the gateway model
+ asyncore.loop(count=10, timeout=0.2)
+
+ # Now MLGW and MasterLink Command Line Client are set up, retrieve MasterLink IDs of products
+ config.get_masterlink_id(self.mlgw, self.mlcli)
+
+ # If the gateway is a BLGW use the BLHIP protocol, else use the legacy MLHIP protocol
+ if self.mlcli.isBLGW:
+ indigo.server.log('Creating BLGW Home Integration Protocol Client...', level=logging.WARNING)
+ self.blgw = BLHIP.BLHIPClient(self.host, self.port[1], self.user, self.pwd,
+ 'BLGW Home Integration Protocol', self.debug, self.cb)
+ self.mltn = None
+ else:
+ indigo.server.log('Creating MLGW Home Integration Protocol Client...', level=logging.WARNING)
+ self.mltn = MLtn.MLtnClient(self.host, self.port[2], self.user, self.pwd, 'ML telnet client',
+ self.debug, self.cb)
+ self.blgw = None
+
+ # Connection polling
+ def check_connection(self, client):
+ last = round(time.time() - client.last_received_at, 2)
+ # Reconnect if socket has disconnected, or if no response received to last ping
+ if not client.is_connected or last > 60:
+ indigo.server.log("\t" + client.name + ": Reconnecting!", level=logging.WARNING)
+ client.handle_close()
+ self.sleep(0.5)
+ client.client_connect()
+
+ # Indigo main program loop
+ def runConcurrentThread(self):
+ try:
+ while True:
+ # Ping all connections every 10 minutes to prompt messages on the network
+ asyncore.loop(count=self.pollinterval, timeout=1)
+ if self.mlgw.is_connected:
+ self.mlgw.ping()
+ if self.mlcli.is_connected:
+ self.mlcli.ping()
+ if self.mlcli.isBLGW:
+ if self.blgw.is_connected:
+ self.blgw.ping()
+ else:
+ if self.mltn.is_connected:
+ self.mltn.ping()
+
+ # Check the connections approximately every 10 minutes to keep sockets open
+ asyncore.loop(count=5, timeout=1)
+ self.check_connection(self.mlgw)
+ self.check_connection(self.mlcli)
+ if self.mlcli.isBLGW:
+ self.check_connection(self.blgw)
+ else:
+ self.check_connection(self.mltn)
+
+ self.sleep(0.5)
+
+ except self.StopThread:
+ raise asyncore.ExitNow('Server is quitting!')
+
+ # Tidy up on shutdown
+ def shutdown(self):
+ indigo.server.log("Shutdown plugin")
+ del self.mlgw
+ del self.mlcli
+ if self.mlcli.isBLGW:
+ del self.blgw
+ else:
+ del self.mltn
+ del self.iTunes
+ raise asyncore.ExitNow('Server is quitting!')