From 2b827730709ac7c681ff9ce3a1b185d933097892 Mon Sep 17 00:00:00 2001 From: loosrob <79396812+loosrob@users.noreply.github.com> Date: Fri, 30 Jul 2021 14:47:56 +0200 Subject: [PATCH] Nymea plugins: add Yamaha AVR plugin add control for (non-Musiccast) Yamaha network receivers --- yamahaavr/README.md | 28 + yamahaavr/integrationpluginyamaha.json | 754 +++++++++++++ yamahaavr/integrationpluginyamaha.py | 1382 ++++++++++++++++++++++++ yamahaavr/meta.json | 13 + yamahaavr/requirements.txt | 64 ++ yamahaavr/yamaha.png | Bin 0 -> 12812 bytes 6 files changed, 2241 insertions(+) create mode 100644 yamahaavr/README.md create mode 100644 yamahaavr/integrationpluginyamaha.json create mode 100644 yamahaavr/integrationpluginyamaha.py create mode 100644 yamahaavr/meta.json create mode 100644 yamahaavr/requirements.txt create mode 100644 yamahaavr/yamaha.png 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 0000000000000000000000000000000000000000..2672fa3b06ca1c5c55dc2f44e32eaffde4b9ed18 GIT binary patch literal 12812 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 ZqS^0h#He-5{(bL2(@@b?u2zDC{~ym^rY!&f literal 0 HcmV?d00001