├── .gitignore ├── Dockerfile ├── EventListener.py ├── MQTTClient.py ├── MusicCastAPI.py ├── MusicCastController.py ├── MusicCastWorker.py ├── README.md ├── config.json └── requirements.txt /.gitignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Iture/MusicCastControl/34042fdb712a2e001380fd1a052044d0f7ce2405/.gitignore -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # To run: 2 | # docker run -d --restart unless-stopped --net=host --name MusicCastControl-container MusicCastControl 3 | 4 | FROM python:3 5 | MAINTAINER Keith Berry "keithwberry@gmail.com" 6 | 7 | WORKDIR /usr/src/app 8 | 9 | ADD https://github.com/berryk/MusicCastControl/archive/master.tar.gz . 10 | RUN gunzip -c master.tar.gz | tar xvf - 11 | 12 | WORKDIR /usr/src/app/MusicCastControl-master 13 | 14 | RUN pip install --no-cache-dir -r ./requirements.txt 15 | 16 | CMD [ "python", "./MusicCastController.py" ] 17 | -------------------------------------------------------------------------------- /EventListener.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import multiprocessing 3 | import select 4 | import socket 5 | import time 6 | 7 | 8 | class EventListener (multiprocessing.Process): 9 | def __init__(self,messageQ, commandQ, config): 10 | multiprocessing.Process.__init__(self) 11 | self.logger = logging.getLogger('MusicCast.EventListener') 12 | self.logger.info("Starting...") 13 | self.__commandQ=commandQ 14 | self.logger.debug("Opening listening socket") 15 | self.event_port=config['mc_notification_port'] 16 | self.devices = config['mc_devices'] 17 | try: 18 | self.__socket = socket.socket(socket.AF_INET, # Internet 19 | socket.SOCK_DGRAM) # UDP 20 | self.__socket.bind(('', self.event_port)) 21 | self.__socket.setblocking(0) 22 | except Exception as ex: 23 | self.logger.error("Problem opening event socket:%s" % ex) 24 | 25 | def run(self): 26 | while True: 27 | addr, message = self.get_event(1) 28 | # if message and 'main' in str(message): 29 | # Need to find which deviceId it is, we have the zone in the event and the IP address 30 | if message: 31 | for dev in self.devices: 32 | if self.devices[dev]['ip'] == addr[0] and self.devices[dev]['zone'] in str(message): 33 | devid = dev 34 | data_out = { 35 | 'method': 'command', 36 | 'topic': message, 37 | 'deviceId': devid, 38 | 'param': 'update', 39 | 'payload': 0, 40 | 'qos': 1, 41 | 'timestamp': time.time() 42 | } 43 | self.logger.debug("update message:%s" % data_out) 44 | self.__commandQ.put(data_out) 45 | 46 | 47 | def get_event(self, timeout=1): 48 | msg = None 49 | if self.__socket: 50 | addr=None 51 | try: 52 | result = select.select([self.__socket], [], [], timeout) 53 | if result[0]: 54 | msg, addr = result[0][0].recvfrom(1024) 55 | msg = str(msg) 56 | self.logger.debug("Message received:%s from %s" % (msg, addr)) 57 | except Exception as ex: 58 | self.logger.error("Error getting event notification: %s" % ex) 59 | return (addr,msg) -------------------------------------------------------------------------------- /MQTTClient.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import multiprocessing 3 | import time 4 | 5 | import paho.mqtt.client as mqtt 6 | 7 | 8 | class MQTTClient(multiprocessing.Process): 9 | def __init__(self, messageQ, commandQ, config): 10 | self.logger = logging.getLogger('MusicCast.MQTTClient') 11 | self.logger.info("Starting...") 12 | 13 | multiprocessing.Process.__init__(self) 14 | self.messageQ = messageQ 15 | self.commandQ = commandQ 16 | 17 | self.mqttDataPrefix = config['mqtt_prefix'] 18 | self.mqtt_host = config['mqtt_host'] 19 | self.mqtt_port = config['mqtt_port'] 20 | self._mqttConn = mqtt.Client(client_id='MusicCast-Controller_' 21 | '', clean_session=True, userdata=None) 22 | 23 | self._mqttConn.connect(self.mqtt_host, port=self.mqtt_port, keepalive=120) 24 | self._mqttConn.on_disconnect = self._on_disconnect 25 | self._mqttConn.on_publish = self._on_publish 26 | self._mqttConn.on_message = self._on_message 27 | 28 | self.message_timeout = config['mqtt_message_timeout'] 29 | 30 | def close(self): 31 | self.logger.info("Closing connection") 32 | self._mqttConn.disconnect() 33 | 34 | def _on_disconnect(self, client, userdata, rc): 35 | if rc != 0: 36 | self.logger.error("Unexpected disconnection.") 37 | time.sleep(1) 38 | self._mqttConn.reconnect() 39 | 40 | def _on_publish(self, client, userdata, mid): 41 | self.logger.debug("Message " + str(mid) + " published.") 42 | 43 | def _on_message(self, client, userdata, message): 44 | self.logger.debug("Message received: %s:%s" % (message.topic, message.payload)) 45 | 46 | data = message.topic.replace(self.mqttDataPrefix + "/", "").split("/") 47 | data_out = { 48 | 'method': 'command', 49 | 'topic': message.topic, 50 | 'deviceId': data[0], 51 | 'param': data[1], 52 | 'payload': message.payload.decode('ascii'), 53 | 'qos': 1, 54 | 'timestamp': time.time() 55 | } 56 | 57 | self.commandQ.put(data_out) 58 | if message.retain != 0: 59 | (rc, final_mid) = self._mqttConn.publish(message.topic, None, 1, True) 60 | self.logger.info("Clearing topic " + message.topic) 61 | 62 | def publish(self, task): 63 | if task['timestamp'] <= time.time() + self.message_timeout: 64 | topic = "%s/%s/%s" % (self.mqttDataPrefix, task['deviceId'], task['param']) 65 | try: 66 | if task['payload'] is not None and task['param'] != "tone_control": 67 | self._mqttConn.publish(topic, payload=task['payload']) 68 | self.logger.debug('Sending:%s' % (task)) 69 | except Exception as e: 70 | self.logger.error('Publish problem: %s' % (e)) 71 | self.messageQ.put(task) 72 | 73 | def run(self): 74 | self._mqttConn.subscribe(self.mqttDataPrefix + "/+/+/set") 75 | while True: 76 | while not self.messageQ.empty(): 77 | task = self.messageQ.get() 78 | if task['method'] == 'publish': 79 | self.logger.debug("Publishing:%s" % task) 80 | self.publish(task) 81 | time.sleep(0.01) 82 | self._mqttConn.loop() 83 | -------------------------------------------------------------------------------- /MusicCastAPI.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import select 3 | 4 | import requests 5 | 6 | 7 | class MusicCastAPI: 8 | def __init__(self, host, zone='main', notification_port=5005): 9 | self.logger = logging.getLogger('MusicCastAPI') 10 | self.host = host 11 | self.zone = zone 12 | self.status = {} 13 | self.base_url = "http://%s/YamahaExtendedControl/v1" % self.host 14 | self.inputs = ['tv', 'usb', 'net_radio', 'spotify', 'hdmi', 'server', 'bluetooth'] 15 | self.sound_programs = ['stereo', 'movie', 'music'] 16 | 17 | self.event_port = notification_port 18 | self.headers = {} 19 | if self.event_port is not None: 20 | self.headers = {'X-AppName': 'MusicCast/0.001(python)', 'X-AppPort': '%s' % self.event_port} 21 | self.preferred_modes = { 22 | 'tv': 'movie', 23 | 'hdmi': 'movie', 24 | 'net_radio': ['stereo', 'music'], 25 | 'spotify': ['stereo', 'music'], 26 | 'server': ['stereo', 'music'], 27 | 'bluetooth': ['stereo', 'music'], 28 | 'airplay': ['stereo', 'music'] 29 | } 30 | 31 | self.update_features() 32 | 33 | self.__socket = None 34 | 35 | def update_features(self): 36 | url = "%s/system/getFeatures" % self.base_url 37 | self.logger.debug("Trying to update features") 38 | 39 | res = self.__issue_request(url) 40 | if res is not None: 41 | self.inputs = res['zone'][0]['input_list'] 42 | self.sound_programs = res['zone'][0]['sound_program_list'] 43 | self.logger.debug("Features updated suceessfully") 44 | 45 | def get_device_status(self): 46 | url = "%s/%s/getStatus" % (self.base_url, self.zone) 47 | self.logger.debug("Trying to update status") 48 | out = {} 49 | res = self.__issue_request(url) 50 | if res is not None: 51 | for param in res: 52 | if (param not in self.status) or (res[param] != self.status[param]): 53 | out[param] = res[param] 54 | self.status = res 55 | # out.pop('tone_control',None) 56 | self.logger.info("Status updated successfully") 57 | return out 58 | 59 | def get_event(self, timeout=1): 60 | msg = None 61 | if self.__socket: 62 | try: 63 | result = select.select([self.__socket], [], [], timeout) 64 | if result[0]: 65 | msg, addr = result[0][0].recvfrom(1024) 66 | msg=str(msg) 67 | self.logger.debug("Message received:%s from %s" % (msg,addr)) 68 | except Exception as ex: 69 | self.logger.error("Error getting event notification: %s" % ex) 70 | return msg 71 | 72 | def ensure_mode(self): 73 | if self.status['input'] in self.preferred_modes and self.status['sound_program'] not in self.preferred_modes[self.status['input']]: 74 | if type(self.preferred_modes[self.status['input']]) == list: 75 | pref = self.preferred_modes[self.status['input']][0] 76 | else: 77 | pref = self.preferred_modes[self.status['input']] 78 | self.set_sound_program(pref) 79 | self.logger.debug("Reset mode to :%s" % pref) 80 | 81 | # TODO poprawic issue_request 82 | 83 | def set_power_state(self, power_state): 84 | url = "%s/%s/setPower?power=%s" % (self.base_url, self.zone, power_state) 85 | self.logger.debug("Trying to set power mode to:%s" % power_state) 86 | self.logger.debug("Request url: %s" % url) 87 | 88 | try: 89 | result = requests.get(url, headers=self.headers) 90 | if result.status_code == 200 and result.json()['response_code'] == 0: 91 | self.status['power'] = power_state 92 | except Exception as ex: 93 | self.logger.error("Problem setting power state: %s" % ex) 94 | return 95 | 96 | # TODO poprawic issue_request 97 | 98 | def set_input(self, input_name): 99 | if input_name in self.inputs: 100 | url = "%s/%s/setInput?input=%s" % (self.base_url, self.zone, input_name) 101 | self.logger.debug("Trying to set input to:%s" % input_name) 102 | self.logger.debug("Request url: %s" % url) 103 | 104 | try: 105 | result = requests.get(url) 106 | if result.status_code == 200 and result.json()['response_code'] == 0: 107 | self.status['input'] = input_name 108 | except Exception as ex: 109 | self.logger.error("Problem setting input: %s" % ex) 110 | return 111 | 112 | #TODO poprawic issue_request 113 | 114 | def set_sound_program(self, sound_program): 115 | if sound_program in self.sound_programs: 116 | url = "%s/%s/setSoundProgram?program=%s" % (self.base_url, self.zone, sound_program) 117 | self.logger.debug("Trying to set sound program to:%s" % sound_program) 118 | self.logger.debug("Request url: %s" % url) 119 | 120 | try: 121 | result = requests.get(url) 122 | if result.status_code == 200 and result.json()['response_code'] == 0: 123 | self.status['sound_program'] = sound_program 124 | except Exception as ex: 125 | self.logger.error("Problem setting sound program: %s" % ex) 126 | 127 | return 128 | 129 | #TODO poprawic issue_request 130 | 131 | def set_radio_station(self, preset_index): 132 | url = "%s/netusb/recallPreset?zone=%s&num=%s" % (self.base_url, self.zone, preset_index) 133 | self.logger.debug("Trying to set radio station to :%s" % preset_index) 134 | self.logger.debug("Request url: %s" % url) 135 | self.set_input('net_radio') 136 | 137 | try: 138 | result = requests.get(url) 139 | if result.status_code == 200 and result.json()['response_code'] == 0: 140 | self.logger.debug("Radio station set") 141 | except Exception as ex: 142 | self.logger.error("Problem setting radio station: %s" % ex) 143 | # self.set_playback_status('play') 144 | return 145 | 146 | def set_playback_status(self, status): 147 | playback_states = ["play", "stop", "pause", "play_pause", 148 | "previous", "next", "fast_reverse_start", "fast_reverse_end", 149 | "fast_forward_start", "fast_forward_end"] 150 | if status in playback_states: 151 | url = "%s/netusb/setPlayback?playback=%s" % (self.base_url, status) 152 | self.logger.debug("Trying to set playback status to :%s" % status) 153 | res = self.__issue_request(url) 154 | if res is not None: 155 | self.logger.info("Playback status set to:%s" % status) 156 | return 157 | 158 | def set_volume(self, volume): 159 | url = "%s/%s/setVolume?volume=%s" % (self.base_url, self.zone, volume) 160 | self.logger.debug("Trying to set volume to :%s" % volume) 161 | 162 | res = self.__issue_request(url) 163 | if res is not None: 164 | self.status['volume'] = volume 165 | 166 | def set_mute(self, state): 167 | url = "%s/%s/setMute?enable=%s" % (self.base_url, self.zone, state) 168 | self.logger.debug("Trying to set mute state to :%s" % state) 169 | 170 | if state in ['on', 'off']: 171 | res = self.__issue_request(url) 172 | if res is not None: 173 | self.status['mute'] = state 174 | return 175 | 176 | def __issue_request(self, url): 177 | self.logger.debug("Request url: %s" % url) 178 | try: 179 | result = requests.get(url,headers=self.headers) 180 | if result.status_code == 200 and result.json()['response_code'] == 0: 181 | return result.json() 182 | except Exception as ex: 183 | self.logger.error("Problem during: %s" % ex) 184 | return None 185 | -------------------------------------------------------------------------------- /MusicCastController.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | import multiprocessing 4 | import time 5 | 6 | import tornado.gen 7 | import tornado.ioloop 8 | import tornado.websocket 9 | from tornado.options import options 10 | 11 | import MQTTClient 12 | import MusicCastWorker 13 | import EventListener 14 | 15 | logger = logging.getLogger('MusicCast') 16 | formatter = logging.Formatter('%(asctime)s - %(name)s - %(funcName)s - %(levelname)s - %(message)s') 17 | logger.setLevel(logging.DEBUG) 18 | 19 | ch = logging.StreamHandler() 20 | ch.setFormatter(formatter) 21 | ch.setLevel(logging.DEBUG) 22 | logger.addHandler(ch) 23 | 24 | 25 | def main(): 26 | # messages read from device 27 | messageQ = multiprocessing.Queue() 28 | # messages written to device 29 | commandQ = multiprocessing.Queue() 30 | config = {} 31 | try: 32 | with open('config.json') as json_data: 33 | config = json.load(json_data) 34 | except Exception as e: 35 | logger.error("Config load failed") 36 | exit(1) 37 | 38 | mqtt = MQTTClient.MQTTClient(messageQ, commandQ, config) 39 | mqtt.daemon = True 40 | mqtt.start() 41 | 42 | mw = MusicCastWorker.MusicCastWorker(messageQ, commandQ, config) 43 | mw.daemon = True 44 | mw.start() 45 | 46 | event_listener=EventListener.EventListener(messageQ, commandQ, config) 47 | event_listener.daemon = True 48 | event_listener.start() 49 | 50 | # wait a second before sending first task 51 | time.sleep(1) 52 | options.parse_command_line() 53 | 54 | mainLoop = tornado.ioloop.IOLoop.instance() 55 | mainLoop.start() 56 | 57 | 58 | if __name__ == "__main__": 59 | main() 60 | -------------------------------------------------------------------------------- /MusicCastWorker.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import multiprocessing 3 | import time 4 | 5 | import MusicCastAPI 6 | 7 | 8 | class MusicCastWorker(multiprocessing.Process): 9 | def __init__(self, messageQ, commandQ, config): 10 | self.logger = logging.getLogger('MusicCast.Worker') 11 | self.__messageQ = messageQ 12 | self.__commandQ = commandQ 13 | self.devices = {} 14 | self.notification_port = config['mc_notification_port'] 15 | self.last_update = 0 16 | self.update_interval = config['mc_status_update_interval'] 17 | multiprocessing.Process.__init__(self) 18 | for dev in config['mc_devices']: 19 | ip = config['mc_devices'][dev]['ip'] 20 | zone = config['mc_devices'][dev]['zone'] 21 | self.get_device(dev,ip,zone) 22 | return 23 | 24 | def run(self): 25 | while True: 26 | #Added sleep to reduce CPU usage 27 | time.sleep(0.01) 28 | if time.time() > self.last_update+self.update_interval: 29 | self.logger.debug("Updating devices") 30 | self.update_devices() 31 | self.last_update=time.time() 32 | if not self.__commandQ.empty(): 33 | while not self.__commandQ.empty(): 34 | try: 35 | task = self.__commandQ.get() 36 | if task['method'] == 'command' and task['payload'] != '': 37 | param = task['param'] 38 | if param == 'volume': 39 | self.get_device(task['deviceId']).set_volume(int(float(task['payload']))) 40 | elif param == 'power': 41 | self.get_device(task['deviceId']).set_power_state(task['payload']) 42 | elif param == 'input': 43 | self.get_device(task['deviceId']).set_input(task['payload']) 44 | elif param == 'sound_program': 45 | self.get_device(task['deviceId']).set_sound_program(task['payload']) 46 | elif param == 'radio': 47 | self.get_device(task['deviceId']).set_radio_station(int(task['payload'])) 48 | elif param == 'playback': 49 | self.get_device(task['deviceId']).set_playback_status(task['payload']) 50 | elif param == 'update': 51 | device = self.get_device(task['deviceId']) 52 | #device.ensure_mode() 53 | changes = device.get_device_status() 54 | for param in changes: 55 | self.__messageQ.put(self.prepare_message(task['deviceId'], param, changes[param])) 56 | self.logger.debug("Change:%s" % param) 57 | except Exception as ex: 58 | self.logger.error(("Error ocurred while executing command:%s" % ex)) 59 | return 60 | 61 | def get_device(self,device_id,ip=None,zone=None): 62 | self.logger.debug(device_id) 63 | if not device_id in self.devices: 64 | self.devices[device_id]=MusicCastAPI.MusicCastAPI(ip, zone, notification_port=self.notification_port) 65 | return self.devices[device_id] 66 | 67 | def update_devices(self): 68 | for device_id in self.devices: 69 | device=self.devices[device_id] 70 | out = device.get_device_status() 71 | #device.ensure_mode() 72 | for param in out: 73 | self.__messageQ.put(self.prepare_message(device_id, param, device.status[param])) 74 | 75 | return 76 | 77 | def prepare_message(self, device, param_name, param_value): 78 | out = { 79 | 'method': 'publish', 80 | 'deviceId': device, 81 | 'param': param_name, 82 | 'payload': param_value, 83 | 'qos': 1, 84 | 'timestamp': time.time() 85 | } 86 | return out 87 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MusicCastControl 2 | MQTT interface for Yamaha MusicCast 3 | 4 | Receives events from the MusicCast device when a value changes and publishes them to MQTT. 5 | 6 | Publishes topics in the format: 7 | 8 | MusicCast/friendly_name/power 9 | 10 | To set a value, use: 11 | 12 | MusicCast/friendly_name/power/set 13 | 14 | Example values from a Yamaha A2060 (Zone 3 with a friendly_name of dining_room): 15 | 16 | ``` 17 | MusicCast/dining_room/sleep:0 18 | MusicCast/dining_room/input:audio3 19 | MusicCast/dining_room/response_code:0 20 | MusicCast/dining_room/max_volume:161 21 | MusicCast/dining_room/distribution_enable:False 22 | MusicCast/dining_room/power:standby 23 | MusicCast/dining_room/volume:102 24 | MusicCast/dining_room/enhancer:True 25 | MusicCast/dining_room/mute:False 26 | MusicCast/dining_room/disable_flags:0 27 | ``` 28 | 29 | friendly_names, ip addresses and zones are defined in config.json 30 | 31 | To start run: 32 | 33 | python3 MusicCastController.py 34 | -------------------------------------------------------------------------------- /config.json: -------------------------------------------------------------------------------- 1 | { 2 | "mqtt_host": "mqtt.lan", 3 | "mqtt_port": 1883, 4 | "mqtt_message_timeout": 60, 5 | "mqtt_prefix": "MusicCast", 6 | "mc_devices": { 7 | "family_room": { "ip": "192.168.1.203", "zone": "main" }, 8 | "kitchen": { "ip": "192.168.1.203", "zone": "zone2" }, 9 | "dining_room": { "ip": "192.168.1.203", "zone": "zone3" }, 10 | "basement": { "ip": "192.168.1.205", "zone": "main" } 11 | }, 12 | "mc_notification_port": 5005, 13 | "mc_status_update_interval": 300 14 | } 15 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | certifi==2020.4.5.1 2 | chardet==3.0.4 3 | idna==2.9 4 | paho-mqtt==1.5.0 5 | requests==2.23.0 6 | tornado==6.0.4 7 | urllib3==1.25.9 8 | --------------------------------------------------------------------------------