From afdcf9dae37a1027ea58b4eff6074e2cb8844cc9 Mon Sep 17 00:00:00 2001 From: Indigo Domotics Date: Fri, 10 Mar 2017 11:20:19 -0600 Subject: [PATCH] Added new device type for the RX-Vx73 (and compatible) receivers. Added some extra actions for those receivers as well. Maintained the RX-V3900 (and compatible) receiver type. Added polling for both types so that status is refreshed every 2 seconds. --- .gitignore | 1 + Contents/Server Plugin/Actions.xml | 114 ---- Contents/Server Plugin/Devices.xml | 74 --- Contents/Server Plugin/plugin.py | 187 ------- README.md | 66 ++- .../Contents}/Info.plist | 8 +- .../Contents/Server Plugin/Actions.xml | 144 +++++ .../Contents/Server Plugin/Devices.xml | 161 ++++++ .../Contents}/Server Plugin/PluginConfig.xml | 0 .../Contents/Server Plugin/plugin.py | 471 ++++++++++++++++ .../Contents/Server Plugin/rxv/LICENSE | 10 + .../Contents/Server Plugin/rxv/__init__.py | 27 + .../Contents/Server Plugin/rxv/exceptions.py | 25 + .../Contents/Server Plugin/rxv/rxv.py | 514 ++++++++++++++++++ .../Contents/Server Plugin/rxv/ssdp.py | 92 ++++ 15 files changed, 1505 insertions(+), 389 deletions(-) delete mode 100644 Contents/Server Plugin/Actions.xml delete mode 100644 Contents/Server Plugin/Devices.xml delete mode 100644 Contents/Server Plugin/plugin.py rename {Contents => Yahama RX.indigoPlugin/Contents}/Info.plist (79%) create mode 100644 Yahama RX.indigoPlugin/Contents/Server Plugin/Actions.xml create mode 100644 Yahama RX.indigoPlugin/Contents/Server Plugin/Devices.xml rename {Contents => Yahama RX.indigoPlugin/Contents}/Server Plugin/PluginConfig.xml (100%) create mode 100644 Yahama RX.indigoPlugin/Contents/Server Plugin/plugin.py create mode 100644 Yahama RX.indigoPlugin/Contents/Server Plugin/rxv/LICENSE create mode 100644 Yahama RX.indigoPlugin/Contents/Server Plugin/rxv/__init__.py create mode 100644 Yahama RX.indigoPlugin/Contents/Server Plugin/rxv/exceptions.py create mode 100644 Yahama RX.indigoPlugin/Contents/Server Plugin/rxv/rxv.py create mode 100644 Yahama RX.indigoPlugin/Contents/Server Plugin/rxv/ssdp.py diff --git a/.gitignore b/.gitignore index f24cd99..974e9b0 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,4 @@ pip-log.txt #Mr Developer .mr.developer.cfg +.idea/ diff --git a/Contents/Server Plugin/Actions.xml b/Contents/Server Plugin/Actions.xml deleted file mode 100644 index 82ed6aa..0000000 --- a/Contents/Server Plugin/Actions.xml +++ /dev/null @@ -1,114 +0,0 @@ - - - - Set Volume - setVolume - - - - in dB - - - - - Increase Volume - increaseVolume - - - - in dB - - - - - Decrease Volume - decreaseVolume - - - - in dB - - - - - Set Mute - setMute - - - - - - - - - - - - Toggle Mute - toggleMute - - - Set Power - setPower - - - - - - - - - - - - Toggle Power - togglePower - - - Set Sleep - setSleep - - - - - - - - - - - - - - - Set Input - setInput - - - - - - - - - - - - - - - - - - - - - - - - - - Get Status - getStatus - - diff --git a/Contents/Server Plugin/Devices.xml b/Contents/Server Plugin/Devices.xml deleted file mode 100644 index 1e5842a..0000000 --- a/Contents/Server Plugin/Devices.xml +++ /dev/null @@ -1,74 +0,0 @@ - - - - Yamaha RX Series Receiver - - - - enter the ip address of your reciever - - - - - - - - - - - Power - Power - - - - - - - - - - - - Sleep - Sleep - - - Integer - Volume - Volume - - - Boolean - Mute - Mute - - - - - - - - - - - - - - - - - - - - - - - - - - Input - Input - - - - diff --git a/Contents/Server Plugin/plugin.py b/Contents/Server Plugin/plugin.py deleted file mode 100644 index 7657f2f..0000000 --- a/Contents/Server Plugin/plugin.py +++ /dev/null @@ -1,187 +0,0 @@ -#! /usr/bin/env python -# -*- coding: utf-8 -*- - -import httplib, urllib2, sys, os -try: - import xml.etree.cElementTree as ET -except ImportError: - import xml.etree.ElementTree as ET - -def str2bool(v): - return v.lower() in ("yes", "true", "t", "1") - -def xmitToReceiver(dev, xml_string): - url = 'http://'+dev.pluginProps['txtip']+'/YamahaRemoteControl/ctrl' - - req = urllib2.Request( - url=url, - data=xml_string, - headers={'Content-Type': 'application/xml'}) - resp = urllib2.urlopen(req) - status_xml = resp.read() - root = ET.fromstring(status_xml) - return root - -class Plugin(indigo.PluginBase): - - def __init__(self, pluginId, pluginDisplayName, pluginVersion, pluginPrefs): - indigo.PluginBase.__init__(self, pluginId, pluginDisplayName, pluginVersion, pluginPrefs) - self.debug = True - - def __del__(self): - indigo.PluginBase.__del__(self) - - - def startup(self): - self.debugLog(u"startup called") - - def shutdown(self): - self.debugLog(u"shutdown called") - - def deviceStartComm(self, dev): - self.updateStatus(dev) - - # helper methods, these mostly serve to facilitate calls to the device - - def updateStatus(self, dev): - # get status from receiver, update locals - self.debugLog(u"updating status...") - - if dev is None: - self.debugLog(u"no device defined") - return - - xml_string = 'GetParam' - root = xmitToReceiver( dev, xml_string) - power = root.find("./Main_Zone/Basic_Status/Power_Control/Power").text - sleep = root.find("./Main_Zone/Basic_Status/Power_Control/Sleep").text - if(sleep!='Off'): sleep = "n"+sleep - volume = root.find("./Main_Zone/Basic_Status/Vol/Lvl/Val").text - mute = root.find("./Main_Zone/Basic_Status/Vol/Mute").text - inputmode = root.find("./Main_Zone/Basic_Status/Input/Input_Sel").text - - dev.updateStateOnServer("power", power) - dev.updateStateOnServer("sleep", sleep) - dev.updateStateOnServer("volume", volume) - dev.updateStateOnServer("mute", mute) - dev.updateStateOnServer("input", inputmode) - - def putMute(self, dev, val): - if dev is None: - self.debugLog(u"no device defined") - return - - if val is None: - self.debugLog(u"value not defined") - return - - xml_string = ''+val+'' - root = xmitToReceiver( dev, xml_string) - self.updateStatus(dev) - - def putVolume(self, dev, val): - if dev is None: - self.debugLog(u"no device defined") - return - - if val is None: - self.debugLog(u"value not defined") - return - - xml_string = ''+val+'1dB' - root = xmitToReceiver( dev, xml_string) - self.updateStatus(dev) - - def putPower(self, dev, val): - if dev is None: - self.debugLog(u"no device defined") - return - - if val is None: - self.debugLog(u"value not defined") - return - - xml_string = ''+val+'' - root = xmitToReceiver( dev, xml_string) - self.updateStatus(dev) - - def putSleep(self, dev, val): - if dev is None: - self.debugLog(u"no device defined") - return - - if val is None: - self.debugLog(u"value not defined") - return - - xml_string = ''+val+'' - root = xmitToReceiver( dev, xml_string) - self.updateStatus(dev) - - def putInput(self, dev, val): - if dev is None: - self.debugLog(u"no device defined") - return - - if val is None: - self.debugLog(u"value not defined") - return - - xml_string = ''+val+'' - root = xmitToReceiver( dev, xml_string) - self.updateStatus(dev) - - - # actions go here - def getStatus(self, pluginAction, dev): - self.updateStatus(dev) - - def setMute(self, pluginAction, dev): - self.debugLog(u"setMute called") - val = pluginAction.props['ddlmute'] - self.putMute(dev, val) - - def toggleMute(self, pluginAction, dev): - self.debugLog(u"toggleMute called") - self.updateStatus(dev) - val = 'On' if dev.states['mute']=='Off' else 'Off' - self.putMute(dev, val) - - def setVolume(self, pluginAction, dev): - self.debugLog(u"setVolume called") - val = pluginAction.props['txtvolume'] - self.putVolume(dev, val) - - def increaseVolume(self, pluginAction, dev): - self.debugLog(u"increaseVolume called") - self.updateStatus(dev) - val = str(int(dev.states['volume']) + int(pluginAction.props['txtincrement'])) - self.putVolume(dev, val) - - def decreaseVolume(self, pluginAction, dev): - self.debugLog(u"decreaseVolume called") - self.updateStatus(dev) - val = str(int(dev.states['volume']) - int(pluginAction.props['txtincrement'])) - self.putVolume(dev, val) - - def setPower(self, pluginAction, dev): - self.debugLog(u"setPower called") - val = pluginAction.props['ddlpower'] - self.putPower(dev, val) - - def togglePower(self, pluginAction, dev): - self.debugLog(u"togglePower called") - self.updateStatus(dev) - val = 'On' if (dev.states['power']=='Standby') else 'Standby' - self.putPower(dev, val) - - def setSleep(self, pluginAction, dev): - self.debugLog(u"setSleep called") - val = pluginAction.props['ddlsleep'].replace('n','') - self.putSleep(dev, val) - - def setInput(self, pluginAction, dev): - self.debugLog(u"setInput called") - val = pluginAction.props['ddlinput'].upper().replace(".","/").replace("_"," ") - self.putInput(dev, val) - diff --git a/README.md b/README.md index 3565419..290ceb8 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,34 @@ indigo-yamaharx =============== -[Indigo](http://www.perceptiveautomation.com/indigo/index.html) plugin - basic control of Yamaha RX series a/v receivers +This is a plugin for the [Indigo](http://www.indigodomo.com/) smart home server that integrates the Yamaha RX-V series +A/V receivers. The receiver must have some kind of network connection (wired Ethernet, WiFi). Serial control is not +supported. ### Requirements -1. [Indigo 6](http://www.perceptiveautomation.com/indigo/index.html) or later (pro version only) -2. Yamaha RX Series A/V Receiver (needs to be accessible via network from the box hosting your Indigo server) +1. [Indigo 7](http://www.indigodomo.com/) or later +2. Yamaha RX-V Series A/V Receiver (needs to be accessible via network from the box hosting your Indigo server) ### Installation Instructions -1. Download latest release [here](https://github.com/discgolfer1138/indigo-yamaharx/releases) -2. Follow [standard plugin installation process](http://bit.ly/1e1Vc7b) +1. Download latest release [here](https://github.com/IndigoDomotics/indigo-yamaharx/releases). If you're using Indigo 6, +you can download the [0.1.1 release](https://github.com/IndigoDomotics/indigo-yamaharx/releases/tag/0.1.1) instead, but +note that it only supports the RX-V3900. Support for newer receivers requires v1.0.0 or later. +2. Follow [standard plugin installation process](http://wiki.indigodomo.com/doku.php?id=indigo_7_documentation:getting_started#installing_plugins_configuring_plugin_settings_permanently_removing_plugins) ### Compatible Hardware -This plugin has only been tested with the Yamaha RX-V3900 +This plugin supports both the RX-V3900 and the RX-Vx73 (RX-V473, RX-V573, RX-V673, RX-V773) receiver line. Other +Yahama RX-V receivers that use the same command API may also work. -### Actions Supported +### Usage + +Select the appropriate receiver when creating a new receiver in Indigo. If you choose RX-Vx73, the config dialog should +show a popup with the available receivers on your network. If your network doesn't show in the list, it may be because +it's not configured to operate in network standby mode. Confirm that Network Standby is turned on. If that doesn't solve +the issue, you may manually specify the IP address for the receiver (your router may be blocking the discovery protocol). + +### Actions Supported by All Receivers * Set Volume * Increase Volume * Decrease Volume @@ -27,9 +39,43 @@ This plugin has only been tested with the Yamaha RX-V3900 * Set Sleep * Get Status -### States Surfaced +### Additional Actions Supported by RX-Vx73 (and compatible) Receivers +* Set Zone - specify the zone that future commands will go to (where applicable) - untested until we have someone with a +multizone amp to test against +* Menu Up, Menu Down, Menu Left, Menu Right, Menu Select, Menu Return - to navigate the menus when an input is selected +that presents menus on the receiver itself. Note - in our testing, left/right don't work reliably, but using select and +return seem to work consistently and do pretty much the same thing. +* Play Net Radio Station (Experimental) - specify the path to a network radio station (we've seen mixed results using +the rxv library to do this - sometimes it works sometimes it doesn't) + +### States Exposed * power (On, Standby) * sleep (Off,30,60,90,120) -* volume (int) +* volume (float) * mute (bool) -* input (sirius, xm, tuner, multi_ch, phono, cd, tv, md.cd-r, bd.hd_dvd, dvd, cbl.sat, dvr, vcr, v-aux, dock, pc.mcx, net_radio, rhapsody, usb) +* input + RX-V3900: (sirius, xm, tuner, multi_ch, phono, cd, tv, md.cd-r, bd.hd_dvd, dvd, cbl.sat, dvr, vcr, v-aux, dock, pc.mcx, net_radio, rhapsody, usb) + RX-Vx73: (based on receiver capability - input list is dynamically generated for the set input action) + +### License + +The original author of the plugin (discgolfer1138) did not apply a license, so there is none. v1.0.0 and later uses a +modified version of the [rxv library](https://github.com/wuub/rxv) (included in the plugin and in this repo for ease of +installation). See the LICENSE file in *Yamaha RX.indigoPlugin/Contents/Server Plugin/rxv* for details. + +### rxv Library Changes + +The included rxv library has the following changes: + +* Added *timeout* to the RXV object that allows specification of a timeout value on all network communications. The +default network timeout is 30 seconds which can cause significant delays in plugin processing so by shortening it to +5 seconds we can more quickly determine if a receiver is no longer available on the network and react accordingly. + +### Troubleshooting + +Sometimes the receiver's status will change to unavailable - this means that the plugin is having a problem communicating +with it. This can happen for a variety of reasons, but one in particular seems to be an issue in our testing. When the +network radio input is selected, it will periodically become busy, presumably trying to query for updated radio stations, +and this will cause all other network communication to stop. So, apparently the receiver has a single network connection +that it uses for everything. Unfortunately, we can't tell the difference between one of these times and when the receiver +is actually offline (unplugged or otherwise unavailable on the network). \ No newline at end of file diff --git a/Contents/Info.plist b/Yahama RX.indigoPlugin/Contents/Info.plist similarity index 79% rename from Contents/Info.plist rename to Yahama RX.indigoPlugin/Contents/Info.plist index 8459e19..56be2a9 100644 --- a/Contents/Info.plist +++ b/Yahama RX.indigoPlugin/Contents/Info.plist @@ -3,13 +3,13 @@ PluginVersion - 0.1.1 + 1.0.0 ServerApiVersion - 1.0 + 2.0 IwsApiVersion 1.0.0 CFBundleDisplayName - Yamaha Rx Receiver + Yamaha RX Receiver CFBundleIdentifier io.thechad.indigoplugin.yamaharx CFBundleVersion @@ -18,7 +18,7 @@ CFBundleURLName - https://github.com/discgolfer1138/indigo-yamaharx + https://github.com/IndigoDomotics/indigo-yamaharx diff --git a/Yahama RX.indigoPlugin/Contents/Server Plugin/Actions.xml b/Yahama RX.indigoPlugin/Contents/Server Plugin/Actions.xml new file mode 100644 index 0000000..62baf38 --- /dev/null +++ b/Yahama RX.indigoPlugin/Contents/Server Plugin/Actions.xml @@ -0,0 +1,144 @@ + + + + Set Volume + setVolume + + + + in dB + + + + + Increase Volume + increaseVolume + + + + in dB + + + + + Decrease Volume + decreaseVolume + + + + in dB + + + + + Set Mute + setMute + + + + + + + + + + + + Toggle Mute + toggleMute + + + Set Power + setPower + + + + + + + + + + + + Toggle Power + togglePower + + + Set Sleep + setSleep + + + + + + + + + + + + + + + Set Input + setInput + + + + + + + + + Get Status + getStatus + + + + Set Zone + setZone + + + + + + + + + Menu Up + menuUp + + + Menu Down + menuDown + + + Menu Left + menuLeft + + + Menu Right + menuRight + + + Menu Select + menuSelect + + + Menu Return + menuReturn + + + Play Net Radio Station (Experimental) + playNetRadio + + + + + + + + + + diff --git a/Yahama RX.indigoPlugin/Contents/Server Plugin/Devices.xml b/Yahama RX.indigoPlugin/Contents/Server Plugin/Devices.xml new file mode 100644 index 0000000..f85daeb --- /dev/null +++ b/Yahama RX.indigoPlugin/Contents/Server Plugin/Devices.xml @@ -0,0 +1,161 @@ + + + + Yamaha RX-V3900 Series Receiver + + + + enter the ip address of your reciever + + + + + + + + + + + Power + Power + + + + + + + + + + + + Sleep + Sleep + + + Integer + Volume + Volume + + + Boolean + Mute + Mute + + + + + + + + + + + + + + + + + + + + + + + + + + Input + Input + + + + + Yamaha RX-Vx73 Series Receiver + + + + + + + + + + Find Receivers + refresh_receiver_list + + + Use manual IP address + + + + + + + + + + + + + + + + + Power + Power + + + + + + + + + + + + Sleep + Sleep + + + Float + Volume + Volume + + + Boolean + Mute + Mute + + + + + + + + + + + + + + + + + + + + + + + + + Input + Input + + + power + + diff --git a/Contents/Server Plugin/PluginConfig.xml b/Yahama RX.indigoPlugin/Contents/Server Plugin/PluginConfig.xml similarity index 100% rename from Contents/Server Plugin/PluginConfig.xml rename to Yahama RX.indigoPlugin/Contents/Server Plugin/PluginConfig.xml diff --git a/Yahama RX.indigoPlugin/Contents/Server Plugin/plugin.py b/Yahama RX.indigoPlugin/Contents/Server Plugin/plugin.py new file mode 100644 index 0000000..55dd174 --- /dev/null +++ b/Yahama RX.indigoPlugin/Contents/Server Plugin/plugin.py @@ -0,0 +1,471 @@ +#! /usr/bin/env python +# -*- coding: utf-8 -*- + +import urllib2 +import traceback + +import requests +from requests.exceptions import ConnectionError, ConnectTimeout, ReadTimeout + +import rxv +from rxv import exceptions as rxv_exceptions + +try: + import xml.etree.cElementTree as ET +except ImportError: + import xml.etree.ElementTree as ET + +from xml.etree.ElementTree import ParseError + +try: import indigo +except: pass + +kSleepBetweenUpdatePolls = 2 # number of seconds to sleep between polling each receiver for current status +kSleepValueMap = { + "Off": "Off", + "120 min": "n120", + "90 min": "n90", + "60 min": "n60", + "30 min": "n30", +} + +def str2bool(v): + return v.lower() in ("yes", "true", "t", "1", "on") + +class ClassicReceiver(object): + kInputList = ( + ('tuner', 'TUNER'), + ('multi_ch', 'MULTI CH'), + ('phono', 'PHONO'), + ('cd', 'CD'), + ('tv', 'TV'), + ('md.cd-r', 'MD/CD-R'), + ('bd.hd_dvd', 'BD/HD DVD'), + ('dvd', 'DVD'), + ('cbl.sat', 'CBL/SAT'), + ('dvr', 'DVR'), + ('vcr', 'VCR'), + ('v-aux', 'V-AUX'), + ('dock', 'DOCK'), + ('pc.mcx', 'PC/MCX'), + ('net_radio', 'NET RADIO'), + ('usb', 'USB'), + ) + @staticmethod + def xmitToReceiver(dev, xml_string): + url = 'http://' + dev.pluginProps['txtip'] + '/YamahaRemoteControl/ctrl' + + req = urllib2.Request( + url=url, + data=xml_string, + headers={'Content-Type': 'application/xml'}) + resp = urllib2.urlopen(req) + status_xml = resp.read() + root = ET.fromstring(status_xml) + return root + + @staticmethod + def putMute(logger, dev, val): + if dev is None: + logger.debug(u"no device defined") + return + + if val is None: + logger.debug(u"value not defined") + return + + xml_string = ''+val+'' + root = ClassicReceiver.xmitToReceiver( dev, xml_string) + + @staticmethod + def putVolume(logger, dev, val): + if dev is None: + logger.debug(u"no device defined") + return + + if val is None: + logger.debug(u"value not defined") + return + + xml_string = ''+val+'1dB' + root = ClassicReceiver.xmitToReceiver( dev, xml_string) + + @staticmethod + def putPower(logger, dev, val): + if dev is None: + logger.debug(u"no device defined") + return + + if val is None: + logger.debug(u"value not defined") + return + + xml_string = ''+val+'' + root = ClassicReceiver.xmitToReceiver( dev, xml_string) + + @staticmethod + def putSleep(logger, dev, val): + if dev is None: + logger.debug(u"no device defined") + return + + if val is None: + logger.debug(u"value not defined") + return + + xml_string = ''+val+'' + root = ClassicReceiver.xmitToReceiver( dev, xml_string) + + @staticmethod + def putInput(logger, dev, val): + if dev is None: + logger.debug(u"no device defined") + return + + if val is None: + logger.debug(u"value not defined") + return + + xml_string = ''+val+'' + root = ClassicReceiver.xmitToReceiver( dev, xml_string) + + + +class Plugin(indigo.PluginBase): + + def __init__(self, pluginId, pluginDisplayName, pluginVersion, pluginPrefs): + indigo.PluginBase.__init__(self, pluginId, pluginDisplayName, pluginVersion, pluginPrefs) + self.debug = True + self.devices = {} + self.refresh_receiver_list() + + ################################## + # Standard plugin operation methods + ################################## + def startup(self): + self.logger.debug(u"startup called") + + def shutdown(self): + self.logger.debug(u"shutdown called") + + def deviceStartComm(self, dev): + if dev.deviceTypeId == "receiver": + devTup = (dev,) + elif dev.deviceTypeId == "rxvX73": + try: + rxv_obj = self.receivers.get(dev.pluginProps["control-url"], None) + if not rxv_obj and len(dev.pluginProps["manual-ip"]): + rxv_obj = rxv.RXV(ctrl_url=dev.pluginProps["manual-ip"]) + devTup = (dev, rxv_obj) + except (ConnectTimeout, ReadTimeout, ConnectionError) as e: + dev.setErrorStateOnServer('unavailable') + if isinstance(e, ConnectTimeout) or isinstance(e, ReadTimeout): + self.logger.debug("device '%s' connection timed out" % dev.name) + else: + self.logger.debug("device '%s' had a connection error" % dev.name) + return + except ParseError: + # This seems to happen relatively frequently - apparently sometimes the amp goes out to lunch for + # a bit (causing connection errors) and when it comes back it doesn't always return correct XML. + # I think the better idea here is to just pass on these errors as it always seems to resolve itself. + dev.setErrorStateOnServer('unavailable') + devTup = (dev, None) + except Exception as e: + self.logger.error("Couldn't start device %s - it may not be available on the network or the IP address may have changed." % dev.name) + self.logger.debug("exception starting device:\n%s" % traceback.format_exc(10)) + return + self.devices[dev.id] = devTup + self.updateStatus(dev.id) + + def deviceStopComm(self, dev): + try: + del self.devices[dev.id] + except: + pass + + def runConcurrentThread(self): + try: + while True: + for devId in self.devices.keys(): + self.updateStatus(devId) + self.sleep(2) + except self.StopThread: + return + except Exception as e: + self.logger.error("runConcurrentThread error: \n%s" % traceback.format_exc(10)) + + ################################## + # Config dialog methods + ################################## + ############### + # get_receiver_list + # + # Returns the list of receivers as a tuple for the device config dialog. + ############### + def get_receiver_list(self, filter="", valuesDict=None, typeId="", targetId=0): + return [(k, v.friendly_name) for k, v in self.receivers.iteritems()] + + def get_input_list(self, filter="", valuesDict=None, typeId="", targetId=0): + dev = indigo.devices.get(targetId, None) + if dev.deviceTypeId == 'receiver': + return ClassicReceiver.kInputList + elif dev.deviceTypeId == "rxvX73": + dev, rxv_obj = self.devices.get(dev.id, (None, None)) + if rxv_obj: + return [(k.replace("iPod (USB)", "iPod").replace(" ", "_"), k) for k in rxv_obj.inputs().keys()] + return [] + + def get_zone_list(self, filter="", valuesDict=None, typeId="", targetId=0): + dev = indigo.devices.get(targetId, None) + d, rxv_obj = self.devices.get(dev.id, (None, None)) + if rxv_obj: + return [(k, k) for k in rxv_obj.zones()] + return [] + + ################################## + # Helper methods + ################################## + + ############### + # refresh_receiver_list + # + # Refreshes the self.receivers list from the SSDP query. It's automatically loaded at plugin launch, but we provide + # this method just in case we need to refresh the list. We also have a button in the config dialog that calls this + # so that we can force the list to refresh in the dialog itself. We don't refresh automatically because it takes + # a couple of seconds for the method to finish so the dialog looks like it's hung up. + ############### + def refresh_receiver_list(self, filter="", valuesDict=None, typeId="", targetId=0): + self.receivers = {r.ctrl_url: r for r in rxv.find()} + self.logger.debug("receivers list: %s" % unicode(self.receivers)) + + ############### + # updateStatus + # + # Updates the status for the specified device. + ############### + def updateStatus(self, dev_id): + devTup = self.devices.get(dev_id, None) + if devTup: + dev = devTup[0] + if dev.deviceTypeId == "receiver": + xml_string = 'GetParam' + root = ClassicReceiver.xmitToReceiver(dev, xml_string) + power = root.find("./Main_Zone/Basic_Status/Power_Control/Power").text + sleep = root.find("./Main_Zone/Basic_Status/Power_Control/Sleep").text + if(sleep!='Off'): sleep = "n"+sleep + volume = root.find("./Main_Zone/Basic_Status/Vol/Lvl/Val").text + mute = root.find("./Main_Zone/Basic_Status/Vol/Mute").text + inputmode = root.find("./Main_Zone/Basic_Status/Input/Input_Sel").text + state_list = [ + {"key": "power", "value": power}, + {"key": "sleep", "value": sleep}, + {"key": "volume", "value": volume}, + {"key": "mute", "value": mute}, + {"key": "input", "value": inputmode} + ] + dev.updateStatesOnServer(state_list) + elif dev.deviceTypeId == "rxvX73": + rxv_obj = devTup[1] + if not rxv_obj: + self.refresh_receiver_list() + rxv_obj = self.receivers.get(dev.pluginProps["control-url"], None) + if not rxv_obj: + dev.setErrorStateOnServer("unavailable") + return + else: + self.devices[dev.id] = (dev, rxv_obj) + try: + status = rxv_obj.basic_status + state_list = [ + {"key": "power", "value": status.on}, + {"key": "volume", "value": status.volume}, + {"key": "mute", "value": status.mute}, + {"key": "input", "value": status.input.replace(" ", "_")} + ] + dev.updateStatesOnServer(state_list) + dev.updateStateOnServer(key="sleep", value=kSleepValueMap[rxv_obj.sleep]) + if dev.errorState: + dev.setErrorStateOnServer(None) + except (ConnectTimeout, ReadTimeout, ConnectionError) as e: + dev.setErrorStateOnServer('unavailable') + if isinstance(e, ConnectTimeout) or isinstance(e, ReadTimeout): + self.logger.debug("device '%s' connection timed out" % dev.name) + else: + self.logger.debug("device '%s' had a connection error" % dev.name) + except ParseError: + # dev.setErrorStateOnServer('unavailable') + # self.logger.debug("device '%s' failed to update status with an XML parse error" % dev.name) + # This seems to happen relatively frequently - apparently sometimes the amp goes out to lunch for + # a bit (causing connection errors) and when it comes back it doesn't always return correct XML. + # I think the better idea here is to just pass on these errors as it always seems to resolve itself. + pass + except Exception as e: + dev.setErrorStateOnServer('unavailable') + self.logger.debug("device '%s' failed to update status with error: \n%s" % (dev.name, traceback.format_exc(10))) + + ############### + # _set_rxv_property + # + # Updates the specified property for the specified device. It catches exceptions and does the appropriate thing. + ############### + def _set_rxv_property(self, dev, property, value): + try: + d, rxv_obj = self.devices.get(dev.id, (None, None)) + if rxv_obj: + if callable(getattr(rxv_obj, property)): + if value: + getattr(rxv_obj, property)(value) + else: + getattr(rxv_obj, property)() + else: + setattr(rxv_obj, property, value) + else: + self.logger.error("device '%s' isn't available" % dev.name) + except (ConnectTimeout, ReadTimeout, ConnectionError) as e: + dev.setErrorStateOnServer('unavailable') + if isinstance(e, ConnectTimeout) or isinstance(e, ReadTimeout): + self.logger.debug("device '%s' connection timed out" % dev.name) + self.logger.error("device '%s' is unavailable" % dev.name) + else: + self.logger.debug("device '%s' had a connection error" % dev.name) + self.logger.error("device '%s' is unavailable" % dev.name) + except rxv_exceptions.ResponseException as e: + response = ET.XML(str(e)) + if response.get("RC") == "3": + # RC 3 is what happens when you issue a command that's not valid - wrong menu navigation direction, etc. + # We just skip it. + pass + elif response.get("RC") != "4": + self.logger.error("device '%s' can't have property '%s' set to value '%s'" % (dev.name, property, str(value))) + else: + # RC 4 is what happens when the amp is offline or in standby and you try to send it a command other than + # to turn on (if in standby) + self.logger.error("device '%s' is unavailable" % dev.name) + except ET.ParseError: + # dev.setErrorStateOnServer('unavailable') + # self.logger.debug("device '%s' failed to update status with an XML parse error" % dev.name) + # This seems to happen relatively frequently - apparently sometimes the amp goes out to lunch for + # a bit (causing connection errors) and when it comes back it doesn't always return correct XML. + # I think the better idea here is to just pass on these errors as it always seems to resolve itself. + pass + except: + dev.setErrorStateOnServer('unavailable') + self.logger.error("device '%s' failed to set property status with error: \n%s" % (dev.name, traceback.format_exc(10))) + + ################################## + # Action methods + ################################## + def getStatus(self, pluginAction, dev): + self.updateStatus(dev.id) + + def setMute(self, pluginAction, dev): + self.logger.debug(u"setMute called") + val = pluginAction.props['ddlmute'] + if dev.deviceTypeId == "receiver": + ClassicReceiver.putMute(self.logger, dev, val) + elif dev.deviceTypeId == "rxvX73": + self._set_rxv_property(dev, 'mute', str2bool(val)) + + def toggleMute(self, pluginAction, dev): + self.logger.debug(u"toggleMute called") + self.updateStatus(dev.id) + dev.refreshFromServer() + if dev.deviceTypeId == "receiver": + val = 'On' if dev.states['mute'] == 'Off' else 'Off' + ClassicReceiver.putMute(self.logger, dev, val) + elif dev.deviceTypeId == "rxvX73": + self._set_rxv_property(dev, 'mute', not str2bool(dev.states['mute'])) + + def setVolume(self, pluginAction, dev): + self.logger.debug(u"setVolume called") + val = pluginAction.props['txtvolume'] + if dev.deviceTypeId == "receiver": + ClassicReceiver.putVolume(self.logger, dev, val) + elif dev.deviceTypeId == "rxvX73": + self._set_rxv_property(dev, 'volume', float(val)) + + def increaseVolume(self, pluginAction, dev): + self.logger.debug(u"increaseVolume called") + self.updateStatus(dev.id) + dev.refreshFromServer() + val = float(dev.states['volume']) + int(pluginAction.props['txtincrement']) + if dev.deviceTypeId == "receiver": + ClassicReceiver.putVolume(self.logger, dev, int(val)) + elif dev.deviceTypeId == "rxvX73": + self._set_rxv_property(dev, 'volume', val) + + def decreaseVolume(self, pluginAction, dev): + self.logger.debug(u"decreaseVolume called") + self.updateStatus(dev.id) + dev.refreshFromServer() + val = float(dev.states['volume']) - int(pluginAction.props['txtincrement']) + if dev.deviceTypeId == "receiver": + ClassicReceiver.putVolume(self.logger, dev, int(val)) + elif dev.deviceTypeId == "rxvX73": + self._set_rxv_property(dev, 'volume', val) + + def setPower(self, pluginAction, dev): + self.logger.debug(u"setPower called") + val = pluginAction.props['ddlpower'] + if dev.deviceTypeId == "receiver": + ClassicReceiver.putPower(self.logger, dev, val) + elif dev.deviceTypeId == "rxvX73": + self._set_rxv_property(dev, 'on', str2bool(val)) + + def togglePower(self, pluginAction, dev): + self.logger.debug(u"togglePower called") + self.updateStatus(dev.id) + dev.refreshFromServer() + val = 'On' if (dev.states['power']=='Standby') else 'Standby' + if dev.deviceTypeId == "receiver": + ClassicReceiver.putPower(self.logger, dev, val) + elif dev.deviceTypeId == "rxvX73": + self._set_rxv_property(dev, 'on', str2bool(val)) + + def setSleep(self, pluginAction, dev): + self.logger.debug(u"setSleep called") + val = pluginAction.props['ddlsleep'].replace('n','') + if dev.deviceTypeId == "receiver": + ClassicReceiver.putSleep(self.logger, dev, val) + elif dev.deviceTypeId == "rxvX73": + self._set_rxv_property(dev, 'sleep', "Off" if val == "Off" else "%s min" % val) + + def setInput(self, pluginAction, dev): + self.logger.debug(u"setInput called") + if dev.deviceTypeId == "receiver": + val = pluginAction.props['ddlinput'].upper().replace(".","/").replace("_"," ") + ClassicReceiver.putInput(self.logger, dev, val) + elif dev.deviceTypeId == "rxvX73": + self._set_rxv_property(dev, 'input', pluginAction.props['ddlinput'].replace("_", " ").replace("iPod", "iPod (USB)")) + + def setZone(self, pluginAction, dev): + self.logger.debug(u"setZone called") + self._set_rxv_property(dev, 'zone', pluginAction.props['zone']) + + def playNetRadio(self, pluginAction, dev): + self.logger.debug(u"playNetRadio called") + self._set_rxv_property(dev, 'net_radio', pluginAction.props['path']) + + def menuUp(self, pluginAction, dev): + self.logger.debug(u"menuUp called") + self._set_rxv_property(dev, 'menu_up', None) + + def menuDown(self, pluginAction, dev): + self.logger.debug(u"menuDown called") + self._set_rxv_property(dev, 'menu_down', None) + + def menuLeft(self, pluginAction, dev): + self.logger.debug(u"menuLeft called") + self._set_rxv_property(dev, 'menu_left', None) + + def menuRight(self, pluginAction, dev): + self.logger.debug(u"menuRight called") + self._set_rxv_property(dev, 'menu_right', None) + + def menuSelect(self, pluginAction, dev): + self.logger.debug(u"menuSelect called") + self._set_rxv_property(dev, 'menu_sel', None) + + def menuReturn(self, pluginAction, dev): + self.logger.debug(u"menuReturn called") + self._set_rxv_property(dev, 'menu_return', None) diff --git a/Yahama RX.indigoPlugin/Contents/Server Plugin/rxv/LICENSE b/Yahama RX.indigoPlugin/Contents/Server Plugin/rxv/LICENSE new file mode 100644 index 0000000..0925749 --- /dev/null +++ b/Yahama RX.indigoPlugin/Contents/Server Plugin/rxv/LICENSE @@ -0,0 +1,10 @@ +Copyright (c) 2013, Joanna Tustanowska & Wojciech Bederski +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + + - Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. + - Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. + - Names of contributors may not be used to endorse or promote products derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. \ No newline at end of file diff --git a/Yahama RX.indigoPlugin/Contents/Server Plugin/rxv/__init__.py b/Yahama RX.indigoPlugin/Contents/Server Plugin/rxv/__init__.py new file mode 100644 index 0000000..2f4faeb --- /dev/null +++ b/Yahama RX.indigoPlugin/Contents/Server Plugin/rxv/__init__.py @@ -0,0 +1,27 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +from __future__ import division, absolute_import, print_function +import logging + +from .rxv import RXV +from .rxv import PlaybackSupport +from . import ssdp + +__all__ = ['RXV'] + +# disable default logging of warnings to stderr. If a consuming +# application sets up logging, it will work as expected. +logging.getLogger('rxv').addHandler(logging.NullHandler()) + + +def find(timeout=1.5): + """Find all Yamah receivers on local network using SSDP search.""" + return [ + RXV( + ctrl_url=ri.ctrl_url, + model_name=ri.model_name, + friendly_name=ri.friendly_name, + unit_desc_url=ri.unit_desc_url + ) + for ri in ssdp.discover(timeout=timeout) + ] diff --git a/Yahama RX.indigoPlugin/Contents/Server Plugin/rxv/exceptions.py b/Yahama RX.indigoPlugin/Contents/Server Plugin/rxv/exceptions.py new file mode 100644 index 0000000..b5719f1 --- /dev/null +++ b/Yahama RX.indigoPlugin/Contents/Server Plugin/rxv/exceptions.py @@ -0,0 +1,25 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +from __future__ import absolute_import, division, print_function + + +class RXVException(Exception): + pass + + +class ResponseException(RXVException): + """Exception raised when yamaha receiver responded with an error code""" + pass + +ReponseException = ResponseException + + +class MenuUnavailable(RXVException): + """Menu control unavailable for current input""" + pass + + +class PlaybackUnavailable(RXVException): + """Raised when playback function called on unsupported source.""" + def __init__(self, source, action): + super(PlaybackUnavailable, self).__init__('{} does not support {}'.format(source, action)) diff --git a/Yahama RX.indigoPlugin/Contents/Server Plugin/rxv/rxv.py b/Yahama RX.indigoPlugin/Contents/Server Plugin/rxv/rxv.py new file mode 100644 index 0000000..cc0c077 --- /dev/null +++ b/Yahama RX.indigoPlugin/Contents/Server Plugin/rxv/rxv.py @@ -0,0 +1,514 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +from __future__ import absolute_import, division, print_function + +import copy +import logging +import re +import time +import warnings +import xml.etree.ElementTree as ET +from collections import namedtuple +from math import floor + +import requests + +from .exceptions import MenuUnavailable, PlaybackUnavailable, ResponseException + +try: + from urllib.parse import urlparse +except ImportError: + from urlparse import urlparse + +logger = logging.getLogger('rxv') + + +class PlaybackSupport: + """Container for Playback support. + + This stores a set of booleans so that they are easy to turn into + whatever format the support needs to be specified at a higher + level. + + """ + def __init__(self, play=False, stop=False, pause=False, + skip_f=False, skip_r=False): + self.play = play + self.stop = stop + self.pause = pause + self.skip_f = skip_f + self.skip_r = skip_r + + +BasicStatus = namedtuple("BasicStatus", "on volume mute input") +PlayStatus = namedtuple("PlayStatus", "playing artist album song station") +MenuStatus = namedtuple("MenuStatus", "ready layer name current_line max_line current_list") + +GetParam = 'GetParam' +YamahaCommand = '{payload}' +Zone = '<{zone}>{request_text}' +BasicStatusGet = 'GetParam' +PowerControl = '{state}' +PowerControlSleep = '{sleep_value}' +Input = '{input_name}' +InputSelItem = '{input_name}' +ConfigGet = '<{src_name}>GetParam' +PlayGet = '<{src_name}>GetParam' +PlayControl = '<{src_name}>{action}' +ListGet = '<{src_name}>GetParam' +ListControlJumpLine = '<{src_name}>{lineno}' \ + '' +ListControlCursor = '<{src_name}>{action}'\ + '' +VolumeLevel = '{value}' +VolumeLevelValue = '{val}{exp}{unit}' +VolumeMute = '{state}' +SelectNetRadioLine = 'Line_{lineno}'\ + '' + + +class RXV(object): + + def __init__(self, ctrl_url, model_name="Unknown", + zone="Main_Zone", friendly_name='Unknown', + unit_desc_url=None, timeout=5): + if re.match(r"\d{1,3}\.\d{1,3}\.\d{1,3}.\d{1,3}", ctrl_url): + # backward compatibility: accept ip address as a contorl url + warnings.warn("Using IP address as a Control URL is deprecated") + ctrl_url = 'http://%s/YamahaRemoteControl/ctrl' % ctrl_url + self.ctrl_url = ctrl_url + self.unit_desc_url = unit_desc_url or re.sub('ctrl$', 'desc.xml', ctrl_url) + self.model_name = model_name + self.friendly_name = friendly_name + self._inputs_cache = None + self._zones_cache = None + self._zone = zone + self.timeout = timeout + self._session = requests.Session() + self._discover_features() + + def _discover_features(self): + """Pull and parse the desc.xml so we can query it later.""" + try: + desc_xml = self._session.get(self.unit_desc_url, timeout=self.timeout).content + self._desc_xml = ET.fromstring(desc_xml) + except ET.ParseError: + logger.exception("Invalid XML returned for request %s: %s", + self.unit_desc_url, desc_xml) + raise + except Exception: + logger.exception("Failed to fetch %s" % self.unit_desc_url) + raise + + def __unicode__(self): + return ('<{cls} model_name="{model}" zone="{zone}" ' + 'ctrl_url="{ctrl_url}" at {addr}>'.format( + cls=self.__class__.__name__, + zone=self._zone, + model=self.model_name, + ctrl_url=self.ctrl_url, + addr=hex(id(self)) + )) + + def __str__(self): + return self.__unicode__() + + def __repr__(self): + return self.__unicode__() + + def _request(self, command, request_text, zone_cmd=True): + if zone_cmd: + payload = Zone.format(request_text=request_text, zone=self._zone) + else: + payload = request_text + + request_text = YamahaCommand.format(command=command, payload=payload) + try: + res = self._session.post( + self.ctrl_url, + data=request_text, + headers={"Content-Type": "text/xml"}, + timeout=self.timeout, + ) + response = ET.XML(res.content) # releases connection to the pool + if response.get("RC") != "0": + logger.error("Request %s failed with %s", + request_text, res.content) + raise ResponseException(res.content) + return response + except ET.ParseError: + logger.exception("Invalid XML returned for request %s: %s", + request_text, res.content) + raise + + @property + def basic_status(self): + response = self._request('GET', BasicStatusGet) + on = response.find("%s/Basic_Status/Power_Control/Power" % self.zone).text + inp = response.find("%s/Basic_Status/Input/Input_Sel" % self.zone).text + mute = response.find("%s/Basic_Status/Volume/Mute" % self.zone).text + volume = response.find("%s/Basic_Status/Volume/Lvl/Val" % self.zone).text + volume = int(volume) / 10.0 + + status = BasicStatus(on, volume, mute, inp) + return status + + @property + def on(self): + request_text = PowerControl.format(state=GetParam) + response = self._request('GET', request_text) + power = response.find("%s/Power_Control/Power" % self._zone).text + assert power in ["On", "Standby"] + return power == "On" + + @on.setter + def on(self, state): + assert state in [True, False] + new_state = "On" if state else "Standby" + request_text = PowerControl.format(state=new_state) + response = self._request('PUT', request_text) + return response + + def off(self): + return self.on(False) + + def get_playback_support(self, input_source=None): + """Get playback support as bit vector. + + In order to expose features correctly in Home Assistant, we + need to make it possible to understand what play operations a + source supports. This builds us a Home Assistant compatible + bit vector from the desc.xml for the specified source. + """ + + if input_source is None: + input_source = self.input + src_name = self._src_name(input_source) + + return PlaybackSupport( + play=self.supports_play_method(src_name, 'Play'), + pause=self.supports_play_method(src_name, 'Pause'), + stop=self.supports_play_method(src_name, 'Stop'), + skip_f=self.supports_play_method(src_name, 'Skip Fwd'), + skip_r=self.supports_play_method(src_name, 'Skip Rev')) + + def is_playback_supported(self, input_source=None): + if input_source is None: + input_source = self.input + support = self.get_playback_support(input_source) + return support.play + + def play(self): + self._playback_control('Play') + + def pause(self): + self._playback_control('Pause') + + def stop(self): + self._playback_control('Stop') + + def next(self): + self._playback_control('Skip Fwd') + + def previous(self): + self._playback_control('Skip Rev') + + def _playback_control(self, action): + # Cache current input to "save" one HTTP-request + input_source = self.input + if not self.is_playback_supported(input_source): + raise PlaybackUnavailable(input_source, action) + + src_name = self._src_name(input_source) + if not src_name: + return None + + request_text = PlayControl.format(src_name=src_name, action=action) + response = self._request('PUT', request_text, zone_cmd=False) + return response + + @property + def input(self): + request_text = Input.format(input_name=GetParam) + response = self._request('GET', request_text) + return response.find("%s/Input/Input_Sel" % self.zone).text + + @input.setter + def input(self, input_name): + assert input_name in self.inputs() + request_text = Input.format(input_name=input_name) + self._request('PUT', request_text) + + def inputs(self): + if not self._inputs_cache: + request_text = InputSelItem.format(input_name=GetParam) + res = self._request('GET', request_text) + self._inputs_cache = dict(zip((elt.text + for elt in res.iter('Param')), + (elt.text + for elt in res.iter("Src_Name")))) + return self._inputs_cache + + @property + def zone(self): + return self._zone + + @zone.setter + def zone(self, zone_name): + assert zone_name in self.zones() + self._zone = zone_name + + def zones(self): + if self._zones_cache is None: + xml = self._desc_xml + self._zones_cache = [ + e.get("YNC_Tag") for e in xml.findall('.//*[@Func="Subunit"]') + ] + return self._zones_cache + + def zone_controllers(self): + """Return separate RXV controller for each available zone.""" + controllers = [] + for zone in self.zones(): + zone_ctrl = copy.copy(self) + zone_ctrl.zone = zone + controllers.append(zone_ctrl) + return controllers + + def supports_method(self, source, *args): + # if there was a complete xpath implementation we could do + # this all with xpath, but without it it's lots of + # iteration. This is probably not worth optimizing, these + # loops are cheep in the long run. + commands = self._desc_xml.findall('.//Cmd_List') + for c in commands: + for item in c: + parts = item.text.split(",") + if parts[0] == source and parts[1:] == list(args): + return True + return False + + def supports_play_method(self, source, method): + # if there was a complete xpath implementation we could do + # this all with xpath, but without it it's lots of + # iteration. This is probably not worth optimizing, these + # loops are cheep in the long run. + source_xml = self._desc_xml.find('.//*[@YNC_Tag="%s"]' % source) + if source_xml is None: + return False + + play_control = source_xml.find('.//*[@Func="Play_Control"]') + if play_control is None: + return False + + # built in Element Tree does not support search by text() + supports = play_control.findall('.//Put_1') + for s in supports: + if s.text == method: + return True + return False + + def _src_name(self, cur_input): + if cur_input not in self.inputs(): + return None + return self.inputs()[cur_input] + + def is_ready(self): + src_name = self._src_name(self.input) + if not src_name: + return True # input is instantly ready + + request_text = ConfigGet.format(src_name=src_name) + config = self._request('GET', request_text, zone_cmd=False) + + avail = next(config.iter('Feature_Availability')) + return avail.text == 'Ready' + + def play_status(self): + src_name = self._src_name(self.input) + if not src_name: + return None + + if not self.supports_method(src_name, 'Play_Info'): + return + + request_text = PlayGet.format(src_name=src_name) + res = self._request('GET', request_text, zone_cmd=False) + + playing = (res.find(".//Playback_Info").text == "Play") + + def safe_get(doc, name): + tag = doc.find(".//%s" % name) + if tag is not None: + return tag.text or "" + else: + return "" + + artist = safe_get(res, "Artist") + album = safe_get(res, "Album") + song = safe_get(res, "Song") + station = safe_get(res, "Station") + + status = PlayStatus(playing, artist, album, song, station) + return status + + def menu_status(self): + cur_input = self.input + src_name = self._src_name(cur_input) + if not src_name: + raise MenuUnavailable(cur_input) + + request_text = ListGet.format(src_name=src_name) + res = self._request('GET', request_text, zone_cmd=False) + + ready = (next(res.iter("Menu_Status")).text == "Ready") + layer = int(next(res.iter("Menu_Layer")).text) + name = next(res.iter("Menu_Name")).text + current_line = int(next(res.iter("Current_Line")).text) + max_line = int(next(res.iter("Max_Line")).text) + current_list = next(res.iter('Current_List')) + + cl = { + elt.tag: elt.find('Txt').text + for elt in current_list.getchildren() + if elt.find('Attribute').text != 'Unselectable' + } + + status = MenuStatus(ready, layer, name, current_line, max_line, cl) + return status + + def menu_jump_line(self, lineno): + cur_input = self.input + src_name = self._src_name(cur_input) + if not src_name: + raise MenuUnavailable(cur_input) + + request_text = ListControlJumpLine.format(src_name=src_name, lineno=lineno) + return self._request('PUT', request_text, zone_cmd=False) + + def _menu_cursor(self, action): + cur_input = self.input + src_name = self._src_name(cur_input) + if not src_name: + raise MenuUnavailable(cur_input) + + request_text = ListControlCursor.format(src_name=src_name, action=action) + return self._request('PUT', request_text, zone_cmd=False) + + def menu_up(self): + return self._menu_cursor("Up") + + def menu_down(self): + return self._menu_cursor("Down") + + def menu_left(self): + return self._menu_cursor("Left") + + def menu_right(self): + return self._menu_cursor("Right") + + def menu_sel(self): + return self._menu_cursor("Sel") + + def menu_return(self): + return self._menu_cursor("Return") + + @property + def volume(self): + request_text = VolumeLevel.format(value=GetParam) + response = self._request('GET', request_text) + vol = response.find('%s/Volume/Lvl/Val' % self.zone).text + return float(vol) / 10.0 + + @volume.setter + def volume(self, value): + value = str(int(value * 10)) + exp = 1 + unit = 'dB' + + volume_val = VolumeLevelValue.format(val=value, exp=exp, unit=unit) + request_text = VolumeLevel.format(value=volume_val) + self._request('PUT', request_text) + + def volume_fade(self, final_vol, sleep=0.5): + start_vol = int(floor(self.volume)) + step = 1 if final_vol > start_vol else -1 + final_vol += step # to make sure, we don't stop one dB before + + for val in range(start_vol, final_vol, step): + self.volume = val + time.sleep(sleep) + + @property + def mute(self): + request_text = VolumeMute.format(state=GetParam) + response = self._request('GET', request_text) + mute = response.find('%s/Volume/Mute' % self.zone).text + assert mute in ["On", "Off"] + return mute == "On" + + @mute.setter + def mute(self, state): + assert state in [True, False] + new_state = "On" if state else "Off" + request_text = VolumeMute.format(state=new_state) + response = self._request('PUT', request_text) + return response + + def _direct_sel(self, lineno): + request_text = SelectNetRadioLine.format(lineno=lineno) + return self._request('PUT', request_text, zone_cmd=False) + + def net_radio(self, path): + """Play net radio at the specified path. + + This lets you play a NET_RADIO address in a single command + with by encoding it with > as separators. For instance: + + Bookmarks>Internet>Radio Paradise + + It does this by push commands, then looping and making sure + the menu is in a ready state before we try to push the next + one. A sufficient number of iterations are allowed for to + ensure we give it time to get there. + + TODO: better error handling if we some how time out + """ + layers = path.split(">") + self.input = "NET RADIO" + + for attempt in range(20): + menu = self.menu_status() + if menu.ready: + for line, value in menu.current_list.items(): + if value == layers[menu.layer - 1]: + lineno = line[5:] + self._direct_sel(lineno) + if menu.layer == len(layers): + return + break + else: + # print("Sleeping because we are not ready yet") + time.sleep(1) + + @property + def sleep(self): + request_text = PowerControlSleep.format(sleep_value=GetParam) + response = self._request('GET', request_text) + sleep = response.find("%s/Power_Control/Sleep" % self._zone).text + return sleep + + @sleep.setter + def sleep(self, value): + request_text = PowerControlSleep.format(sleep_value=value) + self._request('PUT', request_text) + + @property + def small_image_url(self): + host = urlparse(self.ctrl_url).hostname + return "http://{}:8080/BCO_device_sm_icon.png".format(host) + + @property + def large_image_url(self): + host = urlparse(self.ctrl_url).hostname + return "http://{}:8080/BCO_device_lrg_icon.png".format(host) diff --git a/Yahama RX.indigoPlugin/Contents/Server Plugin/rxv/ssdp.py b/Yahama RX.indigoPlugin/Contents/Server Plugin/rxv/ssdp.py new file mode 100644 index 0000000..7be95a3 --- /dev/null +++ b/Yahama RX.indigoPlugin/Contents/Server Plugin/rxv/ssdp.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +from __future__ import absolute_import, division, print_function + +import re +import socket +import xml.etree.ElementTree as ET +from collections import namedtuple + +import requests + +try: + from urllib.parse import urljoin +except ImportError: + from urlparse import urljoin + + +SSDP_ADDR = '239.255.255.250' +SSDP_PORT = 1900 +SSDP_MSEARCH_QUERY = ( + 'M-SEARCH * HTTP/1.1\r\n' + 'MX: 1\r\n' + 'HOST: 239.255.255.250:1900\r\n' + 'MAN: "ssdp:discover"\r\n' + 'ST: upnp:rootdevice\r\n\r\n' +) + +URL_BASE_QUERY = '*/{urn:schemas-yamaha-com:device-1-0}X_URLBase' +CONTROL_URL_QUERY = '***/{urn:schemas-yamaha-com:device-1-0}X_controlURL' +UNITDESC_URL_QUERY = '***/{urn:schemas-yamaha-com:device-1-0}X_unitDescURL' +MODEL_NAME_QUERY = ( + "{urn:schemas-upnp-org:device-1-0}device" + "/{urn:schemas-upnp-org:device-1-0}modelName" +) +FRIENDLY_NAME_QUERY = ( + "{urn:schemas-upnp-org:device-1-0}device" + "/{urn:schemas-upnp-org:device-1-0}friendlyName" +) + +RxvDetails = namedtuple("RxvDetails", "ctrl_url unit_desc_url, model_name friendly_name") + + +def discover(timeout=1.5): + """Crude SSDP discovery. Returns a list of RxvDetails objects + with data about Yamaha Receivers in local network""" + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP) + sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, 2) + sock.sendto(SSDP_MSEARCH_QUERY.encode("utf-8"), (SSDP_ADDR, SSDP_PORT)) + sock.settimeout(timeout) + + responses = [] + try: + while True: + responses.append(sock.recv(10240)) + except socket.timeout: + pass + + results = [] + for res in responses: + m = re.search(r"LOCATION:(.+)", res.decode('utf-8'), re.IGNORECASE) + if not m: + continue + url = m.group(1).strip() + res = rxv_details(url) + if res: + results.append(res) + + return results + + +def rxv_details(location): + """Looks under given UPNP url, and checks if Yamaha amplituner lives there + returns RxvDetails if yes, None otherwise""" + try: + xml = ET.XML(requests.get(location).content) + except: + return None + url_base_el = xml.find(URL_BASE_QUERY) + if url_base_el is None: + return None + ctrl_url_local = xml.find(CONTROL_URL_QUERY).text + ctrl_url = urljoin(url_base_el.text, ctrl_url_local) + unit_desc_url_local = xml.find(UNITDESC_URL_QUERY).text + unit_desc_url = urljoin(url_base_el.text, unit_desc_url_local) + model_name = xml.find(MODEL_NAME_QUERY).text + friendly_name = xml.find(FRIENDLY_NAME_QUERY).text + + return RxvDetails(ctrl_url, unit_desc_url, model_name, friendly_name) + + +if __name__ == '__main__': + print(discover())