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