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("" + zone + ">")
+ 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' + inputSource + '>'
+ 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' + inputSource + '>'
+ 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' + source + '>'
+ 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' + source + '>'
+ 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' + source + '>'
+ 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' + source + '>'
+ 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' + source + '>'
+ 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 + '' + source + '>'
+ 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 + '' + source + '>'
+ 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) + '' + source + '>'
+ 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' + source + '>'
+ sr = requests.post(rUrl, headers=headers, data=scrollBody)
+ return
+
+def menuLevelUp(rUrl, source):
+ headers = {'Content-Type': 'text/xml', 'Accept': '*/*'}
+ returnBody = '<' + source + '>Return' + source + '>'
+ 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' + source + '>'
+ browseResponse, menuLayer = browseMenuReady(rUrl, source)
+ jumpBody = '<' + source + '>' + str(lineNbr) + '' + source + '>'
+ 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' + source + '>'
+ 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