diff --git a/yamahaavr/README.md b/yamahaavr/README.md new file mode 100644 index 00000000..24174267 --- /dev/null +++ b/yamahaavr/README.md @@ -0,0 +1,28 @@ +# Yamaha AV Receiver + +This plugin allows to control your Yamaha (non-MusicCast) AV receivers. + +Each supported receiver on your local area network should appear automatically in the system. + +Browsing is supported, but can be slow due to the nature of the Yamaha API. +As a nice extra, a random album on a random server can be started with a simple action. + +## Supported Things + +* Yamaha RX-V675 (tested) +* Other non-MusicCast Yamaha RX-V devices should also work, but haven't been tested +* Newer Yamaha MusicCast devices aren't supported, as they use a different API + +## Manual + +* Volumes (and some other variables) are represented by integer in the Yamaha API, but shown as double = int/10 in Yamaha UI, so e.g. API will show -455, but receiver will show -45.5 dB. As the Nymea media interface currently needs volume to be an integer, the plugin will show volume as integer, so e.g. -455 instead of -45.5 dB +* Browsing shortcuts (for browsing a SERVER source) can be added via the Thing settings, to avoid having to go through e.g. ServerName/Music/By Folder/FolderName each time. By adding a "shortcut tree" such as ServerName/Music/By Folder/FolderName to the settings, browsing will start in folder FolderName instead of first showing a list of servers including ServerName; another possibility is e.g. ServerName2/Music/By Album + +## Requirements + +* nymea and the Yamaha device must be in the same local area network. +* The package "nymea-plugin-yamaha" must be installed. + +## More + + [Yamaha Electronics](https://www.yamaha.com/en/) diff --git a/yamahaavr/integrationpluginyamaha.json b/yamahaavr/integrationpluginyamaha.json new file mode 100644 index 00000000..cc86df4e --- /dev/null +++ b/yamahaavr/integrationpluginyamaha.json @@ -0,0 +1,754 @@ +{ + "displayName": "Yamaha AV receiver", + "name": "Yamaha", + "id": "aa07a5cc-ca65-4043-9e61-e7040a6a60ff", + "vendors": [ + { + "id": "07460546-0b4a-4c3c-9143-d5de6b09de71", + "displayName": "Yamaha Corporation", + "name": "yamaha", + "thingClasses": [ + { + "id": "f799a98a-8521-451e-a206-4b60e2dd0985", + "name": "receiver", + "displayName": "Yamaha AV Receiver", + "createMethods": ["discovery"], + "interfaces": ["mediaplayer", "mediacontroller", "volumecontroller", "mediametadataprovider", "connectable", "power"], + "browsable": true, + "paramTypes": [ + { + "id": "0129f3eb-3cc6-47fa-9366-74194ec655f9", + "name": "serial", + "displayName": "Serial Number", + "type": "QString", + "readOnly": true + } + ], + "settingsTypes": [ + { + "id": "e7a1e300-f3e9-4b56-a320-ed347409da9c", + "name": "browsingShortcut1", + "displayName": "Browsing shortcut 1 (see readme)", + "type": "QString", + "defaultValue": "" + }, + { + "id": "98fea0de-82a5-40af-bd98-35099555c639", + "name": "shortcutLabel1", + "displayName": "Shortcut 1 label", + "type": "QString", + "defaultValue": "" + }, + { + "id": "ca923c89-fdf9-4d3d-b1e7-947530450129", + "name": "browsingShortcut2", + "displayName": "Browsing shortcut 2", + "type": "QString", + "defaultValue": "" + }, + { + "id": "2aa92119-8a84-4ad0-8115-3e040ce1b54a", + "name": "shortcutLabel2", + "displayName": "Shortcut 2 label", + "type": "QString", + "defaultValue": "" + }, + { + "id": "62747f24-f706-4f40-9a3a-28a444125e59", + "name": "browsingShortcut3", + "displayName": "Browsing shortcut 3", + "type": "QString", + "defaultValue": "" + }, + { + "id": "bdc440e9-8340-406f-9c86-32fd3795031a", + "name": "shortcutLabel3", + "displayName": "Shortcut 3 label", + "type": "QString", + "defaultValue": "" + }, + { + "id": "28549b0d-c006-47ca-b215-3a86b1ed5440", + "name": "browsingShortcut4", + "displayName": "Browsing shortcut 4", + "type": "QString", + "defaultValue": "" + }, + { + "id": "e35d5f98-3ea6-488c-af41-a1b7635f1cb3", + "name": "shortcutLabel4", + "displayName": "Shortcut 4 label", + "type": "QString", + "defaultValue": "" + }, + { + "id": "ab9e7218-5673-43e1-80a8-d35125dfa5a7", + "name": "browsingShortcut5", + "displayName": "Browsing shortcut 5", + "type": "QString", + "defaultValue": "" + }, + { + "id": "15793b6f-e2f6-47c4-96f7-494eac77a61e", + "name": "shortcutLabel5", + "displayName": "Shortcut 5 label", + "type": "QString", + "defaultValue": "" + } + ], + "stateTypes": [ + { + "id": "ea3ccab0-549a-4174-a6e1-493981cf5fa8", + "name": "connected", + "displayName": "Connected", + "displayNameEvent": "Connected changed", + "defaultValue": false, + "type": "bool", + "cached": false + }, + { + "displayName": "Power", + "id": "5b1232a6-4eb5-4e7a-825b-4906de623ea0", + "name": "power", + "displayNameEvent": "Power changed", + "displayNameAction": "Set power", + "type": "bool", + "defaultValue": false, + "writable": true + }, + { + "displayName": "Sleep", + "id": "8090c8ea-dea5-45d0-9592-092c50ee4235", + "name": "sleep", + "displayNameEvent": "Sleep timer changed", + "displayNameAction": "Set sleep timer", + "type": "QString", + "writable": true, + "possibleValues": [ + "Off", + "30 min", + "60 min", + "90 min", + "120 min" + ], + "defaultValue": "Off" + }, + { + "displayName": "Mute", + "id": "f36a7bac-313e-4ab2-8816-4d9ce3684414", + "name": "mute", + "displayNameEvent": "Mute changed", + "displayNameAction": "Set mute", + "type": "bool", + "defaultValue": false, + "writable": true + }, + { + "displayName": "Volume", + "id": "9734a504-7447-4c82-ba90-d47fbe50a696", + "name": "volume", + "displayNameEvent": "Volume changed", + "displayNameAction": "Set volume", + "type": "int", + "defaultValue": -500, + "minValue": -800, + "maxValue": -100, + "writable": true + }, + { + "displayName": "Input Source", + "id": "710949d8-ae7c-4a60-9cfa-a9f0eb2ffbe1", + "name": "inputSource", + "displayNameEvent": "Input source changed", + "displayNameAction": "Set input source", + "type": "QString", + "writable": true, + "possibleValues": [ + "TUNER", + "NAPSTER", + "SPOTIFY", + "SERVER", + "AirPlay", + "NET RADIO", + "USB", + "HDMI1", + "HDMI2", + "HDMI3", + "HDMI4", + "HDMI5", + "AV1", + "AV2", + "AV3", + "AV4", + "AV5", + "AV6", + "V-AUX", + "AUDIO1", + "AUDIO2" + ], + "defaultValue": "HDMI1" + }, + { + "displayName": "Surround Program", + "id": "1a9b6260-e71b-4750-bf03-34bca0b34222", + "name": "surroundMode", + "displayNameEvent": "Surround program changed", + "displayNameAction": "Set surround program", + "type": "QString", + "writable": true, + "possibleValues": [ + "Standard", + "2ch Stereo", + "7ch Stereo", + "Straight", + "Surround Decoder", + "Sci-Fi", + "Adventure", + "Drama", + "Spectacle", + "Mono Movie", + "Music Video", + "Roleplaying Game", + "Action Game", + "Sports", + "Hall in Munich", + "Hall in Vienna", + "Chamber", + "Cellar Club", + "The Roxy Theatre", + "The Bottom Line" + ], + "defaultValue": "Standard" + }, + { + "displayName": "Bass", + "id": "07b2c6ae-74c6-4cb2-b434-a25ad90fb7f9", + "name": "bass", + "displayNameEvent": "Bass changed", + "displayNameAction": "Set bass", + "type": "int", + "defaultValue": 0, + "minValue": -60, + "maxValue": 60, + "writable": true + }, + { + "displayName": "Treble", + "id": "c06bce86-c1cb-42f0-9891-161a7f0849f5", + "name": "treble", + "displayNameEvent": "Treble changed", + "displayNameAction": "Set treble", + "type": "int", + "defaultValue": 0, + "minValue": -60, + "maxValue": 60, + "writable": true + }, + { + "displayName": "Subwoofer Trim", + "id": "8b77db08-9a94-466c-8a57-b7cd8e638a80", + "name": "subwooferTrim", + "displayNameEvent": "Subwoofer trim changed", + "displayNameAction": "Set subwoofer Trim", + "type": "int", + "defaultValue": 0, + "minValue": -60, + "maxValue": 60, + "writable": true + }, + { + "displayName": "Pure direct", + "id": "d19ddece-6bf5-4c2d-9bbc-018b02d564df", + "name": "pureDirect", + "displayNameEvent": "Pure direct changed", + "displayNameAction": "Set pure direct", + "type": "bool", + "defaultValue": false, + "writable": true + }, + { + "displayName": "Enhancer", + "id": "ca7a4564-9f6d-4f07-8f63-445e44a0eb93", + "name": "enhancer", + "displayNameEvent": "Enhancer changed", + "displayNameAction": "Set enhancer", + "type": "bool", + "defaultValue": false, + "writable": true + }, + { + "displayName": "Cinema DSP 3D", + "id": "8504e78f-924b-492f-8cc7-69290fb29c95", + "name": "cinemaDSP3D", + "displayNameEvent": "Cinema DSP 3D changed", + "displayNameAction": "Set Cinema DSP 3D", + "type": "QString", + "writable": true, + "possibleValues": [ + "Off", + "Auto" + ], + "defaultValue": "Auto" + }, + { + "displayName": "Adaptive DRC", + "id": "99057bc6-4d2a-4670-a1c7-02e9e869b86b", + "name": "adaptiveDRC", + "displayNameEvent": "Adaptive DRC changed", + "displayNameAction": "Set Adaptive DRC", + "type": "QString", + "writable": true, + "possibleValues": [ + "Off", + "Auto" + ], + "defaultValue": "Auto" + }, + { + "displayName": "Dialogue level", + "id": "042ed95f-d0f7-4eda-b469-a7863a639e84", + "name": "dialogueLevel", + "displayNameEvent": "Dialogue level changed", + "displayNameAction": "Set dialogue level", + "type": "int", + "defaultValue": 0, + "minValue": 0, + "maxValue": 3, + "writable": true + }, + { + "displayName": "Dialogue lift", + "id": "3eff11b2-f6b0-4180-aadc-ef77fca633f4", + "name": "dialogueLift", + "displayNameEvent": "Dialogue lift changed", + "displayNameAction": "Set dialogue lift", + "type": "int", + "defaultValue": 0, + "minValue": 0, + "maxValue": 5, + "writable": true + }, + { + "id": "053c01cd-4696-490b-91ad-480cd90171e0", + "name": "artist", + "displayName": "Artist", + "displayNameEvent": "Artist changed", + "type": "QString", + "defaultValue": "" + }, + { + "id": "3db27780-b0a0-409e-9986-2c751f0bb9d8", + "name": "collection", + "displayName": "Album", + "displayNameEvent": "Album changed", + "type": "QString", + "defaultValue": "" + }, + { + "id": "1686f85c-ce71-474b-8ece-e8da97fb809b", + "name": "title", + "displayName": "Title", + "displayNameEvent": "Title changed", + "type": "QString", + "defaultValue": "" + }, + { + "id": "e321c171-b0c1-486f-a245-b4fd8ddf90e3", + "name": "artwork", + "displayName": "Artwork", + "displayNameEvent": "Artwork changed", + "type": "QString", + "defaultValue": "" + }, + { + "id": "f936b76f-657d-467a-90c0-cbdf08de2201", + "name": "playerType", + "displayName": "Player type", + "displayNameEvent": "Player type changed", + "possibleValues": [ + "audio", + "video" + ], + "type": "QString", + "defaultValue": "audio" + }, + { + "id": "f5a12603-5df9-4eae-9096-17775272d22f", + "name": "playbackStatus", + "displayName": "Playback status", + "displayNameEvent": "Playback status changed", + "displayNameAction": "Set playback status", + "type": "QString", + "defaultValue": "Stopped", + "possibleValues": ["Playing", "Paused", "Stopped"], + "cached": false, + "writable": true + }, + { + "id": "189600dd-6cdc-4453-a816-f897b8ef06dd", + "name": "shuffle", + "displayName": "Shuffle", + "displayNameEvent": "Shuffle changed", + "displayNameAction": "Set shuffle", + "type": "bool", + "defaultValue": false, + "cached": false, + "writable": true + }, + { + "id": "fa5e8258-ce4c-4afa-b8bc-9c3c2be7653b", + "name": "repeat", + "displayName": "Repeat mode", + "displayNameEvent": "Repeat mode changed", + "displayNameAction": "Set repeat mode", + "type": "QString", + "defaultValue": "None", + "possibleValues": ["None", "One", "All"], + "cached": false, + "writable": true + }, + { + "id": "b269c1f4-1881-461d-810b-b594de499490", + "name": "url", + "displayName": "Device Address", + "displayNameEvent": "Device IP changed", + "defaultValue": "0.0.0.0", + "type" : "QString" + } + ], + "actionTypes": [ + { + "id": "671abe60-b8a5-48cd-a138-9f4283a823d2", + "name": "randomAlbum", + "displayName": "Play Random Album" + }, + { + "id": "dd1a44d1-ecb2-4752-a8d4-f3960dec6808", + "displayName": "Increase volume", + "name": "increaseVolume", + "paramTypes": [ + { + "id": "3bb5ff93-a787-4a30-957e-b96f27da2199", + "name": "step", + "displayName": "Step size", + "type": "uint", + "minValue": 1, + "maxValue": 5, + "defaultValue": 5 + } + ] + }, + { + "id": "d3752c32-92e3-4396-8e2f-ab5e57c6cfb1", + "displayName": "Decrease volume", + "name": "decreaseVolume", + "paramTypes": [ + { + "id": "736f5e25-31ce-4d17-80ab-b82b18cf6540", + "name": "step", + "displayName": "Step size", + "type": "uint", + "minValue": 1, + "maxValue": 5, + "defaultValue": 5 + } + ] + }, + { + "id": "9d1812ba-77a6-493a-8fd0-77a6c44d3765", + "name": "skipBack", + "displayName": "Skip back" + }, + { + "id": "c5a8d2e0-60d8-4092-ae77-697b13ce5946", + "name": "stop", + "displayName": "Stop" + }, + { + "id": "18e999ae-a1ef-476e-a416-05b8b07527ae", + "name": "play", + "displayName": "Play" + }, + { + "id": "9f019a46-821a-4069-9772-fc649c39f941", + "name": "pause", + "displayName": "Pause" + }, + { + "id": "ffceeb31-8b66-4d03-b664-57a3f9c9ec1a", + "name": "skipNext", + "displayName": "Skip next" + } + ], + "browserItemActionTypes": [ + { + "id": "24e8955e-4a51-411b-863d-aa70c2ab90ef", + "name": "playRandom", + "displayName": "Play random album" + } + ] + }, + { + "id": "664ba198-a3e2-4380-83f3-adfb22a13130", + "name": "zone", + "displayName": "Yamaha AV Receiver Zone", + "createMethods": ["discovery"], + "interfaces": ["mediaplayer", "mediacontroller", "volumecontroller", "mediametadataprovider", "connectable", "power"], + "browsable": true, + "paramTypes": [ + { + "id": "9abc9961-0582-4b31-8f28-6659a3434352", + "name": "serial", + "displayName": "Serial Number", + "type" : "QString", + "readOnly": true + }, + { + "id": "6cfafac2-4aa5-4e73-b06e-550f4f66bc6a", + "name": "zoneId", + "displayName": "Zone number", + "type" : "int", + "readOnly": true + } + ], + "settingsTypes": [ + + ], + "stateTypes": [ + { + "id": "30c97665-1843-46d5-9091-06d97f456827", + "name": "connected", + "displayName": "Connected", + "displayNameEvent": "Connected changed", + "defaultValue": false, + "type": "bool", + "cached": false + }, + { + "displayName": "Power", + "id": "bcace3de-902e-4361-8be3-68e0e42cb729", + "name": "power", + "displayNameEvent": "Power changed", + "displayNameAction": "Set power", + "type": "bool", + "defaultValue": false, + "writable": true + }, + { + "displayName": "Sleep", + "id": "7ac5101f-96cd-40f4-a565-a15c266dafdb", + "name": "sleep", + "displayNameEvent": "Sleep timer changed", + "displayNameAction": "Set sleep timer", + "type": "QString", + "writable": true, + "possibleValues": [ + "Off", + "30 min", + "60 min", + "90 min", + "120 min" + ], + "defaultValue": "Off" + }, + { + "displayName": "Mute", + "id": "26ac1df0-77c7-4461-95a5-294ce629ad5a", + "name": "mute", + "displayNameEvent": "Mute changed", + "displayNameAction": "Set mute", + "type": "bool", + "defaultValue": false, + "writable": true + }, + { + "displayName": "Volume", + "id": "090ade12-ade2-43df-9d47-3d0cfc7c1def", + "name": "volume", + "displayNameEvent": "Volume changed", + "displayNameAction": "Set volume", + "type": "int", + "defaultValue": -500, + "minValue": -800, + "maxValue": -100, + "writable": true + }, + { + "displayName": "Input Source", + "id": "40de91af-6c09-4a72-8812-bdff354917d9", + "name": "inputSource", + "displayNameEvent": "Input source changed", + "displayNameAction": "Set input source", + "type": "QString", + "writable": true, + "possibleValues": [ + "TUNER", + "NAPSTER", + "SPOTIFY", + "SERVER", + "AirPlay", + "NET RADIO", + "USB", + "AV5", + "AV6", + "AUDIO1", + "AUDIO2" + ], + "defaultValue": "SERVER" + }, + { + "id": "14288bcb-be26-43a9-895a-f519fea47e62", + "name": "artist", + "displayName": "Artist", + "displayNameEvent": "Artist changed", + "type": "QString", + "defaultValue": "" + }, + { + "id": "330e5570-586c-44bb-87ee-58d37d139985", + "name": "collection", + "displayName": "Album", + "displayNameEvent": "Album changed", + "type": "QString", + "defaultValue": "" + }, + { + "id": "dfb1fac7-8a2e-42d3-b2f3-badb1c80be5c", + "name": "title", + "displayName": "Title", + "displayNameEvent": "Title changed", + "type": "QString", + "defaultValue": "" + }, + { + "id": "5bb9d082-4c22-49c1-8b03-94aab00b0b97", + "name": "artwork", + "displayName": "Artwork", + "displayNameEvent": "Artwork changed", + "type": "QString", + "defaultValue": "" + }, + { + "id": "eca64246-b1a6-4204-b0ce-fc800b8135d7", + "name": "playerType", + "displayName": "Player type", + "displayNameEvent": "Player type changed", + "possibleValues": [ + "audio", + "video" + ], + "type": "QString", + "defaultValue": "audio" + }, + { + "id": "03a1005e-91be-4c09-869d-4a90ba7c6fca", + "name": "playbackStatus", + "displayName": "Playback status", + "displayNameEvent": "Playback status changed", + "displayNameAction": "Set playback status", + "type": "QString", + "defaultValue": "Stopped", + "possibleValues": ["Playing", "Paused", "Stopped"], + "cached": false, + "writable": true + }, + { + "id": "7444ed1e-952c-4d33-9673-bcc599ffb52a", + "name": "shuffle", + "displayName": "Shuffle", + "displayNameEvent": "Shuffle changed", + "displayNameAction": "Set shuffle", + "type": "bool", + "defaultValue": false, + "cached": false, + "writable": true + }, + { + "id": "d03b4144-46fe-47ba-a287-3408b1dc3c54", + "name": "repeat", + "displayName": "Repeat mode", + "displayNameEvent": "Repeat mode changed", + "displayNameAction": "Set repeat mode", + "type": "QString", + "defaultValue": "None", + "possibleValues": ["None", "One", "All"], + "cached": false, + "writable": true + } + ], + "actionTypes": [ + { + "id": "7ffbe00e-b5e6-4602-9c1c-7319c0fc3257", + "name": "randomAlbum", + "displayName": "Play Random Album" + }, + { + "id": "ed011bda-7b52-4ba1-a99e-60ddf31bc859", + "displayName": "Increase volume", + "name": "increaseVolume", + "paramTypes": [ + { + "id": "065d7fc4-5819-49f6-917e-18fbef84e24e", + "name": "step", + "displayName": "Step size", + "type": "uint", + "minValue": 1, + "maxValue": 5, + "defaultValue": 5 + } + ] + }, + { + "id": "e753b540-c7d3-4f54-8078-e261ebf0bcbe", + "displayName": "Decrease volume", + "name": "decreaseVolume", + "paramTypes": [ + { + "id": "e5ee7aaa-ed67-4807-8941-0aa8266c4951", + "name": "step", + "displayName": "Step size", + "type": "uint", + "minValue": 1, + "maxValue": 5, + "defaultValue": 5 + } + ] + }, + { + "id": "ac22babd-b72a-48bb-913a-e47982c5bff9", + "name": "skipBack", + "displayName": "Skip back" + }, + { + "id": "d8eab201-27bc-4850-bbaa-be91e58cd874", + "name": "stop", + "displayName": "Stop" + }, + { + "id": "fe30284b-6863-4319-b71e-db18c0b1e2f3", + "name": "play", + "displayName": "Play" + }, + { + "id": "2568ac35-d051-442f-877d-a359129b3164", + "name": "pause", + "displayName": "Pause" + }, + { + "id": "e691da7c-ed1d-4b09-b60a-41a7fcd89e49", + "name": "skipNext", + "displayName": "Skip next" + } + ], + "browserItemActionTypes": [ + { + "id": "161ae3ac-efb1-460e-9564-c175bbf68861", + "name": "playRandom", + "displayName": "Play random album" + } + ] + } + ] + } + ] +} diff --git a/yamahaavr/integrationpluginyamaha.py b/yamahaavr/integrationpluginyamaha.py new file mode 100644 index 00000000..f3685ead --- /dev/null +++ b/yamahaavr/integrationpluginyamaha.py @@ -0,0 +1,1382 @@ +import nymea +import time +import threading +import json +import requests +import random +from xml.sax.saxutils import unescape +from zeroconf import IPVersion, ServiceBrowser, ServiceInfo, Zeroconf +from typing import Callable, List + +class ZeroconfDevice(object): + # To do: replace with nymea serviceBrowser + def __init__(self, name: str, ip: str, port: int, model: str, id: str) -> None: + self.name = name + self.ip = ip + self.port = port + self.model = model + self.id = id + + def __repr__(self) -> str: + return f"{type(self).__name__}({self.__dict__})" + + def __eq__(self, other) -> bool: + return self is other or self.__dict__ == other.__dict__ + +class ZeroconfListener(object): + # To do: replace with nymea serviceBrowser + """Basic zeroconf listener.""" + + def __init__(self, func: Callable[[ServiceInfo], None]) -> None: + """Initialize zeroconf listener with function callback.""" + self._func = func + + def __repr__(self) -> str: + return f"{type(self).__name__}({self.__dict__})" + + def __eq__(self, other) -> bool: + return self is other or self.__dict__ == other.__dict__ + + def add_service(self, zeroconf: Zeroconf, type: str, name: str) -> None: + """Callback function when zeroconf service is discovered.""" + self._func(zeroconf.get_service_info(type, name)) + + def update_service(self, zeroconf: Zeroconf, type: str, name: str) -> None: + return + + def remove_service(self, zeroconf: Zeroconf, type: str, name: str) -> None: + return + +pollTimer = None + +playPoll = False + +def discoverThings(info): + if info.thingClassId == receiverThingClassId: + logger.log("Discovery started for", info.thingClassId) + discoveredIps = findIps() + + for i in range(0, len(discoveredIps)): + deviceIp = discoveredIps[i] + rUrl = 'http://' + deviceIp + ':80/YamahaRemoteControl/ctrl' + body = 'GetParam' + headers = {'Content-Type': 'text/xml', 'Accept': '*/*'} + rr = requests.post(rUrl, headers=headers, data=body) + pollResponse = rr.text + if rr.status_code == requests.codes.ok: + logger.log("Device with IP " + deviceIp + " is a supported Yamaha AVR.") + # get device info + stringIndex1 = pollResponse.find("") + stringIndex2 = pollResponse.find("") + responseExtract = pollResponse[stringIndex1+11:stringIndex2] + systemId = responseExtract + logger.log("System ID:", systemId) + stringIndex1 = pollResponse.find("") + stringIndex2 = pollResponse.find("") + responseExtract = pollResponse[stringIndex1+12:stringIndex2] + modelType = "Yamaha " + responseExtract + # check if device already known + exists = False + for thing in myThings(): + logger.log("Comparing to existing receivers: is %s a receiver?" % (thing.name)) + if thing.thingClassId == receiverThingClassId: + logger.log("Yes, %s is a receiver." % (thing.name)) + if thing.paramValue(receiverThingSerialParamTypeId) == systemId: + logger.log("Already have receiver with serial number %s in the system: %s" % (systemId, thing.name)) + exists = True + else: + logger.log("Thing %s doesn't match with found receiver with serial number %s" % (thing.name, systemId)) + if exists == False: # Receiver doesn't exist yet, so add it + thingDescriptor = nymea.ThingDescriptor(receiverThingClassId, modelType) + thingDescriptor.params = [ + nymea.Param(receiverThingSerialParamTypeId, systemId) + ] + info.addDescriptor(thingDescriptor) + else: # Receiver already exists, so show it to allow reconfiguration + thingDescriptor = nymea.ThingDescriptor(receiverThingClassId, modelType, thingId=thing.id) + thingDescriptor.params = [ + nymea.Param(receiverThingSerialParamTypeId, systemId) + ] + info.addDescriptor(thingDescriptor) + else: + logger.log("Device with IP " + deviceIp + " does not appear to be a supported Yamaha AVR.") + info.finish(nymea.ThingErrorNoError) + return + + if info.thingClassId == zoneThingClassId: + logger.log("Discovery started for", info.thingClassId) + + for possibleReceiver in myThings(): + logger.log("Looking for existing receivers to add zones: is %s a receiver?" % (possibleReceiver.name)) + if possibleReceiver.thingClassId == receiverThingClassId: + receiver = possibleReceiver + deviceIp = receiver.stateValue(receiverUrlStateTypeId) + logger.log("Yes, %s with IP address %s is a receiver, looking for zones." % (receiver.name, deviceIp)) + rUrl = 'http://' + deviceIp + ':80/YamahaRemoteControl/ctrl' + body = 'GetParam' + headers = {'Content-Type': 'text/xml', 'Accept': '*/*'} + rr = requests.post(rUrl, headers=headers, data=body) + pollResponse = rr.text + possibleZones = list(("Zone_2", "Zone_3", "Zone_4")) + + for zone in possibleZones: + stringIndex1 = pollResponse.find("<" + zone + ">") + stringIndex2 = pollResponse.find("") + zoneFound = int(pollResponse[stringIndex1+8:stringIndex2]) + zoneNbr = int(zone[5:6]) + stringIndex1 = pollResponse.find("") + stringIndex2 = pollResponse.find("") + systemId = pollResponse[stringIndex1+11:stringIndex2] + if zoneFound == 1: + logger.log("Additional zone with number %s found." % (str(zoneNbr))) + # test if zone already exists + exists = False + for possibleZone in myThings(): + logger.log("Comparing to existing zones: is %s a zone?" % (possibleZone.name)) + if possibleZone.thingClassId == zoneThingClassId: + zone = possibleZone + logger.log("Yes, %s is a zone." % (possibleZone.name)) + if zone.paramValue(zoneThingSerialParamTypeId) == systemId and zone.paramValue(zoneThingZoneIdParamTypeId) == zoneNbr: + logger.log("Already have zone with number %s in the system" % (str(zoneNbr))) + exists = True + else: + logger.log("Thing %s doesn't match with found zone with number %s" % (possibleZone.name, str(zoneNbr))) + elif possibleZone.thingClassId == receiverThingClassId: + logger.log("Yes, %s is a main zone." % (possibleZone.name)) + else: + logger.log("No, %s is not a zone." % (possibleZone.name)) + if exists == False: # Zone doesn't exist yet, so add it + zoneName = receiver.name + " Zone " + str(zoneNbr) + logger.log("Found new additional zone:", zone, zoneNbr) + logger.log("Adding %s to the system with parent:" % (zoneName), receiver.name, receiver.id) + thingDescriptor = nymea.ThingDescriptor(zoneThingClassId, zoneName, parentId=receiver.id) + thingDescriptor.params = [ + nymea.Param(zoneThingSerialParamTypeId, systemId), + nymea.Param(zoneThingZoneIdParamTypeId, zoneNbr) + ] + info.addDescriptor(thingDescriptor) + else: # Zone already exists, so show it to allow reconfiguration + zoneName = receiver.name + " Zone " + str(zoneNbr) + thingDescriptor = nymea.ThingDescriptor(zoneThingClassId, zoneName, thingId=zone.id, parentId=receiver.id) + thingDescriptor.params = [ + nymea.Param(zoneThingSerialParamTypeId, systemId), + nymea.Param(zoneThingZoneIdParamTypeId, zoneNbr) + ] + info.addDescriptor(thingDescriptor) + info.finish(nymea.ThingErrorNoError) + return + +def findIps(): + # To do: in future use nymea capabilities: + # no need of any external libraries, you can just call "serviceBrowser = hardwareManager.zeroconf.registerServiceBrowser()" + # and can then loop over "serviceBrowser.entries"# serviceBrowser = hardwareManager.zeroconf.registerServiceBrowser() + # for i in range(0, len(serviceBrowser.entries)): + # logger.log(serviceBrowser.entries[i]) + + # foreach (const ZeroConfServiceEntry &entry, m_serviceBrowser->serviceEntries()) { + # if (entry.hostAddress().protocol() == QAbstractSocket::IPv6Protocol && entry.hostAddress().toString().startsWith("fe80")) { + # // We don't support link-local ipv6 addresses yet. skip those entries + # continue; + # } + # QString uuid; + # foreach (const QString &txt, entry.txt()) { + # if (txt.startsWith("uuid")) { + # uuid = txt.split("=").last(); + # break; + # } + # } + # if (QUuid(uuid) == kodiUuid) { + # ipString = entry.hostAddress().toString(); + # port = entry.port(); + # break; + # } + # } + # for now we use zeroconf (def discover & classes ZeroconfDevice & ZeroconfListener) as borrowed from pyvizio + + ipList = discover("_http._tcp.local.", 5) + logger.log(ipList) + + discoveredIps = [] + for i in range(0, len(ipList)): + deviceInfo = ipList[i] + if "Yamaha" in deviceInfo.name: + discoveredIps.append(deviceInfo.ip) + return discoveredIps + +def discover(service_type: str, timeout: int = 5) -> List[ZeroconfDevice]: + # To do: replace with nymea serviceBrowser + """From pyvizio: Return all discovered zeroconf services of a given service type over given timeout period.""" + services = [] + + def append_service(info: ServiceInfo) -> None: + """Append discovered zeroconf service to service list.""" + name = info.name[: -(len(info.type) + 1)] + ip = info.parsed_addresses(IPVersion.V4Only)[0] + port = info.port + model = info.properties.get(b"name", "") + id = info.properties.get(b"id") + + # handle id decode for various discovered use cases + if isinstance(id, bytes): + try: + int(id, 16) + except Exception: + id = id.hex() + else: + id = None + + service = ZeroconfDevice(name, ip, port, model, id) + services.append(service) + + zeroconf = Zeroconf() + ServiceBrowser(zeroconf, service_type, ZeroconfListener(append_service)) + time.sleep(timeout) + zeroconf.close() + + return services + +def setupThing(info): + if info.thing.thingClassId == receiverThingClassId: + searchSystemId = info.thing.paramValue(receiverThingSerialParamTypeId) + logger.log("setupThing called for", info.thing.name, searchSystemId) + + discoveredIps = findIps() + found = False + + for i in range(0, len(discoveredIps)): + deviceIp = discoveredIps[i] + rUrl = 'http://' + deviceIp + ':80/YamahaRemoteControl/ctrl' + body = 'GetParam' + headers = {'Content-Type': 'text/xml', 'Accept': '*/*'} + rr = requests.post(rUrl, headers=headers, data=body) + pollResponse = rr.text + if rr.status_code == requests.codes.ok: + logger.log("Device with IP " + deviceIp + " is a supported Yamaha AVR.") + # get device info + stringIndex1 = pollResponse.find("") + stringIndex2 = pollResponse.find("") + responseExtract = pollResponse[stringIndex1+11:stringIndex2] + systemId = responseExtract + logger.log("System ID:", systemId) + # check if this is the device with the serial number we're looking for + if systemId == searchSystemId: + logger.log("Device with IP " + deviceIp + " is the existing device.") + found = True + info.thing.setStateValue(receiverUrlStateTypeId, deviceIp) + rr2 = rr + else: + logger.log("Device with IP " + deviceIp + " does not appear to be a supported Yamaha AVR.") + if found == True: + info.thing.setStateValue(receiverConnectedStateTypeId, True) + pollReceiver(info.thing) + info.finish(nymea.ThingErrorNoError) + else: + info.thing.setStateValue(receiverConnectedStateTypeId, False) + info.finish(nymea.ThingErrorHardwareFailure, "Error connecting to the device in the network.") + + logger.log("Receiver added:", info.thing.name) + + # If no poll timer is set up yet, start it now + logger.log("Creating pollService") + global pollTimer + if pollTimer == None: + logger.log("Starting timer @ setupThing") + pollTimer = threading.Timer(30, pollService) + pollTimer.start() + else: + logger.log("Timer already exists @ setupThing") + + info.finish(nymea.ThingErrorNoError) + return + + # Setup for the zone + if info.thing.thingClassId == zoneThingClassId: + logger.log("SetupThing for zone:", info.thing.name) + # get parent receiver thing, needed to get deviceIp + for possibleParent in myThings(): + if possibleParent.id == info.thing.parentId: + parentReceiver = possibleParent + deviceIp = parentReceiver.stateValue(receiverUrlStateTypeId) + zoneId = info.thing.paramValue(zoneThingZoneIdParamTypeId) + zone = "Zone_" + str(zoneId) + try: + pollReceiver(info.thing) + logger.log(zone + " added.") + info.thing.setStateValue(zoneConnectedStateTypeId, True) + except: + logger.warn("Error getting zone state"); + info.finish(nymea.ThingErrorHardwareFailure, "Unable to set up zone.") + info.thing.setStateValue(zoneConnectedStateTypeId, False) + return; + + # set up polling for zone status + info.finish(nymea.ThingErrorNoError) + return + +def pollReceiver(info): + global playPoll + if info.thingClassId == zoneThingClassId: + # get parent receiver thing, needed to get deviceIp + for possibleParent in myThings(): + if possibleParent.id == info.parentId: + parentReceiver = possibleParent + deviceIp = parentReceiver.stateValue(receiverUrlStateTypeId) + zoneId = info.paramValue(zoneThingZoneIdParamTypeId) + logger.log("polling zone", deviceIp, info.name) + bodyStart = '' + bodyEnd = '' + elif info.thingClassId == receiverThingClassId: + deviceIp = info.stateValue(receiverUrlStateTypeId) + logger.log("polling receiver", deviceIp, info.name) + bodyStart = '' + bodyEnd = '' + rUrl = 'http://' + deviceIp + ':80/YamahaRemoteControl/ctrl' + body = bodyStart + 'GetParam' + bodyEnd + headers = {'Content-Type': 'text/xml', 'Accept': '*/*'} + pr = requests.post(rUrl, headers=headers, data=body) + pollResponse = pr.text + # add distinction between receiver & zone + if info.thingClassId == receiverThingClassId: + receiver = info + if pr.status_code == requests.codes.ok: + receiver.setStateValue(receiverConnectedStateTypeId, True) + # Get power state + if pollResponse.find("Standby") != -1: + receiver.setStateValue(receiverPowerStateTypeId, False) + powerState = False + elif pollResponse.find("On") != -1: + receiver.setStateValue(receiverPowerStateTypeId, True) + powerState = True + else: + logger.log("Power state not found!") + # Get sleep state + stringIndex1 = pollResponse.find("") + stringIndex2 = pollResponse.find("") + responseExtract = pollResponse[stringIndex1+7:stringIndex2] + receiver.setStateValue(receiverSleepStateTypeId, responseExtract) + # Get mute state + if pollResponse.find("Off") != -1: + receiver.setStateValue(receiverMuteStateTypeId, False) + elif pollResponse.find("On") != -1: + receiver.setStateValue(receiverMuteStateTypeId, True) + else: + logger.log("Mute state not found!") + # Get pure direct state + if pollResponse.find("Off") != -1: + receiver.setStateValue(receiverPureDirectStateTypeId, False) + elif pollResponse.find("On") != -1: + receiver.setStateValue(receiverPureDirectStateTypeId, True) + else: + logger.log("Pure Direct state not found!") + # Get enhancer state + if pollResponse.find("Off") != -1: + receiver.setStateValue(receiverEnhancerStateTypeId, False) + elif pollResponse.find("On") != -1: + receiver.setStateValue(receiverEnhancerStateTypeId, True) + else: + logger.log("Enhancer state not found!") + # Get input + stringIndex1 = pollResponse.find("") + stringIndex2 = pollResponse.find("") + inputSource = pollResponse[stringIndex1+18:stringIndex2] + receiver.setStateValue(receiverInputSourceStateTypeId, inputSource) + videoSources = ["HDMI1","HDMI2","HDMI3","HDMI4","HDMI5","AV1","AV2","AV3","AV4","AV5","AV6","V-AUX"] + if inputSource in videoSources: + receiver.setStateValue(receiverPlayerTypeStateTypeId, "video") + else: + receiver.setStateValue(receiverPlayerTypeStateTypeId, "audio") + # Get sound program + stringIndex1 = pollResponse.find("") + stringIndex2 = pollResponse.find("") + responseExtract = pollResponse[stringIndex1+15:stringIndex2] + receiver.setStateValue(receiverSurroundModeStateTypeId, responseExtract) + # Get Cinema DSP 3D state + stringIndex1 = pollResponse.find("<_3D_Cinema_DSP>") + stringIndex2 = pollResponse.find("") + responseExtract = pollResponse[stringIndex1+16:stringIndex2] + receiver.setStateValue(receiverCinemaDSP3DStateTypeId, responseExtract) + # Get Adaptive DRC state + stringIndex1 = pollResponse.find("") + stringIndex2 = pollResponse.find("") + responseExtract = pollResponse[stringIndex1+14:stringIndex2] + receiver.setStateValue(receiverAdaptiveDRCStateTypeId, responseExtract) + # Get volume - volume is represented by int in Yamaha API, but shown as double = int/10 in Yamaha UI - this is ignored here as nymea wants volume to be an int + stringIndex1 = pollResponse.find("") + responseExtract = pollResponse[stringIndex1+18:stringIndex1+30] + stringIndex2 = responseExtract.find("") + responseExtract = responseExtract[0:stringIndex2] + volume = int(responseExtract) + receiver.setStateValue(receiverVolumeStateTypeId, volume) + # Get bass + stringIndex1 = pollResponse.find("") + responseExtract = pollResponse[stringIndex1+11:stringIndex1+30] + stringIndex2 = responseExtract.find("") + responseExtract = responseExtract[0:stringIndex2] + bass = int(responseExtract) + receiver.setStateValue(receiverBassStateTypeId, bass) + # Get treble + stringIndex1 = pollResponse.find("") + responseExtract = pollResponse[stringIndex1+13:stringIndex1+30] + stringIndex2 = responseExtract.find("") + responseExtract = responseExtract[0:stringIndex2] + treble = int(responseExtract) + receiver.setStateValue(receiverTrebleStateTypeId, treble) + # Get dialogue level + stringIndex1 = pollResponse.find("") + stringIndex2 = pollResponse.find("") + responseExtract = pollResponse[stringIndex1+14:stringIndex2] + dialogueLvl = int(responseExtract) + receiver.setStateValue(receiverDialogueLevelStateTypeId, dialogueLvl) + # Get dialogue lift + stringIndex1 = pollResponse.find("") + stringIndex2 = pollResponse.find("") + responseExtract = pollResponse[stringIndex1+15:stringIndex2] + dialogueLift = int(responseExtract) + receiver.setStateValue(receiverDialogueLiftStateTypeId, dialogueLift) + # Get subwoofer trim + stringIndex1 = pollResponse.find("") + responseExtract = pollResponse[stringIndex1+21:stringIndex1+30] + stringIndex2 = responseExtract.find("") + responseExtract = responseExtract[0:stringIndex2] + subTrim = int(responseExtract) + receiver.setStateValue(receiverSubwooferTrimStateTypeId, subTrim) + # Get player info + body = '<' + inputSource + '>GetParam' + headers = {'Content-Type': 'text/xml', 'Accept': '*/*'} + plr = requests.post(rUrl, headers=headers, data=body) + if plr.status_code == requests.codes.ok and powerState == True: + playerResponse = plr.text + # Get repeat state + stringIndex1 = playerResponse.find("") + stringIndex2 = playerResponse.find("") + responseExtract = playerResponse[stringIndex1+8:stringIndex2] + if responseExtract not in ["None", "One", "All"]: + responseExtract = "None" + receiver.setStateValue(receiverRepeatStateTypeId, responseExtract) + # Get shuffle state + stringIndex1 = playerResponse.find("") + stringIndex2 = playerResponse.find("") + responseExtract = playerResponse[stringIndex1+9:stringIndex2] + if responseExtract == "On": + shuffleStatus = True + else: + shuffleStatus = False + receiver.setStateValue(receiverShuffleStateTypeId, shuffleStatus) + # Get playback state + stringIndex1 = playerResponse.find("") + stringIndex2 = playerResponse.find("") + responseExtract = playerResponse[stringIndex1+15:stringIndex2] + if responseExtract == "Play": + playStatus = "Playing" + playPoll = True or playPoll + elif responseExtract == "Pause": + playStatus = "Paused" + playPoll = True or playPoll + else: + playStatus = "Stopped" + playPoll = False or playPoll + receiver.setStateValue(receiverPlaybackStatusStateTypeId, playStatus) + # Get meta info + stringIndex1 = playerResponse.find("") + stringIndex2 = playerResponse.find("") + responseExtract = playerResponse[stringIndex1+8:stringIndex2] + receiver.setStateValue(receiverArtistStateTypeId, unescape(responseExtract, {"&": "&", "'": "'", """: '"'})) + stringIndex1 = playerResponse.find("") + stringIndex2 = playerResponse.find("") + responseExtract = playerResponse[stringIndex1+7:stringIndex2] + receiver.setStateValue(receiverCollectionStateTypeId, unescape(responseExtract, {"&": "&", "'": "'", """: '"'})) + stringIndex1 = playerResponse.find("") + stringIndex2 = playerResponse.find("") + responseExtract = playerResponse[stringIndex1+6:stringIndex2] + receiver.setStateValue(receiverTitleStateTypeId, unescape(responseExtract, {"&": "&", "'": "'", """: '"'})) + # Get artwork --> Yamaha artwork file type isn't recognized by nymea: browse for external cover art? + stringIndex1 = playerResponse.find("") + stringIndex2 = playerResponse.find("") + responseExtract = playerResponse[stringIndex1+5:stringIndex2] + artURL = 'http://' + deviceIp + ':80' + responseExtract + receiver.setStateValue(receiverArtworkStateTypeId, artURL) + else: + # Playing from external source so no info available + receiver.setStateValue(receiverRepeatStateTypeId, "None") + receiver.setStateValue(receiverShuffleStateTypeId, False) + receiver.setStateValue(receiverPlaybackStatusStateTypeId, "Stopped") + receiver.setStateValue(receiverArtistStateTypeId, "") + receiver.setStateValue(receiverCollectionStateTypeId, "") + receiver.setStateValue(receiverTitleStateTypeId, "") + receiver.setStateValue(receiverArtworkStateTypeId, "") + else: + receiver.setStateValue(receiverConnectedStateTypeId, False) + elif info.thingClassId == zoneThingClassId: + zone = info + if pr.status_code == requests.codes.ok: + zone.setStateValue(zoneConnectedStateTypeId, True) + # Get power state + if pollResponse.find("Standby") != -1: + zone.setStateValue(zonePowerStateTypeId, False) + powerState = False + elif pollResponse.find("On") != -1: + zone.setStateValue(zonePowerStateTypeId, True) + powerState = True + else: + logger.log("Power state not found!") + # Get sleep state + stringIndex1 = pollResponse.find("") + stringIndex2 = pollResponse.find("") + responseExtract = pollResponse[stringIndex1+7:stringIndex2] + zone.setStateValue(zoneSleepStateTypeId, responseExtract) + # Get mute state + if pollResponse.find("Off") != -1: + zone.setStateValue(zoneMuteStateTypeId, False) + elif pollResponse.find("On") != -1: + zone.setStateValue(zoneMuteStateTypeId, True) + else: + logger.log("Mute state not found!") + # Get input + stringIndex1 = pollResponse.find("") + stringIndex2 = pollResponse.find("") + inputSource = pollResponse[stringIndex1+18:stringIndex2] + zone.setStateValue(zoneInputSourceStateTypeId, inputSource) + videoSources = ["HDMI1","HDMI2","HDMI3","HDMI4","HDMI5","AV1","AV2","AV3","AV4","AV5","AV6","V-AUX"] + if inputSource in videoSources: + zone.setStateValue(zonePlayerTypeStateTypeId, "video") + else: + zone.setStateValue(zonePlayerTypeStateTypeId, "audio") + # Get volume + stringIndex1 = pollResponse.find("") + responseExtract = pollResponse[stringIndex1+18:stringIndex1+30] + stringIndex2 = responseExtract.find("") + responseExtract = responseExtract[0:stringIndex2] + volume = int(responseExtract) + zone.setStateValue(zoneVolumeStateTypeId, volume) + # Get player info + body = '<' + inputSource + '>GetParam' + headers = {'Content-Type': 'text/xml', 'Accept': '*/*'} + plr = requests.post(rUrl, headers=headers, data=body) + if plr.status_code == requests.codes.ok and powerState == True: + playerResponse = plr.text + # Get repeat state + stringIndex1 = playerResponse.find("") + stringIndex2 = playerResponse.find("") + responseExtract = playerResponse[stringIndex1+8:stringIndex2] + if responseExtract not in ["None", "One", "All"]: + responseExtract = "None" + zone.setStateValue(zoneRepeatStateTypeId, responseExtract) + # Get shuffle state + stringIndex1 = playerResponse.find("") + stringIndex2 = playerResponse.find("") + responseExtract = playerResponse[stringIndex1+9:stringIndex2] + if responseExtract == "On": + shuffleStatus = True + else: + shuffleStatus = False + zone.setStateValue(zoneShuffleStateTypeId, shuffleStatus) + # Get playback state + stringIndex1 = playerResponse.find("") + stringIndex2 = playerResponse.find("") + responseExtract = playerResponse[stringIndex1+15:stringIndex2] + if responseExtract == "Play": + playStatus = "Playing" + playPoll = True or playPoll + elif responseExtract == "Pause": + playStatus = "Paused" + playPoll = True or playPoll + else: + playStatus = "Stopped" + playPoll = False or playPoll + zone.setStateValue(zonePlaybackStatusStateTypeId, playStatus) + # Get meta info + stringIndex1 = playerResponse.find("") + stringIndex2 = playerResponse.find("") + responseExtract = playerResponse[stringIndex1+8:stringIndex2] + zone.setStateValue(zoneArtistStateTypeId, responseExtract) + stringIndex1 = playerResponse.find("") + stringIndex2 = playerResponse.find("") + responseExtract = playerResponse[stringIndex1+7:stringIndex2] + zone.setStateValue(zoneCollectionStateTypeId, responseExtract) + stringIndex1 = playerResponse.find("") + stringIndex2 = playerResponse.find("") + responseExtract = playerResponse[stringIndex1+6:stringIndex2] + zone.setStateValue(zoneTitleStateTypeId, responseExtract) + stringIndex1 = playerResponse.find("") + stringIndex2 = playerResponse.find("") + responseExtract = playerResponse[stringIndex1+5:stringIndex2] + artURL = 'http://' + deviceIp + ':80' + responseExtract + zone.setStateValue(zoneArtworkStateTypeId, artURL) + else: + # Playing from external source so no info available + zone.setStateValue(zoneRepeatStateTypeId, "None") + zone.setStateValue(zoneShuffleStateTypeId, False) + zone.setStateValue(zonePlaybackStatusStateTypeId, "Stopped") + zone.setStateValue(zoneArtistStateTypeId, "") + zone.setStateValue(zoneCollectionStateTypeId, "") + zone.setStateValue(zoneTitleStateTypeId, "") + zone.setStateValue(zoneArtworkStateTypeId, "") + else: + zone.setStateValue(zoneConnectedStateTypeId, False) + +def pollService(): + logger.log("pollService!!!") + global pollTimer + global playPoll + # restart the timer for next poll (if player was playing at previous poll, increase poll frequency) + # we start the timer before polling the receivers to test if this avoids the timer not restarting due to request errors in pollReceiver + if playPoll == True: + interval = 10 + else: + interval = 30 + logger.log("Restarting timer @ pollService") + pollTimer = threading.Timer(interval, pollService) + pollTimer.start() + playPoll = False + # Poll all receivers we know + for thing in myThings(): + if thing.thingClassId == receiverThingClassId or thing.thingClassId == zoneThingClassId: + pollReceiver(thing) + +def executeAction(info): + pollReceiver(info.thing) + if info.thing.thingClassId == zoneThingClassId: + # get parent receiver thing, needed to get deviceIp + for possibleParent in myThings(): + if possibleParent.id == info.thing.parentId: + parentReceiver = possibleParent + deviceIp = parentReceiver.stateValue(receiverUrlStateTypeId) + zoneId = info.thing.paramValue(zoneThingZoneIdParamTypeId) + bodyStart = '' + bodyEnd = '' + source = info.thing.stateValue(zoneInputSourceStateTypeId) + powerCheck = info.thing.stateValue(zonePowerStateTypeId) + elif info.thing.thingClassId == receiverThingClassId: + deviceIp = info.thing.stateValue(receiverUrlStateTypeId) + bodyStart = '' + bodyEnd = '' + source = info.thing.stateValue(receiverInputSourceStateTypeId) + powerCheck = info.thing.stateValue(receiverPowerStateTypeId) + + logger.log("executeAction called for thing", info.thing.name, deviceIp, source, info.actionTypeId, info.params) + rUrl = 'http://' + deviceIp + ':80/YamahaRemoteControl/ctrl' + headers = {'Content-Type': 'text/xml', 'Accept': '*/*'} + + # turn receiver/zone on if needed, before executing the action + if powerCheck == False and info.actionTypeId != receiverPowerActionTypeId and info.actionTypeId != zonePowerActionTypeId: + body = bodyStart + 'On' + bodyEnd + rr = requests.post(rUrl, headers=headers, data=body) + + if info.actionTypeId == receiverIncreaseVolumeActionTypeId or info.actionTypeId == zoneIncreaseVolumeActionTypeId: + if info.actionTypeId == receiverIncreaseVolumeActionTypeId: + stepsize = info.paramValue(receiverIncreaseVolumeActionStepParamTypeId) + else: + stepsize = info.paramValue(zoneIncreaseVolumeActionStepParamTypeId) + volumeDelta = stepsize * 10 + while abs(volumeDelta) >= 5: + if volumeDelta >= 50: + step = "Up 5 dB" + volumeDelta -= 50 + elif volumeDelta >= 10: + step = "Up 1 dB" + volumeDelta -= 10 + elif volumeDelta >= 5: + step = "Up" + volumeDelta -= 5 + else: + break + body = bodyStart + '' + step + '' + bodyEnd + pr = requests.post(rUrl, headers=headers, data=body) + time.sleep(0.5) + pollReceiver(info.thing) + info.finish(nymea.ThingErrorNoError) + return + elif info.actionTypeId == receiverDecreaseVolumeActionTypeId or info.actionTypeId == zoneDecreaseVolumeActionTypeId: + if info.actionTypeId == receiverDecreaseVolumeActionTypeId: + stepsize = info.paramValue(receiverDecreaseVolumeActionStepParamTypeId) + else: + stepsize = info.paramValue(zoneDecreaseVolumeActionStepParamTypeId) + volumeDelta = stepsize * -10 + while abs(volumeDelta) >= 5: + if volumeDelta <= -50: + step = "Down 5 dB" + volumeDelta += 50 + elif volumeDelta <= -10: + step = "Down 1 dB" + volumeDelta += 10 + elif volumeDelta <= -5: + step = "Down" + volumeDelta += 5 + else: + break + body = bodyStart + '' + step + '' + bodyEnd + pr = requests.post(rUrl, headers=headers, data=body) + time.sleep(0.5) + pollReceiver(info.thing) + info.finish(nymea.ThingErrorNoError) + return + elif info.actionTypeId == receiverSkipBackActionTypeId or info.actionTypeId == zoneSkipBackActionTypeId: + body = '<' + source + '>Skip Rev' + rr = requests.post(rUrl, headers=headers, data=body) + time.sleep(0.5) + pollReceiver(info.thing) + # AirPlay statusupdates appear to take a while longer to be available in API + if source == "AirPlay": + time.sleep(6) + pollReceiver(info.thing) + info.finish(nymea.ThingErrorNoError) + return + elif info.actionTypeId == receiverStopActionTypeId or info.actionTypeId == zoneStopActionTypeId: + body = '<' + source + '>Stop' + rr = requests.post(rUrl, headers=headers, data=body) + time.sleep(0.5) + pollReceiver(info.thing) + if source == "AirPlay": + time.sleep(6) + pollReceiver(info.thing) + info.finish(nymea.ThingErrorNoError) + return + elif info.actionTypeId == receiverPlayActionTypeId or info.actionTypeId == zonePlayActionTypeId: + body = '<' + source + '>Play' + rr = requests.post(rUrl, headers=headers, data=body) + time.sleep(0.5) + pollReceiver(info.thing) + if source == "AirPlay": + time.sleep(6) + pollReceiver(info.thing) + info.finish(nymea.ThingErrorNoError) + return + elif info.actionTypeId == receiverPauseActionTypeId or info.actionTypeId == zonePauseActionTypeId: + body = '<' + source + '>Pause' + rr = requests.post(rUrl, headers=headers, data=body) + time.sleep(0.5) + pollReceiver(info.thing) + if source == "AirPlay": + time.sleep(6) + pollReceiver(info.thing) + info.finish(nymea.ThingErrorNoError) + return + elif info.actionTypeId == receiverSkipNextActionTypeId or info.actionTypeId == zoneSkipNextActionTypeId: + body = '<' + source + '>Skip Fwd' + rr = requests.post(rUrl, headers=headers, data=body) + time.sleep(0.5) + pollReceiver(info.thing) + if source == "AirPlay": + time.sleep(6) + pollReceiver(info.thing) + info.finish(nymea.ThingErrorNoError) + return + elif info.actionTypeId == receiverPowerActionTypeId or info.actionTypeId == zonePowerActionTypeId: + if info.actionTypeId == receiverPowerActionTypeId: + power = info.paramValue(receiverPowerActionPowerParamTypeId) + else: + power = info.paramValue(zonePowerActionPowerParamTypeId) + if power == True: + powerString = "On" + else: + powerString = "Standby" + body = bodyStart + '' + powerString + '' + bodyEnd + rr = requests.post(rUrl, headers=headers, data=body) + time.sleep(0.5) + pollReceiver(info.thing) + info.finish(nymea.ThingErrorNoError) + return + elif info.actionTypeId == receiverSleepActionTypeId or info.actionTypeId == zoneSleepActionTypeId: + if info.actionTypeId == receiverSleepActionTypeId: + sleepString = info.paramValue(receiverSleepActionSleepParamTypeId) + else: + sleepString = info.paramValue(zoneSleepActionSleepParamTypeId) + body = bodyStart + '' + sleepString + '' + bodyEnd + rr = requests.post(rUrl, headers=headers, data=body) + time.sleep(0.5) + pollReceiver(info.thing) + info.finish(nymea.ThingErrorNoError) + return + elif info.actionTypeId == receiverMuteActionTypeId or info.actionTypeId == zoneMuteActionTypeId: + if info.actionTypeId == receiverMuteActionTypeId: + mute = info.paramValue(receiverMuteActionMuteParamTypeId) + else: + mute = info.paramValue(zoneMuteActionMuteParamTypeId) + if mute == True: + muteString = "On" + else: + muteString = "Off" + body = bodyStart + '' + muteString + '' + bodyEnd + rr = requests.post(rUrl, headers=headers, data=body) + time.sleep(0.5) + pollReceiver(info.thing) + info.finish(nymea.ThingErrorNoError) + return + elif info.actionTypeId == receiverVolumeActionTypeId or info.actionTypeId == zoneVolumeActionTypeId: + if info.actionTypeId == receiverVolumeActionTypeId: + newVolume = info.paramValue(receiverVolumeActionVolumeParamTypeId) + else: + newVolume = info.paramValue(zoneVolumeActionVolumeParamTypeId) + # volume needs to be multiple of 5 + remainder = newVolume % 5 + newVolume -= remainder + volumeString = str(newVolume) + logger.log("Volume set to", newVolume) + body = bodyStart + '' + volumeString + '1dB' + bodyEnd + pr = requests.post(rUrl, headers=headers, data=body) + time.sleep(0.5) + pollReceiver(info.thing) + info.finish(nymea.ThingErrorNoError) + return + elif info.actionTypeId == receiverSubwooferTrimActionTypeId: + newTrim = info.paramValue(receiverSubwooferTrimActionSubwooferTrimParamTypeId) + # trim needs to be multiple of 5 + remainder = newTrim % 5 + newTrim -= remainder + trimString = str(newTrim) + logger.log("Subwoofer trim set to", newTrim) + body = bodyStart + '' + trimString + '1dB' + bodyEnd + pr = requests.post(rUrl, headers=headers, data=body) + time.sleep(0.5) + pollReceiver(info.thing) + info.finish(nymea.ThingErrorNoError) + return + elif info.actionTypeId == receiverPureDirectActionTypeId: + pureDirect = info.paramValue(receiverPureDirectActionPureDirectParamTypeId) + if pureDirect == True: + PureDirectString = "On" + else: + PureDirectString = "Off" + body = bodyStart + '' + PureDirectString + '' + bodyEnd + rr = requests.post(rUrl, headers=headers, data=body) + time.sleep(0.5) + pollReceiver(info.thing) + info.finish(nymea.ThingErrorNoError) + return + elif info.actionTypeId == receiverEnhancerActionTypeId: + enhancer = info.paramValue(receiverEnhancerActionEnhancerParamTypeId) + if enhancer == True: + enhancerString = "On" + else: + enhancerString = "Off" + body = bodyStart + '' + enhancerString + '' + bodyEnd + rr = requests.post(rUrl, headers=headers, data=body) + time.sleep(0.5) + pollReceiver(info.thing) + info.finish(nymea.ThingErrorNoError) + return + elif info.actionTypeId == receiverDialogueLevelActionTypeId: + diaLvl = info.paramValue(receiverDialogueLevelActionDialogueLevelParamTypeId) + diaStr = str(diaLvl) + body = bodyStart + '' + diaStr + '' + bodyEnd + rr = requests.post(rUrl, headers=headers, data=body) + time.sleep(0.5) + pollReceiver(info.thing) + info.finish(nymea.ThingErrorNoError) + return + elif info.actionTypeId == receiverDialogueLiftActionTypeId: + diaLift = info.paramValue(receiverDialogueLiftActionDialogueLiftParamTypeId) + diaStr = str(diaLift) + body = bodyStart + '' + diaStr + '' + bodyEnd + rr = requests.post(rUrl, headers=headers, data=body) + time.sleep(0.5) + pollReceiver(info.thing) + info.finish(nymea.ThingErrorNoError) + return + elif info.actionTypeId == receiverBassActionTypeId: + bass = info.paramValue(receiverBassActionBassParamTypeId) + # bass needs to be multiple of 5 + remainder = bass % 5 + bass -= remainder + bassStr = str(bass) + logger.log("Bass set to", bassStr) + body = bodyStart + '' + bassStr + '1dB' + bodyEnd + rr = requests.post(rUrl, headers=headers, data=body) + time.sleep(0.5) + pollReceiver(info.thing) + info.finish(nymea.ThingErrorNoError) + return + elif info.actionTypeId == receiverTrebleActionTypeId: + treble = info.paramValue(receiverTrebleActionTrebleParamTypeId) + # treble needs to be multiple of 5 + remainder = treble % 5 + treble -= remainder + trebleStr = str(treble) + logger.log("Treble set to", trebleStr) + body = bodyStart + '' + trebleStr + '1dB' + bodyEnd + rr = requests.post(rUrl, headers=headers, data=body) + time.sleep(0.5) + pollReceiver(info.thing) + info.finish(nymea.ThingErrorNoError) + return + elif info.actionTypeId == receiverCinemaDSP3DActionTypeId: + dsp3D = info.paramValue(receiverCinemaDSP3DActionCinemaDSP3DParamTypeId) + logger.log("Cinema DSP 3D set to", dsp3D) + body = bodyStart + '<_3D_Cinema_DSP>' + dsp3D + '' + bodyEnd + rr = requests.post(rUrl, headers=headers, data=body) + time.sleep(0.5) + pollReceiver(info.thing) + info.finish(nymea.ThingErrorNoError) + return + elif info.actionTypeId == receiverAdaptiveDRCActionTypeId: + adrc = info.paramValue(receiverAdaptiveDRCActionAdaptiveDRCParamTypeId) + logger.log("Adaptive DRC set to", adrc) + body = bodyStart + '' + adrc + '' + bodyEnd + rr = requests.post(rUrl, headers=headers, data=body) + time.sleep(0.5) + pollReceiver(info.thing) + info.finish(nymea.ThingErrorNoError) + return + elif info.actionTypeId == receiverInputSourceActionTypeId or info.actionTypeId == zoneInputSourceActionTypeId: + if info.actionTypeId == receiverInputSourceActionTypeId: + inputSource = info.paramValue(receiverInputSourceActionInputSourceParamTypeId) + else: + inputSource = info.paramValue(zoneInputSourceActionInputSourceParamTypeId) + logger.log("Input Source changed to", inputSource) + body = bodyStart + '' + inputSource + '' + bodyEnd + rr = requests.post(rUrl, headers=headers, data=body) + time.sleep(0.5) + pollReceiver(info.thing) + info.finish(nymea.ThingErrorNoError) + return + elif info.actionTypeId == receiverSurroundModeActionTypeId: + surroundMode = info.paramValue(receiverSurroundModeActionSurroundModeParamTypeId) + logger.log("Surround Mode changed to", surroundMode) + if surroundMode != "Straight": + body = bodyStart + '' + surroundMode + '' + bodyEnd + else: + body = bodyStart + 'On' + bodyEnd + rr = requests.post(rUrl, headers=headers, data=body) + time.sleep(0.5) + pollReceiver(info.thing) + info.finish(nymea.ThingErrorNoError) + return + elif info.actionTypeId == receiverShuffleActionTypeId or info.actionTypeId == zoneShuffleActionTypeId: + if info.actionTypeId == receiverShuffleActionTypeId: + shuffle = info.paramValue(receiverShuffleActionShuffleParamTypeId) + else: + shuffle = info.paramValue(zoneShuffleActionShuffleParamTypeId) + if shuffle == True: + shuffleString = "On" + else: + shuffleString = "Off" + body = '<' + source + '>' + shuffleString + '' + rr = requests.post(rUrl, headers=headers, data=body) + time.sleep(0.5) + pollReceiver(info.thing) + info.finish(nymea.ThingErrorNoError) + return + elif info.actionTypeId == receiverRepeatActionTypeId or info.actionTypeId == zoneRepeatActionTypeId: + if info.actionTypeId == receiverRepeatActionTypeId: + repeat = info.paramValue(receiverRepeatActionRepeatParamTypeId) + else: + repeat = info.paramValue(zoneRepeatActionRepeatParamTypeId) + logger.log("Repeat mode:", repeat) + if repeat == "All": + repeatString = "All" + elif repeat == "One": + repeatString = "One" + else: + repeatString = "Off" + body = '<' + source + '>' + repeatString + '' + rr = requests.post(rUrl, headers=headers, data=body) + time.sleep(0.5) + pollReceiver(info.thing) + info.finish(nymea.ThingErrorNoError) + return + elif info.actionTypeId == receiverRandomAlbumActionTypeId or info.actionTypeId == zoneRandomAlbumActionTypeId: + body = bodyStart + 'SERVER' + bodyEnd + rr = requests.post(rUrl, headers=headers, data=body) + time.sleep(0.5) + playRandomAlbum(rUrl, "SERVER") + time.sleep(0.5) + pollReceiver(info.thing) + info.finish(nymea.ThingErrorNoError) + else: + logger.log("Action not yet implemented for thing") + info.finish(nymea.ThingErrorNoError) + return + +def playRandomAlbum(rUrl, source): + # currently source needs to be SERVER + # To do: add code to filter out unselectable items + if source == "SERVER": + browseTree = ["Random", "Music", "By Album", "Random"] + logger.log("Playing random album on source " + source) + else: + browseTree = [] + logger.log("Source not supported for this action") + # navigate browseTree (first item select random server, then folder "Music", ...) + menuLayer = browseInTree(rUrl, source, browseTree) + # play album by selecting first line --> what if first line is not selectable? filter out non-selectable lines first? + if menuLayer == len(browseTree)+1 and menuLayer > 0: + # don't do anything unless browsing to the required menu item succeeded + selectLine(rUrl, source, 1) + return + +def browseInTree(rUrl, source, browseTree): + menuLayer = 1 + if browseTree == None: + #create empty tree + browseTree = [] + # go up to the main menu level if needed + if len(browseTree) > 0: + selLayer = 1 + browseResponse, menuLayer = browseMenuReady(rUrl, source) + while menuLayer > selLayer: + menuLevelUp(rUrl, source) + browseResponse, menuLayer = browseMenuReady(rUrl, source) + # navigate browseTree + for i in range (0, len(browseTree)): + if browseTree[i] == "Random": + #browseResponse, menuLayer = browseMenuReady(rUrl, source) + currentLine, maxLine = getLineNbrs(browseResponse) + selItem = random.randint(1, maxLine) + selectLine(rUrl, source, selItem) + else: + selItem = findLine(rUrl, source, browseTree[i]) + if selItem > 0: + selectLine(rUrl, source, selItem) + else: + logger.log("Requested item not found") + # set menuLayer in case of error in browsing? + browseResponse, menuLayer = browseMenuReady(rUrl, source) + logger.log("Returning menuLayer", menuLayer) + if menuLayer < len(browseTree)+1: + logger.log("Attention, this isn't the requested menuLayer!") + return menuLayer + +def findLine(rUrl, source, searchTxt): + # browse menu level: keep going through menu pages (of 8 items per page) until lineTxt is found + loop = True + selItem = 0 + gotoLine(rUrl, source, 1) + while loop == True: + browseResponse, menuLayer = browseMenuReady(rUrl, source) + currentLine, maxLine = getLineNbrs(browseResponse) + # read the 8 lines in the current browseResponse page + for i in range(1, 9): + itemTxt, itemAttr = readLine(browseResponse, i) + if itemTxt == searchTxt: + selItem = currentLine + i - 1 + loop = False + if maxLine > currentLine + 7 and loop == True: + # end of list not yet reached, go to next page + pageDown(rUrl, source) + else: + # last page, stop loop + loop = False + return selItem + +def browseThing(browseResult): + # To do: add browse menu action "create shortcut here" as soon as nymea allows browse menu actions? + # To do: limit browsing to sources that allow it? + zoneOrReceiver = browseResult.thing + pollReceiver(zoneOrReceiver) + if zoneOrReceiver.thingClassId == zoneThingClassId: + # get parent receiver thing, needed to get deviceIp + for possibleParent in myThings(): + if possibleParent.id == zoneOrReceiver.parentId: + parentReceiver = possibleParent + zoneId = zoneOrReceiver.paramValue(zoneThingZoneIdParamTypeId) + deviceIp = parentReceiver.stateValue(receiverUrlStateTypeId) + bodyStart = '' + bodyEnd = '' + source = zoneOrReceiver.stateValue(zoneInputSourceStateTypeId) + playRandomId = zonePlayRandomBrowserItemActionTypeId + powerCheck = zoneOrReceiver.stateValue(zonePowerStateTypeId) + elif zoneOrReceiver.thingClassId == receiverThingClassId: + parentReceiver = zoneOrReceiver + deviceIp = zoneOrReceiver.stateValue(receiverUrlStateTypeId) + bodyStart = '' + bodyEnd = '' + source = zoneOrReceiver.stateValue(receiverInputSourceStateTypeId) + playRandomId = receiverPlayRandomBrowserItemActionTypeId + powerCheck = zoneOrReceiver.stateValue(receiverPowerStateTypeId) + rUrl = 'http://' + deviceIp + ':80/YamahaRemoteControl/ctrl' + headers = {'Content-Type': 'text/xml', 'Accept': '*/*'} + maxItems = 128 + # maxItems is used to truncate very long lists, as browsing them is very slow due to the nature of Yamaha's API + # the value of maxItems needs to be a multiple of 8 to work correctly with the browseResponse pages that contain 8 lines + # and it needs to be browsable within nymea's browseThing timeout, which would be around 264-304 + # but it also seems quite easy to overload the device by making to many API calls, so we limit to 128 + # (if you want to test this and get stuck, powering off the receiver (not via nymea) should help) + + browsableSources = ["SERVER", "USB"] + if source in browsableSources: + logger.log("Source %s is browsable" % (source)) + # turn receiver/zone on if needed, before browsing + if powerCheck == False: + body = bodyStart + 'On' + bodyEnd + rr = requests.post(rUrl, headers=headers, data=body) + else: + logger.log("Source %s is not browsable" % (source)) + browseResult.addItem(nymea.BrowserItem("Empty", "Source not browsable", "Non-selectable item", executable=False, disabled=True, icon=nymea.BrowserIconFavorites)) + browseResult.finish(nymea.ThingErrorNoError) + return + + if browseResult.itemId == "": + # go to first menu layer + selType = "BI" + selLayer = 1 + selItem = 0 + selTxt = "Main menu" + for i in range(1, 6): + if i == 1: + shortcutId = receiverSettingsBrowsingShortcut1ParamTypeId + labelId = receiverSettingsShortcutLabel1ParamTypeId + elif i == 2: + shortcutId = receiverSettingsBrowsingShortcut2ParamTypeId + labelId = receiverSettingsShortcutLabel2ParamTypeId + elif i == 3: + shortcutId = receiverSettingsBrowsingShortcut3ParamTypeId + labelId = receiverSettingsShortcutLabel3ParamTypeId + elif i == 4: + shortcutId = receiverSettingsBrowsingShortcut4ParamTypeId + labelId = receiverSettingsShortcutLabel4ParamTypeId + elif i == 5: + shortcutId = receiverSettingsBrowsingShortcut5ParamTypeId + labelId = receiverSettingsShortcutLabel5ParamTypeId + browseTree = parentReceiver.setting(shortcutId) + labelTxt = parentReceiver.setting(labelId) + if len(browseTree) > 0 and source == "SERVER": # shortcut is configured, and source needs to be server + scLayer = len(browseTree) + 1 + subTxt = "Shortcut to " + browseTree + treeInfo = "SC-layer-" + str(scLayer) + "-item-" + str(0) + "-" + browseTree + browseResult.addItem(nymea.BrowserItem(treeInfo, labelTxt, subTxt, browsable=True, icon=nymea.BrowserIconFavorites)) + else: + selType, selLayer, selItem, selTxt = splitBrowseItem(browseResult.itemId) + + # go up to the selected menu level if needed + browseResponse, menuLayer = browseMenuReady(rUrl, source) + while menuLayer > selLayer: + menuLevelUp(rUrl, source) + browseResponse, menuLayer = browseMenuReady(rUrl, source) + + if selType == "BI": + selectLine(rUrl, source, selItem) + elif selType == "EL": + # jump to first line of truncated part of list + gotoLine(rUrl, source, selItem) + elif selType == "SC": + # shortcut, browse shortcut tree + browseTree = selTxt.split("/") + selLayer = browseInTree(rUrl, source, browseTree) + browseResponse, menuLayer = browseMenuReady(rUrl, source) + if menuLayer == len(browseTree)+1 and menuLayer > 0: + # don't do anything unless browsing to the required menu item succeeded + logger.log("Browsing to required menu item succeeded") + selLayer = len(browseTree)+1 + selItem = 0 + selTxt = "Main menu" + else: + logger.log("Browsing to required menu item unsuccessful") + # go up to the selected menu level if needed + while menuLayer > selLayer: + menuLevelUp(rUrl, source) + browseResponse, menuLayer = browseMenuReady(rUrl, source) + selLayer = 1 + selType = "BI" + selItem = 0 + selTxt = "Main menu" + + # browse menu level: keep going through menu pages (of 8 items per page) while last page hasn't been reached + loop = True + while loop == True: + browseResponse, menuLayer = browseMenuReady(rUrl, source) + currentLine, maxLine = getLineNbrs(browseResponse) + remainder = currentLine % maxItems + logger.log("selType", selType, "currentLine", currentLine, "remainder", remainder) + # long lists (longer than maxItems) are truncated and can be extended with user action + if selType == "BI" and remainder == 1 and currentLine != 1: + # truncate list, and create browsable element that will allow user to continue browsing + # create info about menu structure (BI = browsable item, EL = extend list in case long list was truncated) + treeInfo = "EL-layer-" + str(menuLayer) + "-item-" + str(currentLine) + "-truncated" + browseResult.addItem(nymea.BrowserItem(treeInfo, "Continue", "Click to show the next part of this list", browsable=True, icon=nymea.BrowserIconFolder)) + # truncate results, stop loop + loop = False + elif selType == "EL" and remainder == 1 and currentLine != selItem: + # truncate list again, and create browsable element that will allow user to continue browsing + # create info about menu structure (BI = browsable item, EL = extend list in case long list was truncated) + treeInfo = "EL-layer-" + str(menuLayer) + "-item-" + str(currentLine) + "-truncated" + browseResult.addItem(nymea.BrowserItem(treeInfo, "Continue", "Click to show the next part of this list", browsable=True, icon=nymea.BrowserIconFolder)) + # truncate results, stop loop + loop = False + else: + # read the 8 lines in the current browseResponse page + for i in range(1, 9): + itemTxt, itemAttr = readLine(browseResponse, i) + itemTxtClean = unescape(itemTxt, {"&": "&", "'": "'", """: '"'}) + # create info about menu structure (BI = browsable item, EL = extend list in case long list was truncated) + treeInfo = "BI-layer-" + str(menuLayer) + "-item-" + str(currentLine+i-1) + "-" + itemTxt + if itemAttr == "Container": + if source == "SERVER" and menuLayer == 1: # add browserItemAction play random album + # change when nymea supports browserItemActions for python plugins: + # browseResult.addItem(nymea.BrowserItem(treeInfo, itemTxtClean, browsable=True, icon=nymea.BrowserIconFolder, browserItemActions=playRandomId)) + browseResult.addItem(nymea.BrowserItem(treeInfo, itemTxtClean, browsable=True, icon=nymea.BrowserIconFolder)) + else: + browseResult.addItem(nymea.BrowserItem(treeInfo, itemTxtClean, browsable=True, icon=nymea.BrowserIconFolder)) + elif itemAttr == "Item": + browseResult.addItem(nymea.BrowserItem(treeInfo, itemTxtClean, executable=True, icon=nymea.BrowserIconMusic)) + else: + # found unselectable item, indicating end of list, stop loop + if len(itemTxt) > 0: + browseResult.addItem(nymea.BrowserItem(treeInfo, itemTxt, "Not selectable on this receiver", executable=False, disabled=True, icon=nymea.BrowserIconFavorites)) + else: + loop = False + if maxLine > currentLine + 7 and loop == True: + # end of list not yet reached, go to next page + pageDown(rUrl, source) + else: + # last page, stop loop + loop = False + + browseResult.finish(nymea.ThingErrorNoError) + return + +def executeBrowserItem(info): + zoneOrReceiver = info.thing + pollReceiver(zoneOrReceiver) + if zoneOrReceiver.thingClassId == zoneThingClassId: + # get parent receiver thing, needed to get deviceIp + for possibleParent in myThings(): + if possibleParent.id == zoneOrReceiver.parentId: + parentReceiver = possibleParent + deviceIp = parentReceiver.stateValue(receiverUrlStateTypeId) + source = zoneOrReceiver.stateValue(zoneInputSourceStateTypeId) + elif zoneOrReceiver.thingClassId == receiverThingClassId: + deviceIp = zoneOrReceiver.stateValue(receiverUrlStateTypeId) + source = zoneOrReceiver.stateValue(receiverInputSourceStateTypeId) + rUrl = 'http://' + deviceIp + ':80/YamahaRemoteControl/ctrl' + + selType, selLayer, selItem, selTxt = splitBrowseItem(info.itemId) + + # go up to the selected menu level if needed + browseResponse, menuLayer = browseMenuReady(rUrl, source) + while menuLayer > selLayer: + menuLevelUp(rUrl, source) + browseResponse, menuLayer = browseMenuReady(rUrl, source) + + selectLine(rUrl, source, selItem) + + info.finish(nymea.ThingErrorNoError) + time.sleep(0.5) + pollReceiver(zoneOrReceiver) + return + +def executeBrowserItemAction(info): + if info.actionTypeId == receiverPlayRandomBrowserItemActionTypeId or info.actionTypeId == zonePlayRandomBrowserItemActionTypeId: + if info.thing.thingClassId == zoneThingClassId: + # get parent receiver thing, needed to get deviceIp + for possibleParent in myThings(): + if possibleParent.id == info.thing.parentId: + parentReceiver = possibleParent + deviceIp = parentReceiver.stateValue(receiverUrlStateTypeId) + zoneId = info.thing.paramValue(zoneThingZoneIdParamTypeId) + source = info.thing.stateValue(zoneInputSourceStateTypeId) + elif info.thing.thingClassId == receiverThingClassId: + deviceIp = info.thing.stateValue(receiverUrlStateTypeId) + source = info.thing.stateValue(receiverInputSourceStateTypeId) + rUrl = 'http://' + deviceIp + ':80/YamahaRemoteControl/ctrl' + playRandomAlbum(rUrl, source) + return + +def selectLine(rUrl, source, selItem): + if selItem > 0: + headers = {'Content-Type': 'text/xml', 'Accept': '*/*'} + gotoLine(rUrl, source, 1) + browseResponse, menuLayer = browseMenuReady(rUrl, source) + currentLine, maxLine = getLineNbrs(browseResponse) + while selItem > currentLine + 7: + # jump to the list page with the selected line + remainder = selItem % 8 + if remainder == 0: + remainder = 8 + jumpBody = '' + str(selItem - remainder + 1) + '' + jr = requests.post(rUrl, headers=headers, data=jumpBody) + # confirm we got to right page + browseResponse, menuLayer = browseMenuReady(rUrl, source) + currentLine, maxLine = getLineNbrs(browseResponse) + # now select correct line to go to the next menu level + selectBody = '<' + source + '>Line_' + str(selItem - currentLine + 1) + '' + sr = requests.post(rUrl, headers=headers, data=selectBody) + return + +def pageDown(rUrl, source): + # scroll to next page of list + headers = {'Content-Type': 'text/xml', 'Accept': '*/*'} + scrollBody = '<' + source + '>Down' + sr = requests.post(rUrl, headers=headers, data=scrollBody) + return + +def menuLevelUp(rUrl, source): + headers = {'Content-Type': 'text/xml', 'Accept': '*/*'} + returnBody = '<' + source + '>Return' + ur = requests.post(rUrl, headers=headers, data=returnBody) + return + +def readLine(browseResponse, i): + lineResult = [] + stringIndex1 = browseResponse.find("") + stringIndex2 = browseResponse.find("") + browseTxt = browseResponse[stringIndex1+8:stringIndex2] + stringIndex1 = browseTxt.find("") + stringIndex2 = browseTxt.find("") + itemTxt = browseTxt[stringIndex1+5:stringIndex2] + stringIndex1 = browseTxt.find("") + stringIndex2 = browseTxt.find("") + itemAttr = browseTxt[stringIndex1+11:stringIndex2] + return itemTxt, itemAttr + +def splitBrowseItem(itemId): + splitId = itemId.split("-",5) + selType = splitId[0] + selLayer = int(splitId[2]) + selItem = int(splitId[4]) + selTxt = splitId[5] + return selType, selLayer, selItem, selTxt + +def getLineNbrs(browseResponse): + stringIndex1 = browseResponse.find("") + stringIndex2 = browseResponse.find("") + currentLine = int(browseResponse[stringIndex1+14:stringIndex2]) + stringIndex1 = browseResponse.find("") + stringIndex2 = browseResponse.find("") + maxLine = int(browseResponse[stringIndex1+10:stringIndex2]) + return currentLine, maxLine + +def gotoLine(rUrl, source, lineNbr): + # e.g. line 1: make sure we are on the first line in the menu before continuing + headers = {'Content-Type': 'text/xml', 'Accept': '*/*'} + browseBody = '<' + source + '>GetParam' + browseResponse, menuLayer = browseMenuReady(rUrl, source) + jumpBody = '<' + source + '>' + str(lineNbr) + '' + jr = requests.post(rUrl, headers=headers, data=jumpBody) + return + +def browseMenuReady(rUrl, source): + # make sure menu status is Ready before sending any further commands, as they may not be processed by the receiver + # at same time, return list info as we got it anyway when checking menu status + headers = {'Content-Type': 'text/xml', 'Accept': '*/*'} + browseBody = '<' + source + '>GetParam' + ready = False + while ready == False: + br = requests.post(rUrl, headers=headers, data=browseBody) + browseResponse = br.text + stringIndex1 = browseResponse.find("") + stringIndex2 = browseResponse.find("") + menuStatus = browseResponse[stringIndex1+13:stringIndex2] + if menuStatus == "Ready": + ready = True + stringIndex1 = browseResponse.find("") + stringIndex2 = browseResponse.find("") + menuLayer = int(browseResponse[stringIndex1+12:stringIndex2]) + stringIndex1 = browseResponse.find("") + stringIndex2 = browseResponse.find("") + menuTitle = browseResponse[stringIndex1+11:stringIndex2] + logger.log("Menu layer", menuLayer, "Menu title", menuTitle) + else: + time.sleep(0.1) + return browseResponse, menuLayer + +def deinit(): + global pollTimer + # If we started a poll timer, cancel it on shutdown. + if pollTimer is not None: + pollTimer.cancel() + +def thingRemoved(thing): + logger.log("removeThing called for", thing.name) + # Clean up all data related to this thing + if pollTimer is not None: + pollTimer.cancel() \ No newline at end of file diff --git a/yamahaavr/meta.json b/yamahaavr/meta.json new file mode 100644 index 00000000..cd8b5432 --- /dev/null +++ b/yamahaavr/meta.json @@ -0,0 +1,13 @@ +{ + "title": "Yamaha AV receiver", + "tagline": "Connect to and control your (non-Musiccast) Yamaha AV receivers", + "icon": "yamaha.png", + "stability": "consumer", + "offline": true, + "technologies": [ + "network" + ], + "categories": [ + "multimedia" + ] +} diff --git a/yamahaavr/requirements.txt b/yamahaavr/requirements.txt new file mode 100644 index 00000000..1d474356 --- /dev/null +++ b/yamahaavr/requirements.txt @@ -0,0 +1,64 @@ +zeroconf==0.29.0 \ + --hash=sha256:7aefbb658b452b1fd7e51124364f938c6f5e42d6ea893fa2557bea8c06c540af \ + --hash=sha256:85fdeeef88b08965ab87559177457cfdb5dd2e4bc62a476208c2473a51dfa0b2 +testresources==2.0.1 \ + --hash=sha256:67a361c3a2412231963b91ab04192209aa91a1aa052f0ab87245dbea889d1282 \ + --hash=sha256:ee9d1982154a1e212d4e4bac6b610800bfb558e4fb853572a827bc14a96e4417 +requests==2.25.1 \ + --hash=sha256:27973dd4a904a4f13b263a19c866c13b92a39ed1c964655f025f3f8d3d75b804 \ + --hash=sha256:c210084e36a42ae6b9219e00e48287def368a26d03a048ddad7bfee44f75871e +urllib3==1.26.3 \ + --hash=sha256:1b465e494e3e0d8939b50680403e3aedaa2bc434b7d5af64dfd3c958d7f5ae80 \ + --hash=sha256:de3eedaad74a2683334e282005cd8d7f22f4d55fa690a2a1020a416cb0a47e73 +chardet==4.0.0 \ + --hash=sha256:0d6f53a15db4120f2b08c94f11e7d93d2c911ee118b6b30a04ec3ee8310179fa \ + --hash=sha256:f864054d66fd9118f2e67044ac8981a54775ec5b67aed0441892edb553d21da5 +lazr.uri==1.0.5 \ + --hash=sha256:f36e7e40d5f8f2cf20ff2c81784a14a546e6c19c216d40a6617ebe0c96c92c49 \ + --hash=sha256:71f2faf04b148cf63d78da08ee5d8d6a7a7dbda8c9016b389a16f790d080c06f +six==1.15.0 \ + --hash=sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259 \ + --hash=sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced +wadllib==1.3.5 \ + --hash=sha256:84fecbaec2fef5ae2d7717a8115d271f18c6b5441eac861c58be8ca57f63c1d3 \ + --hash=sha256:67d3102b40eefdd6c3007cfbcc4c07f6948fec0666ba5c17d703eab21f054692 +lazr.restfulclient==0.14.3 \ + --hash=sha256:9f28bbb7c00374159376bd4ce36b4dacde7c6b86a0af625aa5e3ae214651a690 \ + --hash=sha256:2320e6d132c9a5148895e85be03274bc9305e4605439b03541ee3a618e00fb94 +pyparsing==2.4.7 \ + --hash=sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1 \ + --hash=sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b +idna==2.5 \ + --hash=sha256:3cb5ce08046c4e3a560fc02f138d0ac63e00f8ce5901a56b32ec8b7994082aab \ + --hash=sha256:cc19709fd6d0cbfed39ea875d29ba6d4e22c0cebc510a76d6302a28385e8bb70 +certifi==2020.12.5 \ + --hash=sha256:1a4995114262bffbc2413b159f2a1a480c969de6e6eb13ee966d470af86af59c \ + --hash=sha256:719a74fb9e33b9bd44cc7f3a8d94bc35e4049deebe19ba7d8e108280cfd59830 +setuptools==54.1.1 \ + --hash=sha256:1ce82798848a978696465866bb3aaab356003c42d6143e1111fcf069ac838274 \ + --hash=sha256:75c5c4479f4961f1ffdb597c98aa4e4077e6813685025e8bdebf7598aa84e859 +pbr==5.5.1 \ + --hash=sha256:5fad80b613c402d5b7df7bd84812548b2a61e9977387a80a5fc5c396492b13c9 \ + --hash=sha256:b236cde0ac9a6aedd5e3c34517b423cd4fd97ef723849da6b0d2231142d89c00 +distro==1.5.0 \ + --hash=sha256:0e58756ae38fbd8fc3020d54badb8eae17c5b9dcbed388b17bb55b8a5928df92 \ + --hash=sha256:df74eed763e18d10d0da624258524ae80486432cd17392d9c3d96f5e83cd2799 +oauthlib==3.1.0 \ + --hash=sha256:bee41cc35fcca6e988463cacc3bcb8a96224f470ca547e697b604cc697b2f889 \ + --hash=sha256:df884cd6cbe20e32633f1db1072e9356f53638e4361bef4e8b03c9127c9328ea +requests-oauthlib==1.3.0 \ + --hash=sha256:7f71572defaecd16372f9006f33c2ec8c077c3cfa6f5911a9a90202beb513f3d \ + --hash=sha256:b4261601a71fd721a8bd6d7aa1cc1d6a8a93b4a9f5e96626f8e4d91e8beeaa6a \ + --hash=sha256:fa6c47b933f01060936d87ae9327fead68768b69c6c9ea2109c48be30f2d4dbc +urllib3==1.26.3 \ + --hash=sha256:1b465e494e3e0d8939b50680403e3aedaa2bc434b7d5af64dfd3c958d7f5ae80 \ + --hash=sha256:de3eedaad74a2683334e282005cd8d7f22f4d55fa690a2a1020a416cb0a47e73 +chardet==4.0.0 \ + --hash=sha256:0d6f53a15db4120f2b08c94f11e7d93d2c911ee118b6b30a04ec3ee8310179fa \ + --hash=sha256:f864054d66fd9118f2e67044ac8981a54775ec5b67aed0441892edb553d21da5 +httplib2==0.19.0 \ + --hash=sha256:749c32603f9bf16c1277f59531d502e8f1c2ca19901ae653b49c4ed698f0820e \ + --hash=sha256:e0d428dad43c72dbce7d163b7753ffc7a39c097e6788ef10f4198db69b92f08e +ifaddr==0.1.7 \ + --hash=sha256:1f9e8a6ca6f16db5a37d3356f07b6e52344f6f9f7e806d618537731669eb1a94 \ + --hash=sha256:d1f603952f0a71c9ab4e705754511e4e03b02565bc4cec7188ad6415ff534cd3 \ No newline at end of file diff --git a/yamahaavr/yamaha.png b/yamahaavr/yamaha.png new file mode 100644 index 00000000..2672fa3b Binary files /dev/null and b/yamahaavr/yamaha.png differ