NEWS
[Vorlage] Denon HEOS Script
-
@chrisblu Ich habe für jeden Lautsprecher einen Ping Adapter angelegt. Sobald sich da was ändert und in den HEOS Objekten der Player noch nicht verbunden ist, starte ich das Script neu (bei mir sind die Player nicht dauerhaft verbunden). Übrigens ich hatte total vergessen, dass das connected Flag der Player im disconnect wieder auf false gesetzt wird.
Mein Blockly Heos Script Starter sieht so aus (kann bestimmt auch eleganter gelöst werden, ich nutze jedoch iobroker erst seit kurzem):var DelayedUnlock; schedule("*/15 * * * * *", function () { if (getState("0_userdata.0.scriptData.HEOSScriptStarterRunning").val == true && ((new Date().getTime()) - getState("0_userdata.0.scriptData.HEOSScriptStarterRunning").lc) / 1000 > 300) { setState("0_userdata.0.scriptData.HEOSScriptStarterRunning"/*HEOSScriptStarterRunning*/, false); } if (getState("0_userdata.0.scriptData.HEOSScriptStarterRunning").val == false) { setState("0_userdata.0.scriptData.HEOSScriptStarterRunning"/*HEOSScriptStarterRunning*/, true); DelayedUnlock = false; if (getState("javascript.0.scriptEnabled.common.HEOS").val == true && getState("javascript.0.heos.connected").val == false) { console.log('HEOS not connected. Shutdown.'); setState("javascript.0.scriptEnabled.common.HEOS"/*scriptEnabled.common.HEOS*/, false); } else if (getState("javascript.0.scriptEnabled.common.HEOS").val == true && (getState("javascript.0.heos.192_168_178_43.connected").val != getState("ping.0.server.192_168_178_43").val || getState("javascript.0.heos.192_168_178_44.connected").val != getState("ping.0.server.192_168_178_44").val || getState("javascript.0.heos.192_168_178_45.connected").val != getState("ping.0.server.192_168_178_45").val || getState("javascript.0.heos.192_168_178_46.connected").val != getState("ping.0.server.192_168_178_46").val)) { console.log('HEOS Player updated. Shutdown.'); setState("javascript.0.scriptEnabled.common.HEOS"/*scriptEnabled.common.HEOS*/, false); } else { if (getState("ping.0.server.192_168_178_45").val == false && getState("ping.0.server.192_168_178_44").val == false && getState("ping.0.server.192_168_178_43").val == false && getState("ping.0.server.192_168_178_46").val == false) { if (getState("javascript.0.scriptEnabled.common.HEOS").val == true) { console.log('No Players. Shutdown.'); setState("javascript.0.scriptEnabled.common.HEOS"/*scriptEnabled.common.HEOS*/, false); DelayedUnlock = true; } } else { if (getState("javascript.0.scriptEnabled.common.HEOS").val == false) { console.log('Players available. Startup.'); setState("javascript.0.scriptEnabled.common.HEOS"/*scriptEnabled.common.HEOS*/, true); DelayedUnlock = true; } } } if (DelayedUnlock == true) { setStateDelayed("0_userdata.0.scriptData.HEOSScriptStarterRunning"/*HEOSScriptStarterRunning*/, false, 60000, false); } else { setState("0_userdata.0.scriptData.HEOSScriptStarterRunning"/*HEOSScriptStarterRunning*/, false); } } });
Zu jedem Player gibt es dann noch ein Script, welches im Notfall das connected Flag wieder auf false setzt (und automatisch die Musik und Lautstärker steuert ) :
var DelayedUnlock, Volume, Preset; on({id: new RegExp('javascript\\.0\\.heos\\.192_168_178_45\\.mute' + "|" + 'javascript\\.0\\.heos\\.192_168_178_45\\.connected' + "|" + 'ping\\.0\\.server\\.192_168_178_45' + "|" + 'javascript\\.0\\.heos\\.connected'), change: "ne"}, function (obj) { if (getState("0_userdata.0.scriptData.BadezimmerAutoPlayRunning").val == false) { setState("0_userdata.0.scriptData.BadezimmerAutoPlayRunning"/*scriptData.BadezimmerAutoPlayRunning*/, true); DelayedUnlock = false; if (getState("javascript.0.heos.connected").val == false && getState("javascript.0.heos.192_168_178_45.connected").val == true && getState("ping.0.server.192_168_178_45").val == false) { setState("javascript.0.heos.192_168_178_45.connected"/*Verbunden?*/, false); } if (getState("javascript.0.heos.192_168_178_45.connected").val == true && getState("javascript.0.heos.192_168_178_45.mute").val == false && (getState("javascript.0.heos.192_168_178_45.play_state").val == 'stop' || getState("javascript.0.heos.192_168_178_45.play_state").val == 'pause')) { console.log('Starte Musik im Badezimmer.'); if ((new Date().getHours()) >= 22 || (new Date().getHours()) <= 7) { Volume = getState("0_userdata.0.scriptData.NachtVolume").val; } else { Volume = getState("0_userdata.0.scriptData.TagVolume").val; } setState("javascript.0.heos.192_168_178_45.volume"/*Aktuelle Lautstärke*/, Volume); if (getState("javascript.0.heos.192_168_178_45.now_playing_media_album_id").val.indexOf('ios-ipod-library') + 1 > 0 || getState("javascript.0.heos.192_168_178_45.last_error").val.length > 0) { if ((new Date().getDay() === 0 ? 7 : new Date().getDay()) == 0) { Preset = getState("0_userdata.0.scriptData.SonntagPreset").val; } else { Preset = getState("0_userdata.0.scriptData.DefaultPreset").val; } setState("javascript.0.heos.192_168_178_45.command"/*Kommando für HEOS Aufrufe*/, ('play_preset&preset=' + String(Preset))); } else { setState("javascript.0.heos.192_168_178_45.play_state"/*Aktueller Wiedergabezustand*/, 'play'); } DelayedUnlock = true; } if (DelayedUnlock == true) { setStateDelayed("0_userdata.0.scriptData.BadezimmerAutoPlayRunning"/*scriptData.BadezimmerAutoPlayRunning*/, false, 60000, false); } else { setState("0_userdata.0.scriptData.BadezimmerAutoPlayRunning"/*scriptData.BadezimmerAutoPlayRunning*/, false); } } }); schedule("* * * * *", function () { if (getState("javascript.0.heos.192_168_178_45.connected").val == true) { if ((new Date().getHours()) >= 22 || (new Date().getHours()) <= 7) { if (getState("0_userdata.0.scriptData.BadezimmerAutoVolume").val != 'Nacht') { Volume = getState("0_userdata.0.scriptData.NachtVolume").val; if (getState("javascript.0.heos.192_168_178_45.volume").val != Volume) { setState("javascript.0.heos.192_168_178_45.volume"/*Aktuelle Lautstärke*/, Volume); } setState("0_userdata.0.scriptData.BadezimmerAutoVolume"/*BadezimmerAutoVolume*/, 'Nacht'); } } else { if (getState("0_userdata.0.scriptData.BadezimmerAutoVolume").val != 'Tag') { Volume = getState("0_userdata.0.scriptData.TagVolume").val; if (getState("javascript.0.heos.192_168_178_45.volume").val != Volume) { setState("javascript.0.heos.192_168_178_45.volume"/*Aktuelle Lautstärke*/, Volume); } setState("0_userdata.0.scriptData.BadezimmerAutoVolume"/*BadezimmerAutoVolume*/, 'Tag'); } } } });
-
@Uhula Das mit den verstümmelten Pakten war bei mir auch zufällig und auch immer bei den Presets. Habe folgendes im Script geändert:
- Player connected Flag hinzugefügt (connect und disconnect)
- this.nodessdp_client.stop() anstatt destroy
- onNodeSSDPResponse Funktion: Filter von Nicht-HEOS Packeten hinzugefügt.
- onDate Funktion: JSON parse check hinzugefügt
- parseResponse Funktion: Filter von "command under process" Paketen hinzugefügt
- Switch get_players: Wenn neue Player erkannt wurden, wird ein disconnect durchgeführt (eleganter wäre natürlich die neuen Player ganze ohne disconnect bzw. restart des Scripts aufzunehmen bzw. zu entfernen)
- Das Event player_mute_changed gibt es nicht mehr. Habe das Aktualisieren des Mute States in player_volume_changed aufgenommen
- get_now_playing_media aktualisiert jetzt wieder alle Song States
- Einige Debug Messages...
Btw. die Funktionen sleep und removeFirstOccurrence im Script werden nicht mehr verwendet und können entfernt werden.
-
@withstu Vielen Dank withstu. Ich habe die meisten Dinge davon übernommen, andere anders implementiert. Wenn du Interesse hast, kannst du die neuste Version testen. Hier die Liste der Änderungen:
v2.0 13.03.2020
- das Script überprüft nun, ob alle notwendigen states für Heos und HeosPlayer im ioBroker korrekt erzeugt wurden und gibt eine Warnung mit Script-Neustartaufforderung aus, wenn die states nicht vorhanden sind. Dieses ist notwendig, da das Anlegen von states asynchron erfolgt und einige ms dauern kann
- (WICHTIG!) die states werden nicht mehr in der aktuellen Javascript-Instanz und auch nicht mehr unter der IP des Heos-Players gespeichert, sondern Javascript-Instanzunabhängig unter 0_userdata.0 und seiner Player-ID (pid), da diese sich nicht ändert. Die IP hingegen u.U. schon. Also statt "javascript.X.heos.192_168_2_43" nun als "0_userdata.0.heos.12345678". Wer bereits auf die alten Werte im VIS zugreift, muss dieses im VIS anpassen!
- es wurden Befehle/states für die Gruppensteuerung der Heos-Player hinzugefügt. Beim Start werden die Gruppen ausgelesen und in den Player-states des Gruppenleiters (der erste genannte Player) gespeichert. Über dessen states kann die Gruppe gesteuert werden:
- group_leader : Ist Gruppenleiter
- group_member : Ist Gruppenmitglied
- group_pid : Player pids in der Gruppe
- group_volume : Lautstärke wenn Gruppen-Leiter
- group_name : Name der Gruppe
- group_mute : Gruppe gemutet?
- alle 60 Sek wird überprüft, ob noch alle HEOS-Player erreichbar sind bzw. neue/reconnectete hinzugekommen sind. Neue/reconnectete werden der Liste der HEOS-Player hinzugefügt, fehlende werden gestoppt. Da hierzu das HEOS-Netzwerk gefragt wird (get_players Meldung), kann es bis zu 5 Min dauern bis Änderungen erkannt werden. Der neue HEOS-Player-State "connected" wird entsprechend gesetzt
- jeder HEOS-Player hat einen neuen state "connected", dieser wird nach erfolgreichem Start auf true gesetzt, bei korrektem Beenden des Scripts auf false
- diverse Filter/Korrekturen aus dem Forum übernommen, vielen dank an withstu
Die Script-Datei: heos_new.js
Eine Beispiel-view für die Gruppen: heos_new_groupview.json (nutzt das MDCSS v2, aber an den Widgets kann man auch so das Prinzip erkennen)
-
@Uhula Danke für die neue Version. Ich habe an der V2 noch 3 kleine Änderungen vorgenommen.
- Im get_now_playing_media fehlt die album_id
if (jdata.payload.hasOwnProperty('album')) this.setState("now_playing_media_album", jdata.payload.album); if (jdata.payload.hasOwnProperty('album_id')) this.setState("now_playing_media_album_id", jdata.payload.album_id); if (jdata.payload.hasOwnProperty('artist')) this.setState("now_playing_media_artist", jdata.payload.artist);
- Die unfinishedResponses habe ich wieder hinzugefügt (siehe meine Änderungen), sonst werden bei mir die Presets nicht geholt, da nicht vollständige Pakete verworfen werden.
init() { this.statePath = '0_userdata.0.heos.'; this.players = []; this.getPlayersInterval = undefined; this.net_client = undefined; this.nodessdp_client = undefined; this.ip=''; this.msgs = []; this.lastResponse = ''; this.state = stateDISCONNECTED; this.unfinishedResponses = ''; this.ssdpSearchTargetName = 'urn:schemas-denon-com:device:ACT-Denon:1'; } [...] onData(data) { try { data = data.toString(); data=data.replace(/[\n\r]/g, ''); // Steuerzeichen "CR" entfernen // es können auch mehrere Antworten vorhanden sein! {"heos": ... } {"heos": ... } // diese nun in einzelne Antworten zerlegen data = this.unfinishedResponses + data; this.unfinishedResponses = ''; data=data.replace(/{"heos":/g, '|{"heos":'); var responses = data.split('|'); responses.shift(); for (var r=0; r<responses.length; r++ ) if(responses[r].trim().length > 0) { try { JSON.parse(responses[r]); // check ob korrektes JSON Array this.parseResponse(responses[r]); } catch(e) { this.logDebug('onData: invalid json (error: ' + e.message + '): ' + responses[r]); this.unfinishedResponses += responses[r]; } } // wenn weitere Msg zum Senden vorhanden sind, die nächste senden if (this.msgs.length>0) this.sendNextMsg(); } catch(err) { this.logError( 'onData: '+err.message ); } }
- Das leere setInterval in startPlayer() habe ich entfernt:
this.setInterval(() => { }, 10000);
Bisher läuft das Skript ganz gut. Läuft allerdings in einen Fehler, wenn alle Player ausgestellt wurden (war beim alten Script auch schon so): Error [ERR_STREAM_DESTROYED]: Cannot call write after a stream was destroyed
-
@Uhula Irgendwie funktioniert der keep alive Mechanismus vom net socket nicht korrekt. Auch wenn ich das Device ausgeschaltet habe, wurde das Timeout Event nicht geworfen. Habe jetzt den HEOS Heartbeat zusätzlich implementiert. Sollte der Master mal ausgeschaltet werden und der Heartbeat Timeout 60 Sekunden keine Reaktion von dem Device bekommen, wird nun ein reconnect bzw. eine Suche nach einem neuen Master durchgeführt. Zusätzlich wird die ssdp Suche jetzt so oft wiederholt, bis ein Device gefunden wurde.
/**************************** ### HEOS Script for ioBroker #### Funktion Das Script stellt zwei JS Klassen zur Steuerung der Denon HEOS Geräte zur Verfügung. Die class Heos dient dabei dem Erkennen und Steuern der HEOS Geräte. Die class HeosPlayer dient der Interpretation der Antworten der Heos Geräte. Das Script muss lediglich gestartet werden, es sucht dann im Netzwerk nach HEOS Playern und erzeugt in der aktuellen Javascript-Instanz einen Subeintrag für die Favoriten und je einen Subeintrag für jeden gefundenen Player. Dort wiederum werden State-Variablen zur Aufnahme der Player-Daten angelegt. Das Script erzeugt auch on-Handler um auf Steuerungen über vis und andere Scripte an den State-Variablen reagieren zu können. Beim Beenden des Scripts werden alle Verbindungen und on-Handler geschlossen. Das Script ermöglicht die HEOS Player zu steuern, es soll aber nicht die HEOS App ersetzen. #### Wichtig! * Im Javascript-Adapter muss in der Instanz-Konfiguration "node-ssdp" mit angegeben werden! Alternativ kann die Bibliothek auch komplett über "npm install node-ssdp" installiert werden. * Wenn mit Favoriten (Presets) gearbeitet werden soll, müssen die HEOS-Konto Logindaten in den beiden Konstanten HEOS_USERNAME (EmailAdr) und HEOS_PASSWORD hier im Script in der Konfiguration angegeben werden (so, wie in der HEOS App)! * Eine Konfiguration von IP-Adressen und Player-IDs ist nicht notwendig! * U.U. kann es notwendig sein das Script nach dem 1.Start zu beenden und nach 30 Sek erneut zu starten, da die Neuanlage der ioBroker States etwas Zeit benötigt. Warnungen sind dabei zu ignorieren. ;-) #### Updates ##### v2.0 13.03.2020 * das Script überprüft nun, ob alle notwendigen states für Heos und HeosPlayer im ioBroker korrekt erzeugt wurden und gibt eine Warnung mit Script-Neustartaufforderung aus, wenn die states nicht vorhanden sind. Dieses ist notwendig, da das Anlegen von states asynchron erfolgt und einige ms dauern kann * (WICHTIG!) die states werden nicht mehr in der aktuellen Javascript-Instanz und auch nicht mehr unter der IP des Heos-Players gespeichert, sondern Javascript-Instanzunabhängig unter 0_userdata.0 und seiner Player-ID (pid), da diese sich nicht ändert. Die IP hingegen u.U. schon. Also statt "javascript.X.heos.192_168_2_43" nun als "0_userdata.0.heos.12345678". Wer bereits auf die alten Werte im VIS zugreift, muss dieses im VIS anpassen! * es wurden Befehle/states für die Gruppensteuerung der Heos-Player hinzugefügt. Beim Start werden die Gruppen ausgelesen und in den Player-states des Gruppenleiters (der erste genannte Player) gespeichert. Über dessen states kann die Gruppe gesteuert werden: group_volume: dient der Steuerung der Gruppen-Lautstärke group_mute: dient der Steuerung der Gruppen-Mutes group_leader: ist true, wenn der Player der Gruppenleiter ist group_member: ist true, wenn der Player Mitglied einer Gruppe ist * alle 60 Sek wird überprüft, ob noch alle HEOS-Player erreichbar sind bzw. neue/reconnectete hinzugekommen sind. Neue/reconnectete werden der Liste der HEOS-Player hinzugefügt, fehlende werden gestoppt. Da hierzu das HEOS-Netzwerk gefragt wird (get_players Meldung), kann es bis zu 5 Min dauern bis Änderungen erkannt werden. Der neue HEOS-Player-State "connected" wird entsprechend gesetzt * jeder HEOS-Player hat einen neuen state "connected", dieser wird nach erfolgreichem Start auf true gesetzt, bei korrektem Beenden des Scripts auf false * diverse Filter/Korrekturen aus dem Forum übernommen, vielen dank an withstu #### class Heos Diese Basisklasse dient * dem Erkennen von HEOS Geräten (Playern). Das Erkennen der HEOS Geräte findet via UPD unter Nutzung von node-ssdp statt. node-ssdp muss dazu im Javascript-Adapter in der Konfiguration mit angegeben werden! * der Kommunikation über TelNet. Es wird genau eine TelNet Verbindung zu einem HEOS Gerät aufgebaut, dieses reicht die Sendungen und Antworten zentral weiter * der Ermittlung von Favoriten (Presets). Für jeden Favorit wird ein ioBroker State heos.presets.n mit entsprechenden Sub-States erzeugt. Zur Nutzung der Presets ist ein SignIn notwendig, hierzu müssen HEOS_USERNAME und HEOS_PASSWORD gesetzt werden. * dem Instanziieren der class HeosPlayer je HEOS Gerät. Je HEOS Gerät wird ein ioBroker-State mit der IP-Adresse des Players erzeugt, dieser State erhält diverse Sub-States ##### State-Variable: Heos Werden angelegt unter "0_userdata.0.heos." * connected: true wenn die Verbindung zu mindestens einem HEOS-Player besteht * script_version: Version des Scripts, dient dazu Script-Updates zu erkennen * last_error: Letzter Fehlertext * command(cmd): Hierüber können der Heos-Klasse Befehle übergeben werden, für cmd gilt: "connect": Verbindung zu HEOS aufbauen bzw. erneut aufbauen (praktisch ein reset) "disconnect": Verbindung zu HEOS beenden "load_presets": lädt die Favoriten neu "group/set_group?pid=<pid1>,<pid2>,...": setzen einer Gruppe, die pids sind die der Player wie sie unter den Objekten für jeden Player abgelegt sind. Bsp: "group/set_group?pid=12345678,12345679". "group/set_group?pid=<pid1>" : hebt die Gruppierung des Players mit der pid1 wieder auf. Bsp: "group/set_group?pid=12345678" "...": alle anderen cmd-Werte werden "as is" versucht an HEOS zu senden * presets.<n>: Hierunter werden die Favoriten, Presets angelegt. Je Favorit ein Unterordner n=1 bis Anzahl. Darunter befinden sich dann die States, welche den Inhalt der Favoriten aufnehmen: * image_url (read): Bild des Favoriten * name (read): Name des Favoriten * playable (read): Spielbar? true/false * type (read): Typ des Favoriten (station, ...) #### class HeosPlayer Diese Klasse dient * der Steuerung genau eines HEOS Gerätes. Hierzu wird die zentrale Telnet Verbindung der class Heos genutzt. * dem Erzeugen der ioBroker-States zum Speichern der Geräte-Werte * der Auswertung von Antworten der HEOS Geräte und Zuweisung an die entsprechenden ioBroker-States ##### State-Variable Werden angelegt unter "0_userdata.0.heos.<pid>" * connected : true, wenn eine Verbindung besteht, sonst false * command(cmd): Hierüber können dem Heos-Player Befehle übergeben werden. Diese werden im Klartext in die State-Variable geschrieben. Getrennt durch das | Zeichen können mehrere hintereinander eingetragen werden. Bsp: Setzen der Lautstärke auf 20 und Abspielen des 1.Favoriten "set_volume&level=20|play_preset&preset=1|set_play_state&state=play". Folgende cmd sind erlaubt: "set_volume&level=0|1|..|100" : Setzt die gewünschte Lautstärke "set_play_state&state=play|pause|stop" : Startet und stoppt die Wiedergabe "set_play_mode&repeat=on_all|on_one|off&shuffle=on|off": Setzt Wiederholung und Zufallsweidergabe "set_mute&state=on|off" : Stumm schalten oder nicht "volume_down&step=1..10" : Lautstärke verringern um "volume_up&step=1..10" : Lauststäre erhöhen um "play_next" : Nächsten Titel spielen "play_previous" : Vorherigen Titel spielen "play_preset&preset=1|2|..|n" : Favorit Nr n abspielen "play_stream&url=url_path" : URL-Stream abspielen * cur_pos (read) : lfd. Position der Wiedergabe in [Sek] * cur_pos_MMSS (read) : lfd. Position der Wiedergabe im Format MM:SS * duration (read) : Länge des aktuellen Titels in [Sek] * duration_MMSS (read) : Länge des aktuellen Titels im Format MM:SS * ip (read): IP-Adresse des HEOS Players * last_error (read): Text des letzten Fehlers, der vom Player gesendet wurde. Wird bei jedem neuen Befehl zurückgesetzt. Wenn man bspw. versucht eine Wiedergabe über Amazon o.ä. zu starten und es gibt aber keine Verbindung dahin, steht hier der Fehlertext drin * model (read): Modell des HEOS Players * mute (read write): Boolsche Variable, die anzeigt ob der Player gemutet (Stumm geschaltet) wurde. Hierüber kann auch ein mute gesetzt werden * name (read): Name des HEOS Players * now_playing_media_... (read) : Eine Gruppe von States, in welchen Infos über den aktuellen Titel gespeichert werden, wird automatisch aktualisiert. * pid (read): Player-ID des HEOS Players * play_mode_repeat (read write) : Repeat-Modus der Wiedergabe. Mögliche Werte: on_all|on_one|off * play_mode_shuffle (read write) : Zufallswiedergabe true/false * play_state (read write): Zustand des Players. Mögliche Werte play|pause|stop * serial (read): Seriennummmer des HEOS Players * volume (read write): Lautstärke im Bereich 0 - 100. * group_leader : Ist Gruppenleiter * group_member : Ist Gruppenmitglied * group_pid : Player pids in der Gruppe * group_volume : Lautstärke wenn Gruppen-Leiter * group_name : Name der Gruppe * group_mute : Gruppe gemutet? #### Weiterführende Links * HEOS CLI Protokoll: http://rn.dmglobal.com/euheos/HEOS_CLI_ProtocolSpecification.pdf * http://forum.iobroker.net/viewtopic.php?f=30&t=5693&p=115554#p115554 (c) Uhula, MIT License, no warranty, use on your own risc */ /**************************** * Konfiguration ****************************/ const HEOS_USERNAME = ''; const HEOS_PASSWORD = ''; /**************************** * ab hier nichts mehr ändern ;-) ****************************/ var net = require('net'); const stateDISCONNECTED = 0; const stateSEARCHING = 1; const stateCONNECTING = 2; const stateCONNECTED = 3; const SCRIPTVERSION = '2.0/2020-03-13'; /******************** * class Heos ********************/ class Heos { logDebug(msg) { console.debug('[Heos] '+msg); } log(msg) { console.log('[Heos] '+msg); } logWarn(msg) { console.warn('[Heos] '+msg); } logError(msg) { console.error('[Heos] '+msg); } constructor() { this.init(); this.states = [ { id:"command", common:{name:"Kommando für HEOS Aufrufe"} }, { id:"connected", common:{name:"Verbunden?", write:false, def:false } }, { id:"last_error", common:{name:"Letzte Fehler", write:false} }, { id:"script_version", common:{name:"Installierte Script-Version", write:false } } ]; // beim 1.Start nur die States erzeugen if ( !this.existState("script_version") || (this.getState('script_version').val!=SCRIPTVERSION) ) { this.installed = false; this.logWarn('creating HEOS states for version '+SCRIPTVERSION+', please start script again'); // Anlage der States for (var s=0; s<this.states.length; s++) { this.setState( this.states[s].id ); } this.setState('script_version', SCRIPTVERSION); } else { this.installed = true; on({id: this.statePath+'command', change: "any"}, (obj) => { this.executeCommand( obj.state.val ); }); } } // Initialisierung init() { this.statePath = '0_userdata.0.heos.'; this.players = []; this.heartbeatInterval = undefined; this.heartbeatTimeout = undefined; this.ssdpSearchInterval = undefined; this.getPlayersInterval = undefined; this.net_client = undefined; this.nodessdp_client = undefined; this.ip=''; this.msgs = []; this.lastResponse = ''; this.state = stateDISCONNECTED; this.unfinishedResponses = ''; this.ssdpSearchTargetName = 'urn:schemas-denon-com:device:ACT-Denon:1'; } // über den $-Operator nachsehen, ob der state bereits vorhanden ist // getState().notExists geht auch, erzeugt aber Warnmeldungen! existState(id) { return ( $(this.statePath+id).length==0?false:true); } // wrapper getState(id) { return getState(this.statePath + id); } // wie setState(), setzt aber den statePath davor und überpüft aber ob der state vorhanden ist und erzeugt ihn, // wenn er noch nicht da ist setState(id,value) { if ( !this.existState(id) ) this.createState(id, value, undefined); else setState( this.statePath + id, value); } // wie createState, setzt aber noch den statePath davor und schaut im states-Array nach, ob dort common-Angaben // vorhanden sind (wenn der common-Parameter leer ist) createState(id, value, common) { if ( !this.existState(id) ) { if (common===undefined) { // id im states-Array suchen for (var i=0; i<this.states.length; i++) { if (this.states[i].id==id) { if (this.states[i].hasOwnProperty('common')) common = this.states[i].common; break; } } } if ( (typeof value === 'undefined') && (common.hasOwnProperty('def'))) value = common.def; // unter "0_userdata.0" let obj = {}; obj.type = 'state'; obj.native = {}; obj.common = common; setObject(this.statePath + id, obj, (err) => { if (err) { this.log('cant write object for state "' + this.statePath + id + '": ' + err); } else { this.log('state "' + this.statePath + id + '" created'); } }); // value zeitversetzt setzen setTimeout( setState, 3000, this.statePath + id, value ); } } /** Verbindung zum HEOS System herstellen **/ connect() { if ( !this.installed ) return; try { this.log("searching for HEOS devices ...") this.setState( "connected", false ); this.state = stateSEARCHING; const NodeSSDP = require('node-ssdp').Client; this.nodessdp_client = new NodeSSDP(); this.nodessdp_client.explicitSocketBind = true; this.nodessdp_client.on('response', (headers, statusCode, rinfo) => this.onNodeSSDPResponse(headers, statusCode, rinfo) ); this.nodessdp_client.on('error', error => { this.nodessdp_client.close(); this.logError(error); }); this.nodessdp_client.search(this.ssdpSearchTargetName); this.ssdpSearchInterval = setInterval(() => { this.log("still searching for HEOS devices ...") this.nodessdp_client.search(this.ssdpSearchTargetName); }, 30000); } catch(err) { this.logError( 'connect: '+err.message ); } } /** Alle Player stoppen und die TelNet Verbindung schließen **/ disconnect() { this.log('disconnecting from HEOS ...'); unsubscribe(this.statePath.substr(0,this.statePath.length-1)); this.stopHeartbeat(); this.stopPlayers(); if (typeof this.net_client!=='undefined') { this.registerChangeEvents( false ); this.net_client.destroy(); this.net_client.unref(); } if (typeof this.nodessdp_client!=='undefined') { this.nodessdp_client.stop(); } this.setState( "connected", false ); this.log('disconnected from HEOS'); } reconnect(){ this.log('reconnecting to HEOS ...'); this.disconnect(); this.init(); this.connect(); } executeCommand(cmd) { //l('command: '+cmd); switch (cmd) { case 'load_presets' : this.getMusicSources(); break; case 'group/get_groups' : this.getGroups(); break; case 'connect' : this.disconnect(); this.init(); this.connect(); break; case 'disconnect' : this.disconnect(); break; default: if (this.state == stateCONNECTED) { this.msgs.push( 'heos://'+cmd+'\n' ); this.sendNextMsg(); } } } /** es wurde mindestens ein Player erkannt, nun über dessen IP alle bekannten HEOS Player * durch senden von "player/get_players" ermitteln */ onNodeSSDPResponse(headers, statusCode, rinfo) { try { // rinfo {"address":"192.168.2.225","family":"IPv4","port":53871,"size":430} if (typeof this.net_client=='undefined') { if(headers.ST !== this.ssdpSearchTargetName) { // korrektes SSDP this.logDebug('onNodeSSDPResponse: Getting wrong SSDP entry. Keep trying...'); } else { if (this.ssdpSearchInterval!==undefined) { clearInterval(this.ssdpSearchInterval); this.ssdpSearchInterval = undefined; } this.ip = rinfo.address; this.log('connecting to HEOS ('+this.ip+') ...'); this.net_client = net.connect({host:this.ip, port:1255}); this.net_client.setKeepAlive(true, 5000); this.net_client.setNoDelay(true); this.net_client.setTimeout(15000); this.state = stateCONNECTING; this.net_client.on('error',(error) => { this.logError(error); this.reconnect(); }); this.net_client.on('connect', () => { this.setState( "connected", true ); this.state = stateCONNECTED; this.log('connected to HEOS ('+this.ip+')'); this.getPlayers(); this.registerChangeEvents( true ); this.signIn(); this.getMusicSources(); this.getGroups(); this.startHeartbeat(); }); // Gegenseite hat die Verbindung geschlossen this.net_client.on('end', () => { this.logWarn('HEOS closed the connection to '+this.ip); this.state = stateDISCONNECTED; this.reconnect(); }); // timeout this.net_client.on('timeout', () => { this.logWarn('Timeout trying connect to '+this.ip); this.state = stateDISCONNECTED; this.reconnect(); }); // Datenempfang this.net_client.on('data', (data) => this.onData(data) ); } } } catch(err) { this.logError( 'onNodeSSDPResponse: '+err.message ); } } setLastError(err) { this.logWarn(err); /* var val = getState(this.statePath+'last_error').val; var lines = val.splitt('\n'); if ( lines.length > 4) lines.pop(); lines.unshift(err+'\n'); l(lines); this.setState('last_error', lines.toString()); */ } /** es liegen Antwort(en) vor * * {"heos": {"command": "browse/browse", "result": "success", "message": "sid=1028&returned=9&count=9"}, * "payload": [ * {"container": "no", "mid": "s25529", "type": "station", "playable": "yes", "name": "NDR 1 Niedersachsen (Adult Hits)", "image_url": "http://cdn-profiles.tunein.com/s25529/images/logoq.png?t=154228"}, * {"container": "no", "mid": "s56857", "type": "station", "playable": "yes", "name": "NDR 2 Niedersachsen 96.2 (Top 40 %26 Pop Music)", "image_url": "http://cdn-profiles.tunein.com/s56857/images/logoq.png?t=154228"}, * {"container": "no", "mid": "s24885", "type": "station", "playable": "yes", "name": "NDR Info", "image_url": "http://cdn-profiles.tunein.com/s24885/images/logoq.png?t=1"}, {"container": "no", "mid": "s158432", "type": "station", "playable": "yes", "name": "Absolut relax (Easy Listening Music)", "image_url": "http://cdn-radiotime-logos.tunein.com/s158432q.png"}, * {"container": "no", "mid": "catalog/stations/A316JYMKQTS45I/#chunk", "type": "station", "playable": "yes", "name": "Johannes Oerding", "image_url": "https://images-na.ssl-images-amazon.com/images/G/01/Gotham/DE_artist/JohannesOerding._SX200_SY200_.jpg"}, * {"container": "no", "mid": "catalog/stations/A1O1J39JGVQ9U1/#chunk", "type": "station", "playable": "yes", "name": "Passenger", "image_url": "https://images-na.ssl-images-amazon.com/images/I/71DsYkU4QaL._SY500_CR150,0,488,488_SX200_SY200_.jpg"}, * {"container": "no", "mid": "catalog/stations/A1W7U8U71CGE50/#chunk" **/ onData(data) { try { data = data.toString(); data=data.replace(/[\n\r]/g, ''); // Steuerzeichen "CR" entfernen // es können auch mehrere Antworten vorhanden sein! {"heos": ... } {"heos": ... } // diese nun in einzelne Antworten zerlegen data = this.unfinishedResponses + data; this.unfinishedResponses = ''; data=data.replace(/{"heos":/g, '|{"heos":'); var responses = data.split('|'); responses.shift(); for (var r=0; r<responses.length; r++ ) if(responses[r].trim().length > 0) { try { JSON.parse(responses[r]); // check ob korrektes JSON Array this.parseResponse(responses[r]); } catch(e) { this.logDebug('onData: invalid json (error: ' + e.message + '): ' + responses[r]); this.unfinishedResponses += responses[r]; } } // wenn weitere Msg zum Senden vorhanden sind, die nächste senden if (this.msgs.length>0) this.sendNextMsg(); } catch(err) { this.logError( 'onData: '+err.message ); } } /** Antwort(en) verarbeiten. Sich wiederholende Antworten ignorieren **/ parseResponse (response) { try { if (response == this.lastResponse || response.indexOf("command under process") > 0 ) return this.lastResponse = response; var jmsg; var i; var jdata = JSON.parse(response); if ( !jdata.hasOwnProperty('heos') || !jdata.heos.hasOwnProperty('command') || !jdata.heos.hasOwnProperty('message') ) return; // msg auswerten try { jmsg = '{"' + decodeURI(jdata.heos.message).replace(/"/g, '\\"').replace(/&/g, '","').replace(/=/g,'":"').replace(/\s/g,'_') + '"}'; jmsg = JSON.parse(jmsg); } catch(err) { jmsg = {}; } this.logDebug('parseResponse: '+response); // result ? var result = 'success'; if (jdata.heos.hasOwnProperty('result') ) result = jdata.heos.result; if ( result!='success' ) { this.setLastError('result='+result+', '+jmsg.text); } // cmd auswerten var cmd = jdata.heos.command.split('/'); var cmd_group = cmd[0]; cmd = cmd[1]; switch (cmd_group) { case 'system': switch(cmd) { case 'heart_beat': this.resetHeartbeatTimeout(); break; } break; case 'event': switch (cmd) { case 'group_volume_changed' : // "heos": {"command": "event/group_volume_changed ","message": "gid='group_id'&level='vol_level'&mute='on_or_off'"} this.getGroups(); break; } break; case 'player': switch (cmd) { // {"heos": {"command": "player/get_players", "result": "success", "message": ""}, // "payload": [{"name": "HEOS Bar", "pid": 1262037998, "model": "HEOS Bar", "version": "1.430.160", "ip": "192.168.2.225", "network": "wifi", "lineout": 0, "serial": "ADAG9170202780"}, // {"name": "HEOS 1 rechts", "pid": -1746612370, "model": "HEOS 1", "version": "1.430.160", "ip": "192.168.2.201", "network": "wifi", "lineout": 0, "serial": "AMWG9170934429"}, // {"name": "HEOS 1 links", "pid": 68572158, "model": "HEOS 1", "version": "1.430.160", "ip": "192.168.2.219", "network": "wifi", "lineout": 0, "serial": "AMWG9170934433"} // ]} case 'get_players' : if (jdata.hasOwnProperty('payload')) { if (jdata.payload.length != this.players.length) { this.stopPlayers(); this.players = []; for (i=0; i<jdata.payload.length; i++) { var player = jdata.payload[i]; this.players.push(player); } this.startPlayers(); } } break; } break; // {"heos": {"command": "browse/get_music_sources", "result": "success", "message": ""}, // "payload": [{"name": "Amazon", "image_url": "https://production...png", "type": "music_service", "sid": 13}, // {"name": "TuneIn", "image_url": "https://production...png", "type": "music_service", "sid": 3}, // {"name": "Local Music", "image_url": "https://production...png", "type": "heos_server", "sid": 1024}, // {"name": "Playlists", "image_url": "https://production...png", "type": "heos_service", "sid": 1025}, // {"name": "History", "image_url": "https://production...png", "type": "heos_service", "sid": 1026}, // {"name": "AUX Input", "image_url": "https://production...png", "type": "heos_service", "sid": 1027}, // {"name": "Favorites", "image_url": "https://production...png", "type": "heos_service", "sid": 1028}]} case 'browse': switch (cmd) { case 'get_music_sources' : if ( (jdata.hasOwnProperty('payload')) ) { for (i=0; i<jdata.payload.length; i++) { var source = jdata.payload[i]; if (source.name=='Favorites') { this.browse(source.sid); } } } break; // {"heos": {"command": "browse/browse", "result": "success", "message": "pid=1262037998&sid=1028&returned=5&count=5"}, // "payload": [{"container": "no", "mid": "s17492", "type": "station", "playable": "yes", "name": "NDR 2 (Adult Contemporary Music)", "image_url": "http://cdn-radiotime-logos.tunein.com/s17492q.png"}, // {"container": "no", "mid": "s158432", "type": "station", "playable": "yes", "name": "Absolut relax (Easy Listening Music)", "image_url": "http://cdn-radiotime-logos.tunein.com/s158432q.png"}, // {"container": "no", "mid": "catalog/stations/A1W7U8U71CGE50/#chunk", "type": "station", "playable": "yes", "name": "Ed Sheeran", "image_url": "https://images-na.ssl-images-amazon.com/images/G/01/Gotham/DE_artist/EdSheeran._SX200_SY200_.jpg"}, // {"container": "no", "mid": "catalog/stations/A1O1J39JGVQ9U1/#chunk", "type": "station", "playable": "yes", "name": "Passenger", "image_url": "https://images-na.ssl-images-amazon.com/images/I/71DsYkU4QaL._SY500_CR150,0,488,488_SX200_SY200_.jpg"}, // {"container": "no", "mid": "catalog/stations/A316JYMKQTS45I/#chunk", "type": "station", "playable": "yes", "name": "Johannes Oerding", "image_url": "https://images-na.ssl-images-amazon.com/images/G/01/Gotham/DE_artist/JohannesOerding._SX200_SY200_.jpg"}], // "options": [{"browse": [{"id": 20, "name": "Remove from HEOS Favorites"}]}]} case 'browse' : if ( (jdata.hasOwnProperty('payload')) ) { for (i=0; i<jdata.payload.length; i++) { var preset = jdata.payload[i]; this.createState( 'presets.'+(i+1)+'.name', preset.name, {name: 'Favoritenname ' }); this.createState( 'presets.'+(i+1)+'.playable', (preset.playable=='yes'?true:false), {name: 'Favorit ist spielbar' }); this.createState( 'presets.'+(i+1)+'.type', preset.type, {name: 'Favorittyp' }); this.createState( 'presets.'+(i+1)+'.image_url', preset.image_url, {name: 'Favoritbild' }); } } break; } break; case 'group': //l('group: '+response); switch (cmd) { // { "heos":{"command":"player/set_group","result":"success", // "message": "gid='new group_id'&name='group_name'&pid='player_id_1, player_id_2,…,player_id_n' // } // } case 'set_group' : this.setGroup( jmsg ); break; // { "heos": {"command":"group/get_volume","result":"success","message": "gid='group_id'&level='vol_level'"} case 'get_volume': if ( jmsg.hasOwnProperty('gid') ) { if ( jmsg.hasOwnProperty('level') ) { this.setState( jmsg.gid+'.group_volume', jmsg.level); } } break; // { "heos": {"command":"group/get_mute","result":"success","message": "gid='group_id'&state='on_or_off'"} case 'get_mute': if ( jmsg.hasOwnProperty('gid') ) { if ( jmsg.hasOwnProperty('state') ) { this.setState( jmsg.gid+'.group_mute', (jmsg.state=='on' ? true : false)); } } break; // { "heos": { "command": "player/get_groups", "result": "success", "message": "" }, // "payload": [{"name":"'group name 1'", "gid": "group id 1'", // "players":[{"name":"player name 1","pid":"'player id1'","role":"player role 1 (leader or member)'"}, // {"name":"player name 2","pid":"'player id2'","role":"player role 2 (leader or member)'"} // ] // }, // {"name":"'group name 2'","gid":"group id 2'", // "players":[{"name":"player name ... case 'get_groups' : // bisherige groups leeren var objs = $(this.statePath+'*.group_name'); for (var o=0; o<objs.length; o++) setState(objs[o], 'no group'); objs = $(this.statePath+'*.group_leader'); for (var o=0; o<objs.length; o++) setState(objs[o], false); objs = $(this.statePath+'*.group_member'); for (var o=0; o<objs.length; o++) setState(objs[o], false); objs = $(this.statePath+'*.group_pid'); for (var o=0; o<objs.length; o++) setState(objs[o], ''); // payload mit den groups auswerten if ( (jdata.hasOwnProperty('payload')) ) { for (i=0; i<jdata.payload.length; i++) { var group = jdata.payload[i]; var players = group.players; // Player IDs addieren. Hinweis: "leader" ist nicht immer der 1.Playereintrag group.pid = ""; for (var p=0; p<players.length; p++) { if ( players[p].role == 'leader' ) group.pid = players[p].pid + (group.pid.length>0?",":"") + group.pid; else group.pid = group.pid + (group.pid.length>0?",":"") + players[p].pid; } this.setGroup(group); } } break; } break; case 'system': switch (cmd) { case 'sign_in' : this.log('signed in: '+jdata.heos.result); break; } break; } // an die zugehörigen Player weiterleiten if ( jmsg.hasOwnProperty('pid') ) { for (i=0; i<this.players.length; i++) if (jmsg.pid==this.players[i].heosPlayer.pid) { this.players[i].heosPlayer.parseResponse (jdata, jmsg, cmd_group, cmd); break; } } } catch(err) { this.logError( 'parseResponse: '+err.message+'\n '+response ); } } // sucht die zur pid passenden player-Insanz sendCommandToPlayer(objID, cmd ) { // aus der objID die pid holen // objID = javascript.1.heos.394645376.command objID = objID.split('.'); var pid = objID[3]; for (var p=0; p<this.players.length; p++ ) if (this.players[p].pid == pid ) { this.players[p].heosPlayer.sendCommand( cmd ); break; } } // Für die gefundenen HEOS Player entsprechende class HeosPlayer Instanzen bilden startPlayers() { try { this.stopPlayers(); let i=0; for (i=0; i<this.players.length; i++) { this.players[i].heosPlayer = new HeosPlayer( this, this.players[i] ); } // alle 30 Sekunden auf neue Player untersuchen this.getPlayersInterval = setInterval(() => { this.getPlayers(); }, 30000); /* // Events setzen for (i=0; i<this.players.length; i++) { let heosPlayer = this.players[i].heosPlayer; heosPlayer.onHandler = []; // on-Event für command heosPlayer.onHandler.push( on({id: heosPlayer.statePath+'command', change: "any"}, (obj) => { this.sendCommandToPlayer( obj.id, obj.state.val ) })); // on-Event für volume (nur wenn ack=false, also über vis) heosPlayer.onHandler.push( on({id: heosPlayer.statePath+'volume', change:'ne', ack:false }, (obj) => { this.sendCommandToPlayer( obj.id, 'set_volume&level='+obj.state.val ); })); // on-Event für mute (nur wenn ack=false, also über vis) heosPlayer.onHandler.push( on({id: heosPlayer.statePath+'mute', change: 'ne', ack:false}, (obj) => { this.sendCommandToPlayer( obj.id, 'set_mute&state='+ (obj.state.val === true ? 'on' : 'off') ); })); // on-Event für play_mode_shuffle (nur wenn ack=false, also über vis) heosPlayer.onHandler.push( on({id: heosPlayer.statePath+'play_mode_shuffle', change: 'ne', ack:false}, (obj) => { this.sendCommandToPlayer( obj.id, 'set_play_mode&shuffle='+ (obj.state.val === true ? 'on' : 'off') ); })); // on-Event für play_state (nur wenn ack=false, also über vis) heosPlayer.onHandler.push( on({id: heosPlayer.statePath+'play_state', change: 'ne', ack:false}, (obj) => { this.sendCommandToPlayer( obj.id, 'set_play_state&state='+ obj.state.val ); })); // on-Event für group-volume (nur wenn ack=false, also über vis) heosPlayer.onHandler.push( on({id: heosPlayer.statePath+'group_volume', change:'ne', ack:false }, (obj) => { // "id":"javascript.1.heos.-1746612370.group_volume" var id = obj.id.split('.'); this.executeCommand( 'group/set_volume?gid='+id[3]+'&level='+obj.state.val ); })); // on-Event für group-mute (nur wenn ack=false, also über vis) heosPlayer.onHandler.push( on({id: heosPlayer.statePath+'group_mute', change:'ne', ack:false }, (obj) => { // "id":"javascript.1.heos.-1746612370.group_volume" var id = obj.id.split('.'); this.executeCommand( 'group/set_mute?gid='+id[3]+'&state='+(obj.state.val===true ? 'on':'off') ); })); } */ } catch(err) { this.logError( 'startPlayers: '+err.message ); } } // stopPlayers() { try { if (this.getPlayersInterval!==undefined) { clearInterval(this.getPlayersInterval); this.getPlayersInterval = undefined; } for (let i=0; i<this.players.length; i++) { let heosPlayer = this.players[i].heosPlayer; if (heosPlayer) heosPlayer.stopPlayer(); // player leeren this.players[i].heosPlayer = undefined; } } catch(err) { this.logError( 'stopPlayers: '+err.message ); } } // setzen der Werte einer Group setGroup(group) { if ( group.hasOwnProperty('pid') ) { // in den Playern den Groupstatus setzen var pids = group.pid.split(','); for (var i=0; i<pids.length; i++) { this.setState(pids[i]+'.group_name', (group.hasOwnProperty('name')?group.name:'')); this.setState(pids[i]+'.group_pid', group.pid); this.setState(pids[i]+'.group_leader', (i==0) && (pids.length>1) ); this.setState(pids[i]+'.group_member', (pids.length>1)); } if ( group.hasOwnProperty('gid') ) { // volume und mute dazu holen this.executeCommand("group/get_volume?gid="+group.gid); this.executeCommand("group/get_mute?gid="+group.gid); } } } getPlayers() { if (this.state == stateCONNECTED) { this.msgs.push( 'heos://player/get_players\n' ); this.sendNextMsg(); } } registerChangeEvents( b ) { if (this.state == stateCONNECTED) { if (b) this.msgs.push( 'heos://system/register_for_change_events?enable=on' ); else this.msgs.push( 'heos://system/register_for_change_events?enable=off' ); this.sendNextMsg(); } } signIn() { if (this.state == stateCONNECTED) { // heos://system/sign_in?un=heos_username&pw=heos_password this.msgs.push( 'heos://system/sign_in?un='+HEOS_USERNAME+'&pw='+HEOS_PASSWORD ); this.sendNextMsg(); } } getMusicSources() { if (this.state == stateCONNECTED) { // heos://browse/get_music_sources this.msgs.push( 'heos://browse/get_music_sources' ); this.sendNextMsg(); } } getGroups() { if (this.state == stateCONNECTED) { // heos://group/get_groups this.msgs.push( 'heos://group/get_groups' ); this.sendNextMsg(); } } startHeartbeat(){ this.logDebug("start heartbeat interval"); this.heartbeatInterval = setInterval(() => { this.logDebug("heartbeat") this.msgs.push( 'heos://system/heart_beat' ); this.sendNextMsg(); if (this.heartbeatTimeout==undefined) { this.heartbeatTimeout = setTimeout(() => { this.log("heartbeat timeout"); this.reconnect(); }, 60000); } }, 15000); } resetHeartbeatTimeout(){ this.logDebug("reset heartbeat timeout"); if (this.heartbeatTimeout!==undefined) { clearTimeout(this.heartbeatTimeout); this.heartbeatTimeout = undefined; } } stopHeartbeat(){ this.logDebug("stop heartbeat interval"); if (this.heartbeatInterval!==undefined) { clearInterval(this.heartbeatInterval); this.heartbeatInterval = undefined; } this.resetHeartbeatTimeout(); } browse(sid) { if (this.state == stateCONNECTED) { // heos://browse/browse?sid=source_id this.msgs.push( 'heos://browse/browse?sid='+sid ); this.sendNextMsg(); } } sendNextMsg () { if (this.msgs.length>0) { var msg = this.msgs.shift(); this.sendMsg(msg); } } // Nachricht an player senden sendMsg (msg) { this.net_client.write(msg + "\n"); this.logDebug("data sent: "+ msg); } } // end of class Heos /******************** * class HeosPlayer ********************/ class HeosPlayer { logDebug(msg) { console.debug('[HeosPlayer '+this.pid+'] '+msg); } log(msg) { console.log('[HeosPlayer '+this.pid+'] '+msg); } logWarn(msg) { console.warn('[HeosPlayer '+this.pid+'] '+msg); } logError(msg) { console.error('[HeosPlayer '+this.pid+'] '+msg); } constructor(heos, player) { this.heos = heos; this.ip = player.ip; this.name = player.name; this.pid = player.pid; this.model = player.model; this.serial = player.serial; this.onHandler = []; // this.statePath = 'javascript.'+instance+'.heos.'+this.ip.replace(/\./g,'_')+"."; this.statePath = '0_userdata.0.heos.'+this.pid+"."; this.log('creating HEOS player with pid '+this.pid+' ('+this.ip+')'); this.states = [ { id:"connected", common:{name:"Verbunden?"} }, { id:"command", common:{name:"Befehl an Heos Player senden"} }, { id:"ip", common:{name:"IP-Adresse", write:false} }, { id:"pid", common:{name:"Player-ID", write:false} }, { id:"name", common:{name:"Name des Players", write:false} }, { id:"model", common:{name:"Modell des Players", write:false} }, { id:"serial", common:{name:"Seriennummer des Players", write:false} }, { id:"last_error", common:{name:"Letzte Fehler", write:false} }, { id:"volume", common:{name:"Aktuelle Lautstärke"} }, { id:"mute", common:{name:"Mute aktiviert?"} }, { id:"play_state", common:{name:"Aktueller Wiedergabezustand"} }, { id:"play_mode_repeat", common:{name:"Wiedergabewiederholung"} }, { id:"play_mode_shuffle", common:{name:"Zufällige Wiedergabe"} }, { id:"now_playing_media_type", common:{name:"Aktuelle Wiedergabemedium", write:false} }, { id:"now_playing_media_song", common:{name:"Aktuelles Lied", write:false} }, { id:"now_playing_media_station", common:{name:"Aktuelle Station", write:false} }, { id:"now_playing_media_album", common:{name:"Aktuelle Wiedergabemedium", write:false} }, { id:"now_playing_media_artist", common:{name:"Aktueller Artist", write:false} }, { id:"now_playing_media_image_url", common:{name:"Aktuelles Coverbild", write:false} }, { id:"now_playing_media_album_id", common:{name:"Aktuelle Album-ID", write:false} }, { id:"now_playing_media_mid", common:{name:"Aktuelle mid", write:false} }, { id:"now_playing_media_qid", common:{name:"Aktuelle qid", write:false} }, { id:"cur_pos", common:{name:"lfd. Position"} }, { id:"duration", common:{name:"Dauer", write:false} }, { id:"cur_pos_MMSS", common:{name:"lfd. Position MM:SS"} } , { id:"duration_MMSS", common:{name:"Dauer MM:SS", write:false} }, { id:"group_leader", common:{name:"Ist Gruppenleiter", write:false} }, { id:"group_member", common:{name:"Ist Gruppenmitglied", write:false} }, { id:"group_pid", common:{name:"PlayerIDs in der Gruppe", write:false} }, { id:"group_volume", common:{name:"Lautstärke wenn Gruppen-Leiter"}}, { id:"group_name", common:{name:"Name der Gruppe", write:false} } , { id:"group_mute", common:{name:"Gruppe gemutet?"} }, { id:"script_version", common:{name:"Installierte Script-Version", write:false} } ]; // beim 1.Start die states erzeugen if ( !this.existState("script_version") || this.getState('script_version').val!=SCRIPTVERSION ) { this.installed = false; this.logWarn('creating HEOS player states for version '+SCRIPTVERSION+', please start script again'); // states erzeugen for (var s=0; s<this.states.length; s++) { this.setState( this.states[s].id); } this.setState('script_version', SCRIPTVERSION); } else { // Initialisierung der States nach 5 Sek setTimeout(() => { this.setState('ip',this.ip); this.setState('name',this.name); this.setState('pid',this.pid); this.setState('model',this.model); this.setState('serial',this.serial); this.sendCommand('get_play_state|get_play_mode|get_now_playing_media|get_volume'); }, 5000 ); setTimeout(() => { this.startPlayer(); }, 10000 ); this.installed = true; } } // über den $-Operator nachsehen, ob der state bereits vorhanden ist // getState().notExists geht auch, erzeugt aber Warnmeldungen! existState(id) { return ( $(this.statePath+id).length==0?false:true); } // wrapper getState(id) { return getState(this.statePath + id); } // wie setState(), setzt aber den statePath davor und überpüft aber ob der state vorhanden ist und erzeugt ihn, // wenn er noch nicht da ist setState(id,value) { if ( !this.existState(id) ) this.createState(id, value, undefined); else setState( this.statePath + id, value); } // wie createState, setzt aber noch den statePath davor und schaut im states-Array nach, ob dort common-Angaben // vorhanden sind (wenn der common-Parameter leer ist) createState(id, value, common) { if ( !this.existState(id) ) { if (common===undefined) { // id im states-Array suchen for (var i=0; i<this.states.length; i++) { if (this.states[i].id==id) { if (this.states[i].hasOwnProperty('common')) common = this.states[i].common; break; } } } if ( (typeof value === 'undefined') && (common.hasOwnProperty('def'))) value = common.def; // unter "0_userdata.0" let obj = {}; obj.type = 'state'; obj.native = {}; obj.common = common; setObject(this.statePath + id, obj, (err) => { if (err) { this.log('cant write object for state "' + this.statePath + id + '": ' + err); } else { this.log('state "' + this.statePath + id + '" created'); } }); // value zeitversetzt setzen setTimeout( setState, 3000, this.statePath + id, value ); } } startPlayer() { try { this.log('starting HEOS player with pid '+this.pid+' ('+this.ip+')'); this.onHandler = []; // on-Event für command this.onHandler.push( on({id: this.statePath+'command', change: "any"}, (obj) => { this.executeCommand( obj.id, obj.state.val ) })); // on-Event für volume (nur wenn ack=false, also über vis) this.onHandler.push( on({id: this.statePath+'volume', change:'ne', ack:false }, (obj) => { this.executeCommand( obj.id, 'set_volume&level='+obj.state.val ); })); // on-Event für mute (nur wenn ack=false, also über vis) this.onHandler.push( on({id: this.statePath+'mute', change: 'ne', ack:false}, (obj) => { this.executeCommand( obj.id, 'set_mute&state='+ (obj.state.val === true ? 'on' : 'off') ); })); // on-Event für play_mode_shuffle (nur wenn ack=false, also über vis) this.onHandler.push( on({id: this.statePath+'play_mode_shuffle', change: 'ne', ack:false}, (obj) => { this.executeCommand( obj.id, 'set_play_mode&shuffle='+ (obj.state.val === true ? 'on' : 'off') ); })); // on-Event für play_state (nur wenn ack=false, also über vis) this.onHandler.push( on({id: this.statePath+'play_state', change: 'ne', ack:false}, (obj) => { this.executeCommand( obj.id, 'set_play_state&state='+ obj.state.val ); })); // on-Event für group-volume (nur wenn ack=false, also über vis) this.onHandler.push( on({id: this.statePath+'group_volume', change:'ne', ack:false }, (obj) => { // "id":"javascript.1.heos.-1746612370.group_volume" var id = obj.id.split('.'); this.executeCommand( 'group/set_volume?gid='+id[3]+'&level='+obj.state.val ); })); // on-Event für group-mute (nur wenn ack=false, also über vis) this.onHandler.push( on({id: this.statePath+'group_mute', change:'ne', ack:false }, (obj) => { // "id":"javascript.1.heos.-1746612370.group_volume" var id = obj.id.split('.'); this.heos.executeCommand( 'group/set_mute?gid='+id[3]+'&state='+(obj.state.val===true ? 'on':'off') ); })); this.setState('connected', true); } catch(err) { this.logError( 'startPlayer: '+err.message ); } } // sucht die zur pid passenden player-Insanz executeCommand(objID, cmd ) { // aus der objID die pid holen // objID = javascript.1.heos.394645376.command objID = objID.split('.'); var pid = objID[3]; if (this.pid == pid ) { this.sendCommand( cmd ); } } stopPlayer() { try { this.log('stopping HEOS player with pid '+this.pid+' ('+this.ip+')'); // events unsubcribe for (let i=0; i<this.onHandler.length; i++) { if (this.onHandler[i]!==undefined) unsubscribe(this.onHandler[i]); } this.onHandler = []; // connected zurücksetzen this.setState( "connected", false ); } catch(err) { this.logError( 'stopPlayer: '+err.message ); } } /** wandelt einen sek Wert in MM:SS Darstellung um **/ toMMSS (s) { var sec_num = parseInt(s, 10); var minutes = Math.floor(sec_num / 60); var seconds = sec_num - (minutes * 60); if (seconds < 10) {seconds = "0"+seconds;} return minutes+':'+seconds; } setLastError(err) { try { this.logWarn(err); let val = this.getState('last_error').val; let lines = val.splitt('\n'); if ( lines.length > 4) lines.pop(); lines.unshift(err+'\n'); this.setState('last_error', lines.toString()); } catch(e) { this.logError( 'setLastError: '+e.message ); } } /** Auswertung der empfangenen Daten **/ parseResponse (jdata, jmsg, cmd_group, cmd) { try { switch (cmd_group) { case 'event': switch (cmd) { case 'player_playback_error' : this.setLastError(jmsg.error.replace(/_/g,' ')); break; case 'player_state_changed' : this.setState("play_state", jmsg.state); break; case 'player_volume_changed' : this.setState("volume", jmsg.level ); this.setState("mute", (jmsg.mute=='on' ? true : false) ); break; case 'player_repeat_mode_changed' : this.setState("play_mode_shuffle", jmsg.shuffle ); break; case 'player_shuffle_mode_changed' : this.setState("play_mode_repeat", jmsg.repeat ); break; case 'player_now_playing_changed' : this.sendCommand('get_now_playing_media'); break; case 'player_now_playing_progress' : this.setState("cur_pos", jmsg.cur_pos / 1000); this.setState("cur_pos_MMSS", this.toMMSS(jmsg.cur_pos / 1000)); this.setState("duration", jmsg.duration / 1000); this.setState("duration_MMSS", this.toMMSS(jmsg.duration / 1000)); break; } break; case 'player': switch (cmd) { case 'set_volume' : case 'get_volume' : if ( getState(this.statePath+"volume").val != jmsg.level) this.setState("volume", jmsg.level); break; case 'set_mute' : case 'get_mute' : this.setState("mute", (jmsg.state=='on' ? true : false) ); break; case 'set_play_state' : case 'get_play_state' : this.setState("play_state", jmsg.state); break; case 'set_play_mode' : case 'get_play_mode' : this.setState("play_mode_repeat", jmsg.repeat); this.setState("play_mode_shuffle", (jmsg.shuffle=='on'?true:false) ); break; case 'get_now_playing_media' : this.setState("now_playing_media_type", jdata.payload.type); if (jdata.payload.hasOwnProperty('song')) this.setState("now_playing_media_song", jdata.payload.song); if (jdata.payload.hasOwnProperty('album')) this.setState("now_playing_media_album", jdata.payload.album); if (jdata.payload.hasOwnProperty('album_id')) this.setState("now_playing_media_album_id", jdata.payload.album_id); if (jdata.payload.hasOwnProperty('artist')) this.setState("now_playing_media_artist", jdata.payload.artist); if (jdata.payload.hasOwnProperty('image_url')) this.setState("now_playing_media_image_url", jdata.payload.image_url); if (jdata.payload.hasOwnProperty('mid')) this.setState("now_playing_media_mid", jdata.payload.mid); if (jdata.payload.hasOwnProperty('qid')) this.setState("now_playing_media_qid", jdata.payload.qid); if (jdata.payload.hasOwnProperty('station')) { if (jdata.payload.type=='station') { this.setState("now_playing_media_station", jdata.payload.station); } else { this.setState("now_playing_media_station", null); } } break; } break; } // switch } catch(err) { this.logError( 'parseResponse: '+err.message ); } } /** cmd der Form "cmd¶m" werden zur msg heos+cmd+pid+¶m aufbereitet cmd der Form "cmd?param" werden zur msg heos+cmd+?param aufbereitet **/ commandToMsg (cmd) { var param = cmd.split('&'); cmd = param[0]; if ( param.length > 1 ) param='&'+param[1]; else param=''; var cmd_group = 'player'; switch (cmd) { case 'get_play_state': case 'get_play_mode': case 'get_now_playing_media': case 'get_volume': case 'play_next': case 'play_previous': case 'set_mute': // &state=on|off case 'set_volume': // &level=1..100 case 'volume_down': // &step=1..10 case 'volume_up': // &step=1..10 case 'set_play_state': // &state=play|pause|stop case 'set_play_mode': // &repeat=on_all|on_one|off shuffle=on|off break; // browse case 'play_preset': // heos://browse/play_preset?pid=player_id&preset=preset_position cmd_group = 'browse'; break; case 'play_stream': // heos://browse/play_stream?pid=player_id&url=url_path cmd_group = 'browse'; break; } return 'heos://'+cmd_group+'/'+cmd+'?pid=' + this.pid + param; } /** Nachricht (command) an player senden es sind auch mehrere commands, getrennt mit | erlaubt bsp: set_volume&level=20|play_preset&preset=1 **/ sendCommand (command) { if ( !this.installed ) return; var cmds = command.split('|'); for (var c=0; c<cmds.length; c++) { this.heos.msgs.push( this.commandToMsg(cmds[c]) ); } this.heos.sendNextMsg(); } } // end of HeosPlayer /* ----- Heos ----- */ // Heos Instanz erzeugen und verbinden var heos = new Heos( ); heos.connect(); // wenn das Script beendet wird, dann auch die Heos Instanz beenden onStop(function () { heos.disconnect(); }, 0 );
-
@withstu Ja, dass der keep alive nicht funktioniert, habe ich auch schon festgestellt. Vermutlich haben die Player keinen entsprechenden Server implementiert. Den Heartbeat und die anderen Änderungen sehe ich mir an und übernehme sie, danke schön!
-
@Uhula Mein Script hat gerade mein ioBroker lahmgelegt und im Millisekundentakt die Geräte muted/unmuted. Ursache war, dass in den zwei setState Funktionen das ack fehlt:
// wie setState(), setzt aber den statePath davor und überpüft aber ob der state vorhanden ist und erzeugt ihn, // wenn er noch nicht da ist setState(id,value) { if ( !this.existState(id) ) this.createState(id, value, undefined); else setState( this.statePath + id, value, true); }
Btw. ich habe die Events sources_changed/players_changed/groups_changed/group_volume_changed angepasst/hinzugefügt. Dadurch kann ich mir das getPlayers alle 30 Sekunden sparen:
var jmsg; var i; var jdata = JSON.parse(response); if ( !jdata.hasOwnProperty('heos') || !jdata.heos.hasOwnProperty('command')) return; [...] case 'event': switch (cmd) { case 'sources_changed': this.getMusicSources(); break; case 'players_changed': this.getPlayers(); break; case 'groups_changed' : this.getGroups(); break; case 'group_volume_changed' : // "heos": {"command": "event/group_volume_changed ","message": "gid='group_id'&level='vol_level'&mute='on_or_off'"} if ( jmsg.hasOwnProperty('gid') ) { if ( jmsg.hasOwnProperty('level') ) { this.setState( jmsg.gid+'.group_volume', jmsg.level); } if ( jmsg.hasOwnProperty('mute') ) { this.setState( jmsg.gid+'.group_mute', (jmsg.mute=='on' ? true : false)); } } break; }
-
Kann es sein, dass HEOS gerade down ist? Script läuft nicht und in der App kann ich mich auch nicht einloggen...
###Edit###
Der AVR brauchte ein Update, jetzt läuft wieder alles...
-
@Brati Ja ich habe grad auch wieder Probleme mich einzuloggen...
@Uhula Ich habe in den letzten Tagen noch ein paar Bugs gefixt und das get_players Verhalten umgestellt. Jetzt werden einzelne Player disconnected und nicht mehr alle.
/**************************** ### HEOS Script for ioBroker #### Funktion Das Script stellt zwei JS Klassen zur Steuerung der Denon HEOS Geräte zur Verfügung. Die class Heos dient dabei dem Erkennen und Steuern der HEOS Geräte. Die class HeosPlayer dient der Interpretation der Antworten der Heos Geräte. Das Script muss lediglich gestartet werden, es sucht dann im Netzwerk nach HEOS Playern und erzeugt in der aktuellen Javascript-Instanz einen Subeintrag für die Favoriten und je einen Subeintrag für jeden gefundenen Player. Dort wiederum werden State-Variablen zur Aufnahme der Player-Daten angelegt. Das Script erzeugt auch on-Handler um auf Steuerungen über vis und andere Scripte an den State-Variablen reagieren zu können. Beim Beenden des Scripts werden alle Verbindungen und on-Handler geschlossen. Das Script ermöglicht die HEOS Player zu steuern, es soll aber nicht die HEOS App ersetzen. #### Wichtig! * Im Javascript-Adapter muss in der Instanz-Konfiguration "node-ssdp" mit angegeben werden! Alternativ kann die Bibliothek auch komplett über "npm install node-ssdp" installiert werden. * Wenn mit Favoriten (Presets) gearbeitet werden soll, müssen die HEOS-Konto Logindaten in den beiden Konstanten HEOS_USERNAME (EmailAdr) und HEOS_PASSWORD hier im Script in der Konfiguration angegeben werden (so, wie in der HEOS App)! * Eine Konfiguration von IP-Adressen und Player-IDs ist nicht notwendig! * U.U. kann es notwendig sein das Script nach dem 1.Start zu beenden und nach 30 Sek erneut zu starten, da die Neuanlage der ioBroker States etwas Zeit benötigt. Warnungen sind dabei zu ignorieren. ;-) #### Updates ##### v2.0 13.03.2020 * das Script überprüft nun, ob alle notwendigen states für Heos und HeosPlayer im ioBroker korrekt erzeugt wurden und gibt eine Warnung mit Script-Neustartaufforderung aus, wenn die states nicht vorhanden sind. Dieses ist notwendig, da das Anlegen von states asynchron erfolgt und einige ms dauern kann * (WICHTIG!) die states werden nicht mehr in der aktuellen Javascript-Instanz und auch nicht mehr unter der IP des Heos-Players gespeichert, sondern Javascript-Instanzunabhängig unter 0_userdata.0 und seiner Player-ID (pid), da diese sich nicht ändert. Die IP hingegen u.U. schon. Also statt "javascript.X.heos.192_168_2_43" nun als "0_userdata.0.heos.12345678". Wer bereits auf die alten Werte im VIS zugreift, muss dieses im VIS anpassen! * es wurden Befehle/states für die Gruppensteuerung der Heos-Player hinzugefügt. Beim Start werden die Gruppen ausgelesen und in den Player-states des Gruppenleiters (der erste genannte Player) gespeichert. Über dessen states kann die Gruppe gesteuert werden: group_volume: dient der Steuerung der Gruppen-Lautstärke group_mute: dient der Steuerung der Gruppen-Mutes group_leader: ist true, wenn der Player der Gruppenleiter ist group_member: ist true, wenn der Player Mitglied einer Gruppe ist * alle 60 Sek wird überprüft, ob noch alle HEOS-Player erreichbar sind bzw. neue/reconnectete hinzugekommen sind. Neue/reconnectete werden der Liste der HEOS-Player hinzugefügt, fehlende werden gestoppt. Da hierzu das HEOS-Netzwerk gefragt wird (get_players Meldung), kann es bis zu 5 Min dauern bis Änderungen erkannt werden. Der neue HEOS-Player-State "connected" wird entsprechend gesetzt * jeder HEOS-Player hat einen neuen state "connected", dieser wird nach erfolgreichem Start auf true gesetzt, bei korrektem Beenden des Scripts auf false * diverse Filter/Korrekturen aus dem Forum übernommen, vielen dank an withstu #### class Heos Diese Basisklasse dient * dem Erkennen von HEOS Geräten (Playern). Das Erkennen der HEOS Geräte findet via UPD unter Nutzung von node-ssdp statt. node-ssdp muss dazu im Javascript-Adapter in der Konfiguration mit angegeben werden! * der Kommunikation über TelNet. Es wird genau eine TelNet Verbindung zu einem HEOS Gerät aufgebaut, dieses reicht die Sendungen und Antworten zentral weiter * der Ermittlung von Favoriten (Presets). Für jeden Favorit wird ein ioBroker State heos.presets.n mit entsprechenden Sub-States erzeugt. Zur Nutzung der Presets ist ein SignIn notwendig, hierzu müssen HEOS_USERNAME und HEOS_PASSWORD gesetzt werden. * dem Instanziieren der class HeosPlayer je HEOS Gerät. Je HEOS Gerät wird ein ioBroker-State mit der IP-Adresse des Players erzeugt, dieser State erhält diverse Sub-States ##### State-Variable: Heos Werden angelegt unter "0_userdata.0.heos." * connected: true wenn die Verbindung zu mindestens einem HEOS-Player besteht * script_version: Version des Scripts, dient dazu Script-Updates zu erkennen * last_error: Letzter Fehlertext * command(cmd): Hierüber können der Heos-Klasse Befehle übergeben werden, für cmd gilt: "connect": Verbindung zu HEOS aufbauen bzw. erneut aufbauen (praktisch ein reset) "disconnect": Verbindung zu HEOS beenden "load_presets": lädt die Favoriten neu "group/set_group?pid=<pid1>,<pid2>,...": setzen einer Gruppe, die pids sind die der Player wie sie unter den Objekten für jeden Player abgelegt sind. Bsp: "group/set_group?pid=12345678,12345679". "group/set_group?pid=<pid1>" : hebt die Gruppierung des Players mit der pid1 wieder auf. Bsp: "group/set_group?pid=12345678" "...": alle anderen cmd-Werte werden "as is" versucht an HEOS zu senden * presets.<n>: Hierunter werden die Favoriten, Presets angelegt. Je Favorit ein Unterordner n=1 bis Anzahl. Darunter befinden sich dann die States, welche den Inhalt der Favoriten aufnehmen: * image_url (read): Bild des Favoriten * name (read): Name des Favoriten * playable (read): Spielbar? true/false * type (read): Typ des Favoriten (station, ...) #### class HeosPlayer Diese Klasse dient * der Steuerung genau eines HEOS Gerätes. Hierzu wird die zentrale Telnet Verbindung der class Heos genutzt. * dem Erzeugen der ioBroker-States zum Speichern der Geräte-Werte * der Auswertung von Antworten der HEOS Geräte und Zuweisung an die entsprechenden ioBroker-States ##### State-Variable Werden angelegt unter "0_userdata.0.heos.<pid>" * connected : true, wenn eine Verbindung besteht, sonst false * command(cmd): Hierüber können dem Heos-Player Befehle übergeben werden. Diese werden im Klartext in die State-Variable geschrieben. Getrennt durch das | Zeichen können mehrere hintereinander eingetragen werden. Bsp: Setzen der Lautstärke auf 20 und Abspielen des 1.Favoriten "set_volume&level=20|play_preset&preset=1|set_play_state&state=play". Folgende cmd sind erlaubt: "set_volume&level=0|1|..|100" : Setzt die gewünschte Lautstärke "set_play_state&state=play|pause|stop" : Startet und stoppt die Wiedergabe "set_play_mode&repeat=on_all|on_one|off&shuffle=on|off": Setzt Wiederholung und Zufallsweidergabe "set_mute&state=on|off" : Stumm schalten oder nicht "volume_down&step=1..10" : Lautstärke verringern um "volume_up&step=1..10" : Lauststäre erhöhen um "play_next" : Nächsten Titel spielen "play_previous" : Vorherigen Titel spielen "play_preset&preset=1|2|..|n" : Favorit Nr n abspielen "play_stream&url=url_path" : URL-Stream abspielen * cur_pos (read) : lfd. Position der Wiedergabe in [Sek] * cur_pos_MMSS (read) : lfd. Position der Wiedergabe im Format MM:SS * duration (read) : Länge des aktuellen Titels in [Sek] * duration_MMSS (read) : Länge des aktuellen Titels im Format MM:SS * ip (read): IP-Adresse des HEOS Players * last_error (read): Text des letzten Fehlers, der vom Player gesendet wurde. Wird bei jedem neuen Befehl zurückgesetzt. Wenn man bspw. versucht eine Wiedergabe über Amazon o.ä. zu starten und es gibt aber keine Verbindung dahin, steht hier der Fehlertext drin * model (read): Modell des HEOS Players * mute (read write): Boolsche Variable, die anzeigt ob der Player gemutet (Stumm geschaltet) wurde. Hierüber kann auch ein mute gesetzt werden * name (read): Name des HEOS Players * now_playing_media_... (read) : Eine Gruppe von States, in welchen Infos über den aktuellen Titel gespeichert werden, wird automatisch aktualisiert. * pid (read): Player-ID des HEOS Players * play_mode_repeat (read write) : Repeat-Modus der Wiedergabe. Mögliche Werte: on_all|on_one|off * play_mode_shuffle (read write) : Zufallswiedergabe true/false * play_state (read write): Zustand des Players. Mögliche Werte play|pause|stop * serial (read): Seriennummmer des HEOS Players * volume (read write): Lautstärke im Bereich 0 - 100. * group_leader : Ist Gruppenleiter * group_member : Ist Gruppenmitglied * group_pid : Player pids in der Gruppe * group_volume : Lautstärke wenn Gruppen-Leiter * group_name : Name der Gruppe * group_mute : Gruppe gemutet? #### Weiterführende Links * HEOS CLI Protokoll: http://rn.dmglobal.com/euheos/HEOS_CLI_ProtocolSpecification.pdf * http://forum.iobroker.net/viewtopic.php?f=30&t=5693&p=115554#p115554 (c) Uhula, MIT License, no warranty, use on your own risc */ /**************************** * Konfiguration ****************************/ const HEOS_USERNAME = ''; const HEOS_PASSWORD = ''; const HEARTBEAT_INTERVAL = 15000; const HEARTBEAT_TIMEOUT = 60000; /**************************** * ab hier nichts mehr ändern ;-) ****************************/ var net = require('net'); const stateDISCONNECTED = 0; const stateSEARCHING = 1; const stateCONNECTING = 2; const stateCONNECTED = 3; const SCRIPTVERSION = '2.0/2020-03-21'; /******************** * class Heos ********************/ class Heos { logDebug(msg) { console.debug('[Heos] ' + msg); } log(msg) { console.log('[Heos] ' + msg); } logWarn(msg) { console.warn('[Heos] ' + msg); } logError(msg) { console.error('[Heos] ' + msg); } constructor() { this.init(); this.states = [ { id: "command", common: { name: "Kommando für HEOS Aufrufe" } }, { id: "connected", common: { name: "Verbunden?", write: false, def: false } }, { id: "last_error", common: { name: "Letzte Fehler", write: false } }, { id: "script_version", common: { name: "Installierte Script-Version", write: false } } ]; // beim 1.Start nur die States erzeugen if (!this.existState("script_version") || (this.getState('script_version').val != SCRIPTVERSION)) { this.installed = false; this.logWarn('creating HEOS states for version ' + SCRIPTVERSION + ', please start script again'); // Anlage der States for (var s = 0; s < this.states.length; s++) { this.setState(this.states[s].id); } this.setState('script_version', SCRIPTVERSION); } else { this.installed = true; on({ id: this.statePath + 'command', change: "any" }, (obj) => { this.executeCommand(obj.state.val); }); } } // Initialisierung init() { this.statePath = '0_userdata.0.heos.'; this.players = {}; this.heartbeatInterval = undefined; this.heartbeatTimeout = undefined; this.ssdpSearchInterval = undefined; this.net_client = undefined; this.nodessdp_client = undefined; this.ip = ''; this.msgs = []; this.lastResponse = ''; this.state = stateDISCONNECTED; this.unfinishedResponses = ''; this.ssdpSearchTargetName = 'urn:schemas-denon-com:device:ACT-Denon:1'; } // über den $-Operator nachsehen, ob der state bereits vorhanden ist // getState().notExists geht auch, erzeugt aber Warnmeldungen! existState(id) { return ($(this.statePath + id).length == 0 ? false : true); } // wrapper getState(id) { return getState(this.statePath + id); } // wie setState(), setzt aber den statePath davor und überpüft aber ob der state vorhanden ist und erzeugt ihn, // wenn er noch nicht da ist setState(id, value) { if (!this.existState(id)) this.createState(id, value, undefined); else setState(this.statePath + id, value, true); } // wie createState, setzt aber noch den statePath davor und schaut im states-Array nach, ob dort common-Angaben // vorhanden sind (wenn der common-Parameter leer ist) createState(id, value, common) { if (!this.existState(id)) { if (common === undefined) { // id im states-Array suchen for (var i = 0; i < this.states.length; i++) { if (this.states[i].id == id) { if (this.states[i].hasOwnProperty('common')) common = this.states[i].common; break; } } } if ((typeof value === 'undefined') && (common.hasOwnProperty('def'))) value = common.def; // unter "0_userdata.0" let obj = {}; obj.type = 'state'; obj.native = {}; obj.common = common; setObject(this.statePath + id, obj, (err) => { if (err) { this.log('cant write object for state "' + this.statePath + id + '": ' + err); } else { this.log('state "' + this.statePath + id + '" created'); } }); // value zeitversetzt setzen setTimeout(setState, 3000, this.statePath + id, value); } } /** Verbindung zum HEOS System herstellen **/ connect() { if (!this.installed) return; try { this.log("searching for HEOS devices ...") this.setState("connected", false); this.state = stateSEARCHING; const NodeSSDP = require('node-ssdp').Client; this.nodessdp_client = new NodeSSDP(); this.nodessdp_client.explicitSocketBind = true; this.nodessdp_client.on('response', (headers, statusCode, rinfo) => this.onNodeSSDPResponse(headers, statusCode, rinfo)); this.nodessdp_client.on('error', error => { this.nodessdp_client.close(); this.logError(error); }); this.nodessdp_client.search(this.ssdpSearchTargetName); this.ssdpSearchInterval = setInterval(() => { this.log("still searching for HEOS devices ...") this.nodessdp_client.search(this.ssdpSearchTargetName); }, 30000); } catch (err) { this.logError('connect: ' + err.message); } } /** Alle Player stoppen und die TelNet Verbindung schließen **/ disconnect() { this.log('disconnecting from HEOS ...'); unsubscribe(this.statePath.substr(0, this.statePath.length - 1)); this.stopHeartbeat(); this.stopPlayers(); if (typeof this.net_client !== 'undefined') { this.registerChangeEvents(false); this.net_client.destroy(); this.net_client.unref(); } if (typeof this.nodessdp_client !== 'undefined') { this.nodessdp_client.stop(); } this.setState("connected", false); this.state = stateDISCONNECTED; this.log('disconnected from HEOS'); } reconnect() { if (this.state == stateCONNECTED) { this.log('reconnecting to HEOS ...'); this.disconnect(); setTimeout(() => { this.init(); this.connect(); }, 5000); } } executeCommand(cmd) { //l('command: '+cmd); switch (cmd) { case 'load_presets': this.getMusicSources(); break; case 'group/get_groups': this.getGroups(); break; case 'connect': this.disconnect(); this.init(); this.connect(); break; case 'disconnect': this.disconnect(); break; default: if (this.state == stateCONNECTED) { this.msgs.push('heos://' + cmd + '\n'); this.sendNextMsg(); } } } /** es wurde mindestens ein Player erkannt, nun über dessen IP alle bekannten HEOS Player * durch senden von "player/get_players" ermitteln */ onNodeSSDPResponse(headers, statusCode, rinfo) { try { // rinfo {"address":"192.168.2.225","family":"IPv4","port":53871,"size":430} if (typeof this.net_client == 'undefined') { if (headers.ST !== this.ssdpSearchTargetName) { // korrektes SSDP this.logDebug('onNodeSSDPResponse: Getting wrong SSDP entry. Keep trying...'); } else { if (this.ssdpSearchInterval) { clearInterval(this.ssdpSearchInterval); this.ssdpSearchInterval = undefined; } this.ip = rinfo.address; this.log('connecting to HEOS (' + this.ip + ') ...'); this.net_client = net.connect({ host: this.ip, port: 1255 }); this.net_client.setKeepAlive(true, 5000); this.net_client.setNoDelay(true); this.net_client.setTimeout(15000); this.state = stateCONNECTING; this.net_client.on('error', (error) => { this.logError(error); this.reconnect(); }); this.net_client.on('connect', () => { this.setState("connected", true); this.state = stateCONNECTED; this.log('connected to HEOS (' + this.ip + ')'); this.getPlayers(); this.registerChangeEvents(true); this.signIn(); this.getMusicSources(); this.getGroups(); this.startHeartbeat(); }); // Gegenseite hat die Verbindung geschlossen this.net_client.on('end', () => { this.logWarn('HEOS closed the connection to ' + this.ip); this.reconnect(); }); // timeout this.net_client.on('timeout', () => { this.logWarn('Timeout trying connect to ' + this.ip); this.reconnect(); }); // Datenempfang this.net_client.on('data', (data) => this.onData(data)); } } } catch (err) { this.logError('onNodeSSDPResponse: ' + err.message); } } setLastError(err) { this.logWarn(err); let val = this.getState('last_error').val + ''; let lines = val.split('\n'); if (lines.length > 4) lines.pop(); lines.unshift(err + '\n'); this.setState('last_error', lines.toString()); } /** es liegen Antwort(en) vor * * {"heos": {"command": "browse/browse", "result": "success", "message": "sid=1028&returned=9&count=9"}, * "payload": [ * {"container": "no", "mid": "s25529", "type": "station", "playable": "yes", "name": "NDR 1 Niedersachsen (Adult Hits)", "image_url": "http://cdn-profiles.tunein.com/s25529/images/logoq.png?t=154228"}, * {"container": "no", "mid": "s56857", "type": "station", "playable": "yes", "name": "NDR 2 Niedersachsen 96.2 (Top 40 %26 Pop Music)", "image_url": "http://cdn-profiles.tunein.com/s56857/images/logoq.png?t=154228"}, * {"container": "no", "mid": "s24885", "type": "station", "playable": "yes", "name": "NDR Info", "image_url": "http://cdn-profiles.tunein.com/s24885/images/logoq.png?t=1"}, {"container": "no", "mid": "s158432", "type": "station", "playable": "yes", "name": "Absolut relax (Easy Listening Music)", "image_url": "http://cdn-radiotime-logos.tunein.com/s158432q.png"}, * {"container": "no", "mid": "catalog/stations/A316JYMKQTS45I/#chunk", "type": "station", "playable": "yes", "name": "Johannes Oerding", "image_url": "https://images-na.ssl-images-amazon.com/images/G/01/Gotham/DE_artist/JohannesOerding._SX200_SY200_.jpg"}, * {"container": "no", "mid": "catalog/stations/A1O1J39JGVQ9U1/#chunk", "type": "station", "playable": "yes", "name": "Passenger", "image_url": "https://images-na.ssl-images-amazon.com/images/I/71DsYkU4QaL._SY500_CR150,0,488,488_SX200_SY200_.jpg"}, * {"container": "no", "mid": "catalog/stations/A1W7U8U71CGE50/#chunk" **/ onData(data) { try { data = data.toString(); data = data.replace(/[\n\r]/g, ''); // Steuerzeichen "CR" entfernen // es können auch mehrere Antworten vorhanden sein! {"heos": ... } {"heos": ... } // diese nun in einzelne Antworten zerlegen data = this.unfinishedResponses + data; this.unfinishedResponses = ''; data = data.replace(/{"heos":/g, '|{"heos":'); var responses = data.split('|'); responses.shift(); for (var r = 0; r < responses.length; r++) if (responses[r].trim().length > 0) { try { JSON.parse(responses[r]); // check ob korrektes JSON Array this.parseResponse(responses[r]); } catch (e) { this.logDebug('onData: invalid json (error: ' + e.message + '): ' + responses[r]); this.unfinishedResponses += responses[r]; } } // wenn weitere Msg zum Senden vorhanden sind, die nächste senden if (this.msgs.length > 0) this.sendNextMsg(); } catch (err) { this.logError('onData: ' + err.message); } } /** Antwort(en) verarbeiten. Sich wiederholende Antworten ignorieren **/ parseResponse(response) { try { this.logDebug('parseResponse: ' + response); if (response == this.lastResponse || response.indexOf("command under process") > 0) return this.lastResponse = response; var jmsg; var i; var jdata = JSON.parse(response); if (!jdata.hasOwnProperty('heos') || !jdata.heos.hasOwnProperty('command')) return; // msg auswerten try { jmsg = '{"' + decodeURI(jdata.heos.message).replace(/"/g, '\\"').replace(/&/g, '","').replace(/=/g, '":"').replace(/\s/g, '_') + '"}'; jmsg = JSON.parse(jmsg); } catch (err) { jmsg = {}; } // result ? var result = 'success'; if (jdata.heos.hasOwnProperty('result')) result = jdata.heos.result; if (result != 'success') { this.setLastError('result=' + result + ', ' + jmsg.text); } // cmd auswerten var cmd = jdata.heos.command.split('/'); var cmd_group = cmd[0]; cmd = cmd[1]; switch (cmd_group) { case 'system': switch (cmd) { case 'heart_beat': this.resetHeartbeatTimeout(); break; } break; case 'event': switch (cmd) { case 'players_changed': this.getPlayers(); break; case 'groups_changed': this.getGroups(); break; case 'group_volume_changed': // "heos": {"command": "event/group_volume_changed ","message": "gid='group_id'&level='vol_level'&mute='on_or_off'"} if (jmsg.hasOwnProperty('gid')) { if (jmsg.hasOwnProperty('level')) { this.setState(jmsg.gid + '.group_volume', jmsg.level); } if (jmsg.hasOwnProperty('mute')) { this.setState(jmsg.gid + '.group_mute', (jmsg.mute == 'on' ? true : false)); } } break; } break; case 'player': switch (cmd) { // {"heos": {"command": "player/get_players", "result": "success", "message": ""}, // "payload": [{"name": "HEOS Bar", "pid": 1262037998, "model": "HEOS Bar", "version": "1.430.160", "ip": "192.168.2.225", "network": "wifi", "lineout": 0, "serial": "ADAG9170202780"}, // {"name": "HEOS 1 rechts", "pid": -1746612370, "model": "HEOS 1", "version": "1.430.160", "ip": "192.168.2.201", "network": "wifi", "lineout": 0, "serial": "AMWG9170934429"}, // {"name": "HEOS 1 links", "pid": 68572158, "model": "HEOS 1", "version": "1.430.160", "ip": "192.168.2.219", "network": "wifi", "lineout": 0, "serial": "AMWG9170934433"} // ]} case 'get_players': if (jdata.hasOwnProperty('payload')) { this.startPlayers(jdata.payload); } break; } break; // {"heos": {"command": "browse/get_music_sources", "result": "success", "message": ""}, // "payload": [{"name": "Amazon", "image_url": "https://production...png", "type": "music_service", "sid": 13}, // {"name": "TuneIn", "image_url": "https://production...png", "type": "music_service", "sid": 3}, // {"name": "Local Music", "image_url": "https://production...png", "type": "heos_server", "sid": 1024}, // {"name": "Playlists", "image_url": "https://production...png", "type": "heos_service", "sid": 1025}, // {"name": "History", "image_url": "https://production...png", "type": "heos_service", "sid": 1026}, // {"name": "AUX Input", "image_url": "https://production...png", "type": "heos_service", "sid": 1027}, // {"name": "Favorites", "image_url": "https://production...png", "type": "heos_service", "sid": 1028}]} case 'browse': switch (cmd) { case 'get_music_sources': if ((jdata.hasOwnProperty('payload'))) { for (i = 0; i < jdata.payload.length; i++) { var source = jdata.payload[i]; if (source.name == 'Favorites') { this.browse(source.sid); } } } break; // {"heos": {"command": "browse/browse", "result": "success", "message": "pid=1262037998&sid=1028&returned=5&count=5"}, // "payload": [{"container": "no", "mid": "s17492", "type": "station", "playable": "yes", "name": "NDR 2 (Adult Contemporary Music)", "image_url": "http://cdn-radiotime-logos.tunein.com/s17492q.png"}, // {"container": "no", "mid": "s158432", "type": "station", "playable": "yes", "name": "Absolut relax (Easy Listening Music)", "image_url": "http://cdn-radiotime-logos.tunein.com/s158432q.png"}, // {"container": "no", "mid": "catalog/stations/A1W7U8U71CGE50/#chunk", "type": "station", "playable": "yes", "name": "Ed Sheeran", "image_url": "https://images-na.ssl-images-amazon.com/images/G/01/Gotham/DE_artist/EdSheeran._SX200_SY200_.jpg"}, // {"container": "no", "mid": "catalog/stations/A1O1J39JGVQ9U1/#chunk", "type": "station", "playable": "yes", "name": "Passenger", "image_url": "https://images-na.ssl-images-amazon.com/images/I/71DsYkU4QaL._SY500_CR150,0,488,488_SX200_SY200_.jpg"}, // {"container": "no", "mid": "catalog/stations/A316JYMKQTS45I/#chunk", "type": "station", "playable": "yes", "name": "Johannes Oerding", "image_url": "https://images-na.ssl-images-amazon.com/images/G/01/Gotham/DE_artist/JohannesOerding._SX200_SY200_.jpg"}], // "options": [{"browse": [{"id": 20, "name": "Remove from HEOS Favorites"}]}]} case 'browse': if ((jdata.hasOwnProperty('payload'))) { for (i = 0; i < jdata.payload.length; i++) { var preset = jdata.payload[i]; this.createState('presets.' + (i + 1) + '.name', preset.name, { name: 'Favoritenname ' }); this.createState('presets.' + (i + 1) + '.playable', (preset.playable == 'yes' ? true : false), { name: 'Favorit ist spielbar' }); this.createState('presets.' + (i + 1) + '.type', preset.type, { name: 'Favorittyp' }); this.createState('presets.' + (i + 1) + '.image_url', preset.image_url, { name: 'Favoritbild' }); } } break; } break; case 'group': //l('group: '+response); switch (cmd) { // { "heos":{"command":"player/set_group","result":"success", // "message": "gid='new group_id'&name='group_name'&pid='player_id_1, player_id_2,…,player_id_n' // } // } case 'set_group': this.setGroup(jmsg); break; // { "heos": {"command":"group/get_volume","result":"success","message": "gid='group_id'&level='vol_level'"} case 'get_volume': if (jmsg.hasOwnProperty('gid')) { if (jmsg.hasOwnProperty('level')) { this.setState(jmsg.gid + '.group_volume', jmsg.level); } } break; // { "heos": {"command":"group/get_mute","result":"success","message": "gid='group_id'&state='on_or_off'"} case 'get_mute': if (jmsg.hasOwnProperty('gid')) { if (jmsg.hasOwnProperty('state')) { this.setState(jmsg.gid + '.group_mute', (jmsg.state == 'on' ? true : false)); } } break; // { "heos": { "command": "player/get_groups", "result": "success", "message": "" }, // "payload": [{"name":"'group name 1'", "gid": "group id 1'", // "players":[{"name":"player name 1","pid":"'player id1'","role":"player role 1 (leader or member)'"}, // {"name":"player name 2","pid":"'player id2'","role":"player role 2 (leader or member)'"} // ] // }, // {"name":"'group name 2'","gid":"group id 2'", // "players":[{"name":"player name ... case 'get_groups': // bisherige groups leeren var objs = $(this.statePath + '*.group_name'); for (var o = 0; o < objs.length; o++) setState(objs[o], 'no group'); objs = $(this.statePath + '*.group_leader'); for (var o = 0; o < objs.length; o++) setState(objs[o], false); objs = $(this.statePath + '*.group_member'); for (var o = 0; o < objs.length; o++) setState(objs[o], false); objs = $(this.statePath + '*.group_pid'); for (var o = 0; o < objs.length; o++) setState(objs[o], ''); // payload mit den groups auswerten if ((jdata.hasOwnProperty('payload'))) { for (i = 0; i < jdata.payload.length; i++) { var group = jdata.payload[i]; var players = group.players; // Player IDs addieren. Hinweis: "leader" ist nicht immer der 1.Playereintrag group.pid = ""; for (var p = 0; p < players.length; p++) { if (players[p].role == 'leader') group.pid = players[p].pid + (group.pid.length > 0 ? "," : "") + group.pid; else group.pid = group.pid + (group.pid.length > 0 ? "," : "") + players[p].pid; } this.setGroup(group); } } break; } break; case 'system': switch (cmd) { case 'sign_in': this.log('signed in: ' + jdata.heos.result); break; } break; } // an die zugehörigen Player weiterleiten if (jmsg.hasOwnProperty('pid')) { if(jmsg.pid in this.players){ this.players[jmsg.pid].parseResponse(jdata, jmsg, cmd_group, cmd); } } } catch (err) { this.logError('parseResponse: ' + err.message + '\n ' + response); } } // sucht die zur pid passenden player-Insanz sendCommandToPlayer(objID, cmd) { // aus der objID die pid holen // objID = javascript.1.heos.394645376.command objID = objID.split('.'); var pid = objID[3]; if(pid in this.players){ this.players[pid].sendCommand(cmd); } } stopPlayer(pid){ try { let heosPlayer = this.players[pid]; if (heosPlayer) heosPlayer.stopPlayer(); // player leeren delete this.players[pid]; } catch (err) { this.logError('stopPlayer: ' + err.message); } } // Für die gefundenen HEOS Player entsprechende class HeosPlayer Instanzen bilden und nicht mehr verbundene Player stoppen startPlayers(payload) { try { var connectedPlayers = []; for (var i = 0; i < payload.length; i++) { var player = payload[i]; var pid = player.pid + ''; //Convert to String if(!(pid in this.players)){ this.players[pid] = new HeosPlayer(this, player); } connectedPlayers.push(pid); } //Remove disconnected players for(var pid in this.players) { if(!connectedPlayers.includes(pid)){ this.stopPlayer(pid); } } } catch (err) { this.logError('startPlayers: ' + err.message); } } //Alle Player stoppen stopPlayers() { this.logDebug("Try to stop players:" + JSON.stringify(Object.keys(this.players))); for (var pid in this.players) { this.stopPlayer(pid); } } // setzen der Werte einer Group setGroup(group) { if (group.hasOwnProperty('pid')) { // in den Playern den Groupstatus setzen var pids = group.pid.split(','); for (var i = 0; i < pids.length; i++) { this.setState(pids[i] + '.group_name', (group.hasOwnProperty('name') ? group.name : '')); this.setState(pids[i] + '.group_pid', group.pid); this.setState(pids[i] + '.group_leader', (i == 0) && (pids.length > 1)); this.setState(pids[i] + '.group_member', (pids.length > 1)); } if (group.hasOwnProperty('gid')) { // volume und mute dazu holen this.executeCommand("group/get_volume?gid=" + group.gid); this.executeCommand("group/get_mute?gid=" + group.gid); } } } getPlayers() { if (this.state == stateCONNECTED) { this.msgs.push('heos://player/get_players\n'); this.sendNextMsg(); } } registerChangeEvents(b) { if (this.state == stateCONNECTED) { if (b) this.msgs.push('heos://system/register_for_change_events?enable=on'); else this.msgs.push('heos://system/register_for_change_events?enable=off'); this.sendNextMsg(); } } signIn() { if (this.state == stateCONNECTED) { // heos://system/sign_in?un=heos_username&pw=heos_password this.msgs.push('heos://system/sign_in?un=' + HEOS_USERNAME + '&pw=' + HEOS_PASSWORD); this.sendNextMsg(); } } getMusicSources() { if (this.state == stateCONNECTED) { // heos://browse/get_music_sources this.msgs.push('heos://browse/get_music_sources'); this.sendNextMsg(); } } getGroups() { if (this.state == stateCONNECTED) { // heos://group/get_groups this.msgs.push('heos://group/get_groups'); this.sendNextMsg(); } } startHeartbeat() { if (this.state == stateCONNECTED) { this.logDebug("start heartbeat interval"); this.heartbeatInterval = setInterval(() => { this.logDebug("heartbeat") this.msgs.push('heos://system/heart_beat'); this.sendNextMsg(); if (typeof this.heartbeatTimeout == undefined) { this.heartbeatTimeout = setTimeout(() => { this.log("heartbeat timeout"); this.reboot(); setTimeout(() => { this.reconnect(); }, 1000) }, HEARTBEAT_TIMEOUT); } }, HEARTBEAT_INTERVAL); } } resetHeartbeatTimeout() { this.logDebug("reset heartbeat timeout"); if (this.heartbeatTimeout) { clearTimeout(this.heartbeatTimeout); this.heartbeatTimeout = undefined; } } stopHeartbeat() { this.logDebug("stop heartbeat interval"); if (this.heartbeatInterval) { clearInterval(this.heartbeatInterval); this.heartbeatInterval = undefined; } this.resetHeartbeatTimeout(); } reboot() { if (this.state == stateCONNECTED) { this.logDebug("reboot device"); // heos://system/reboot this.msgs.push('heos://system/reboot'); this.sendNextMsg(); } } browse(sid) { if (this.state == stateCONNECTED) { // heos://browse/browse?sid=source_id this.msgs.push('heos://browse/browse?sid=' + sid); this.sendNextMsg(); } } sendNextMsg() { if (this.msgs.length > 0) { var msg = this.msgs.shift(); this.sendMsg(msg); } } // Nachricht an player senden sendMsg(msg) { this.net_client.write(msg + "\n"); this.logDebug("data sent: " + msg); } } // end of class Heos /******************** * class HeosPlayer ********************/ class HeosPlayer { logDebug(msg) { console.debug('[HeosPlayer ' + this.pid + '] ' + msg); } log(msg) { console.log('[HeosPlayer ' + this.pid + '] ' + msg); } logWarn(msg) { console.warn('[HeosPlayer ' + this.pid + '] ' + msg); } logError(msg) { console.error('[HeosPlayer ' + this.pid + '] ' + msg); } constructor(heos, player) { this.heos = heos; this.ip = player.ip; this.name = player.name; this.pid = player.pid; this.model = player.model; this.serial = player.serial; this.onHandler = []; // this.statePath = 'javascript.'+instance+'.heos.'+this.ip.replace(/\./g,'_')+"."; this.statePath = '0_userdata.0.heos.' + this.pid + "."; this.log('creating HEOS player with pid ' + this.pid + ' (' + this.ip + ')'); this.states = [ { id: "connected", common: { name: "Verbunden?" } }, { id: "command", common: { name: "Befehl an Heos Player senden" } }, { id: "ip", common: { name: "IP-Adresse", write: false } }, { id: "pid", common: { name: "Player-ID", write: false } }, { id: "name", common: { name: "Name des Players", write: false } }, { id: "model", common: { name: "Modell des Players", write: false } }, { id: "serial", common: { name: "Seriennummer des Players", write: false } }, { id: "last_error", common: { name: "Letzte Fehler", write: false } }, { id: "volume", common: { name: "Aktuelle Lautstärke" } }, { id: "mute", common: { name: "Mute aktiviert?" } }, { id: "play_state", common: { name: "Aktueller Wiedergabezustand" } }, { id: "play_mode_repeat", common: { name: "Wiedergabewiederholung" } }, { id: "play_mode_shuffle", common: { name: "Zufällige Wiedergabe" } }, { id: "now_playing_media_type", common: { name: "Aktuelle Wiedergabemedium", write: false } }, { id: "now_playing_media_song", common: { name: "Aktuelles Lied", write: false } }, { id: "now_playing_media_station", common: { name: "Aktuelle Station", write: false } }, { id: "now_playing_media_album", common: { name: "Aktuelle Wiedergabemedium", write: false } }, { id: "now_playing_media_artist", common: { name: "Aktueller Artist", write: false } }, { id: "now_playing_media_image_url", common: { name: "Aktuelles Coverbild", write: false } }, { id: "now_playing_media_album_id", common: { name: "Aktuelle Album-ID", write: false } }, { id: "now_playing_media_mid", common: { name: "Aktuelle mid", write: false } }, { id: "now_playing_media_qid", common: { name: "Aktuelle qid", write: false } }, { id: "cur_pos", common: { name: "lfd. Position" } }, { id: "duration", common: { name: "Dauer", write: false } }, { id: "cur_pos_MMSS", common: { name: "lfd. Position MM:SS" } }, { id: "duration_MMSS", common: { name: "Dauer MM:SS", write: false } }, { id: "group_leader", common: { name: "Ist Gruppenleiter", write: false } }, { id: "group_member", common: { name: "Ist Gruppenmitglied", write: false } }, { id: "group_pid", common: { name: "PlayerIDs in der Gruppe", write: false } }, { id: "group_volume", common: { name: "Lautstärke wenn Gruppen-Leiter" } }, { id: "group_name", common: { name: "Name der Gruppe", write: false } }, { id: "group_mute", common: { name: "Gruppe gemutet?" } }, { id: "script_version", common: { name: "Installierte Script-Version", write: false } } ]; // beim 1.Start die states erzeugen if (!this.existState("script_version") || this.getState('script_version').val != SCRIPTVERSION) { this.installed = false; this.logWarn('creating HEOS player states for version ' + SCRIPTVERSION + ', please start script again'); // states erzeugen for (var s = 0; s < this.states.length; s++) { this.setState(this.states[s].id, ""); } this.setState('script_version', SCRIPTVERSION); } else { // Initialisierung der States nach 5 Sek setTimeout(() => { this.setState('ip', this.ip); this.setState('name', this.name); this.setState('pid', this.pid); this.setState('model', this.model); this.setState('serial', this.serial); this.sendCommand('get_play_state|get_play_mode|get_now_playing_media|get_volume'); }, 5000); setTimeout(() => { this.startPlayer(); }, 10000); this.installed = true; } } // über den $-Operator nachsehen, ob der state bereits vorhanden ist // getState().notExists geht auch, erzeugt aber Warnmeldungen! existState(id) { return ($(this.statePath + id).length == 0 ? false : true); } // wrapper getState(id) { return getState(this.statePath + id); } // wie setState(), setzt aber den statePath davor und überpüft aber ob der state vorhanden ist und erzeugt ihn, // wenn er noch nicht da ist setState(id, value) { if (!this.existState(id)) this.createState(id, value, undefined); else setState(this.statePath + id, value, true); } // wie createState, setzt aber noch den statePath davor und schaut im states-Array nach, ob dort common-Angaben // vorhanden sind (wenn der common-Parameter leer ist) createState(id, value, common) { if (!this.existState(id)) { if (common === undefined) { // id im states-Array suchen for (var i = 0; i < this.states.length; i++) { if (this.states[i].id == id) { if (this.states[i].hasOwnProperty('common')) common = this.states[i].common; break; } } } if ((typeof value === 'undefined') && (common.hasOwnProperty('def'))) value = common.def; // unter "0_userdata.0" let obj = {}; obj.type = 'state'; obj.native = {}; obj.common = common; setObject(this.statePath + id, obj, (err) => { if (err) { this.log('cant write object for state "' + this.statePath + id + '": ' + err); } else { this.log('state "' + this.statePath + id + '" created'); } }); // value zeitversetzt setzen setTimeout(setState, 3000, this.statePath + id, value); } } startPlayer() { try { this.log('starting HEOS player with pid ' + this.pid + ' (' + this.ip + ')'); this.onHandler = []; // on-Event für command this.onHandler.push(on({ id: this.statePath + 'command', change: "any" }, (obj) => { this.executeCommand(obj.id, obj.state.val) })); // on-Event für volume (nur wenn ack=false, also über vis) this.onHandler.push(on({ id: this.statePath + 'volume', change: 'ne', ack: false }, (obj) => { this.executeCommand(obj.id, 'set_volume&level=' + obj.state.val); })); // on-Event für mute (nur wenn ack=false, also über vis) this.onHandler.push(on({ id: this.statePath + 'mute', change: 'ne', ack: false }, (obj) => { this.executeCommand(obj.id, 'set_mute&state=' + (obj.state.val === true ? 'on' : 'off')); })); // on-Event für play_mode_shuffle (nur wenn ack=false, also über vis) this.onHandler.push(on({ id: this.statePath + 'play_mode_shuffle', change: 'ne', ack: false }, (obj) => { this.executeCommand(obj.id, 'set_play_mode&shuffle=' + (obj.state.val === true ? 'on' : 'off')); })); // on-Event für play_state (nur wenn ack=false, also über vis) this.onHandler.push(on({ id: this.statePath + 'play_state', change: 'ne', ack: false }, (obj) => { this.executeCommand(obj.id, 'set_play_state&state=' + obj.state.val); })); // on-Event für group-volume (nur wenn ack=false, also über vis) this.onHandler.push(on({ id: this.statePath + 'group_volume', change: 'ne', ack: false }, (obj) => { // "id":"javascript.1.heos.-1746612370.group_volume" var id = obj.id.split('.'); this.executeCommand('group/set_volume?gid=' + id[3] + '&level=' + obj.state.val); })); // on-Event für group-mute (nur wenn ack=false, also über vis) this.onHandler.push(on({ id: this.statePath + 'group_mute', change: 'ne', ack: false }, (obj) => { // "id":"javascript.1.heos.-1746612370.group_volume" var id = obj.id.split('.'); this.heos.executeCommand('group/set_mute?gid=' + id[3] + '&state=' + (obj.state.val === true ? 'on' : 'off')); })); this.setState('connected', true); } catch (err) { this.logError('startPlayer: ' + err.message); } } // sucht die zur pid passenden player-Insanz executeCommand(objID, cmd) { // aus der objID die pid holen // objID = javascript.1.heos.394645376.command objID = objID.split('.'); var pid = objID[3]; if (this.pid == pid) { this.sendCommand(cmd); } } cleanupNowPlaying(){ for (var s = 0; s < this.states.length; s++) { if(this.states[s].id.startsWith("now_playing")){ this.setState(this.states[s].id, ""); } } } stopPlayer() { try { this.log('stopping HEOS player with pid ' + this.pid + ' (' + this.ip + ')'); // events unsubcribe for (let i = 0; i < this.onHandler.length; i++) { if (this.onHandler[i] !== undefined) unsubscribe(this.onHandler[i]); } this.onHandler = []; //cleanup now playing this.cleanupNowPlaying(); // reset last error this.setState("last_error", ""); // connected zurücksetzen this.setState("connected", false); } catch (err) { this.logError('stopPlayer: ' + err.message); } } /** wandelt einen sek Wert in MM:SS Darstellung um **/ toMMSS(s) { var sec_num = parseInt(s, 10); var minutes = Math.floor(sec_num / 60); var seconds = sec_num - (minutes * 60); if (seconds < 10) { seconds = "0" + seconds; } return minutes + ':' + seconds; } setLastError(err) { try { this.logWarn(err); let val = this.getState('last_error').val + ''; let lines = val.split('\n'); if (lines.length > 4) lines.pop(); lines.unshift(err + '\n'); this.setState('last_error', lines.toString()); } catch (e) { this.logError('setLastError: ' + e.message); } } /** Auswertung der empfangenen Daten **/ parseResponse(jdata, jmsg, cmd_group, cmd) { try { switch (cmd_group) { case 'event': switch (cmd) { case 'player_playback_error': this.setLastError(jmsg.error.replace(/_/g, ' ')); break; case 'player_state_changed': this.setState("play_state", jmsg.state); this.sendCommand('get_now_playing_media'); break; case 'player_volume_changed': this.setState("volume", jmsg.level); this.setState("mute", (jmsg.mute == 'on' ? true : false)); break; case 'player_repeat_mode_changed': this.setState("play_mode_shuffle", jmsg.shuffle); break; case 'player_shuffle_mode_changed': this.setState("play_mode_repeat", jmsg.repeat); break; case 'player_now_playing_changed': this.sendCommand('get_now_playing_media'); break; case 'player_now_playing_progress': this.setState("cur_pos", jmsg.cur_pos / 1000); this.setState("cur_pos_MMSS", this.toMMSS(jmsg.cur_pos / 1000)); this.setState("duration", jmsg.duration / 1000); this.setState("duration_MMSS", this.toMMSS(jmsg.duration / 1000)); break; } break; case 'player': switch (cmd) { case 'set_volume': case 'get_volume': if (getState(this.statePath + "volume").val != jmsg.level) this.setState("volume", jmsg.level); break; case 'set_mute': case 'get_mute': this.setState("mute", (jmsg.state == 'on' ? true : false)); break; case 'set_play_state': case 'get_play_state': this.setState("play_state", jmsg.state); break; case 'set_play_mode': case 'get_play_mode': this.setState("play_mode_repeat", jmsg.repeat); this.setState("play_mode_shuffle", (jmsg.shuffle == 'on' ? true : false)); break; case 'get_now_playing_media': this.setState("now_playing_media_type", jdata.payload.type); if (jdata.payload.hasOwnProperty('song')) this.setState("now_playing_media_song", jdata.payload.song); if (jdata.payload.hasOwnProperty('album')) this.setState("now_playing_media_album", jdata.payload.album); if (jdata.payload.hasOwnProperty('album_id')) this.setState("now_playing_media_album_id", jdata.payload.album_id); if (jdata.payload.hasOwnProperty('artist')) this.setState("now_playing_media_artist", jdata.payload.artist); if (jdata.payload.hasOwnProperty('image_url')) this.setState("now_playing_media_image_url", jdata.payload.image_url); if (jdata.payload.hasOwnProperty('mid')) this.setState("now_playing_media_mid", jdata.payload.mid); if (jdata.payload.hasOwnProperty('qid')) this.setState("now_playing_media_qid", jdata.payload.qid); if (jdata.payload.hasOwnProperty('type')) { if (jdata.payload.type == 'station') { this.setState("now_playing_media_station", jdata.payload.station); } else { this.setState("now_playing_media_station", ""); } } break; } break; } // switch } catch (err) { this.logError('parseResponse: ' + err.message); } } /** cmd der Form "cmd¶m" werden zur msg heos+cmd+pid+¶m aufbereitet cmd der Form "cmd?param" werden zur msg heos+cmd+?param aufbereitet **/ commandToMsg(cmd) { var param = cmd.split('&'); cmd = param[0]; if (param.length > 1) param = '&' + param[1]; else param = ''; var cmd_group = 'player'; switch (cmd) { case 'get_play_state': case 'get_play_mode': case 'get_now_playing_media': case 'get_volume': case 'play_next': case 'play_previous': case 'set_mute': // &state=on|off case 'set_volume': // &level=1..100 case 'volume_down': // &step=1..10 case 'volume_up': // &step=1..10 case 'set_play_state': // &state=play|pause|stop case 'set_play_mode': // &repeat=on_all|on_one|off shuffle=on|off break; // browse case 'play_preset': // heos://browse/play_preset?pid=player_id&preset=preset_position cmd_group = 'browse'; break; case 'play_stream': // heos://browse/play_stream?pid=player_id&url=url_path cmd_group = 'browse'; break; } return 'heos://' + cmd_group + '/' + cmd + '?pid=' + this.pid + param; } /** Nachricht (command) an player senden es sind auch mehrere commands, getrennt mit | erlaubt bsp: set_volume&level=20|play_preset&preset=1 **/ sendCommand(command) { if (!this.installed) return; var cmds = command.split('|'); for (var c = 0; c < cmds.length; c++) { this.heos.msgs.push(this.commandToMsg(cmds[c])); } this.heos.sendNextMsg(); } } // end of HeosPlayer /* ----- Heos ----- */ // Heos Instanz erzeugen und verbinden var heos = new Heos(); heos.connect(); // wenn das Script beendet wird, dann auch die Heos Instanz beenden onStop(function () { heos.disconnect(); }, 0);
-
@withstu sagte in [Vorlage] Denon HEOS Script:
Ich habe in den letzten Tagen noch ein paar Bugs gefixt
Super, wenn ich Zeit finde (ist ja bald Ostern), schau ich rein und übernehme es. Hinweis: Wenn du das Script hier als code postest, versieht es das Forum mit zufällligen {1} Einträgen; besser ist es das Script als Datei (z.B.: heos.js) hochzuladen.
-
Ich habe für jeden Lautsprecher einen Ping Adapter angelegt. Sobald sich da was ändert und in den HEOS Objekten der Player noch nicht verbunden ist, starte ich das Script neu (bei mir sind die Player nicht dauerhaft verbunden).
Ich komme gerade erst dazu mir das mit der Ping-Überprüfung anzuschauen. Ist das mit dem neuen Skript überhaupt noch erforderlich? Wahrscheinlich ja, weil bei mir sind immer wieder Boxen nicht erreichbar.
Du schreibst, das ist ein Blockly-Skript, aber die Definition sieht eher aus wie ein Java-Script. Könntest Du mir das als Blockly exportieren, oder hast du das in Java umgewandelt.Vielen Dank auf jeden Fall für Eure Mühen, das Heos Skript läuft sonst sehr gut bei mir.
Danke,
Christian -
@chrisblu Mit der zweiten Version ist der Ping Adapter nicht mehr notwendig und auch ein Neustart des Scripts ist nicht mehr erforderlich. Mein Script läuft mittlerweile seit über einer Woche stabil und erkennt automatisch Player die neu hinzukommen oder ausgeschaltet werden. In der nachfolgenden Version habe ich noch Fehler in dem Heartbeat Mechanismus gelöst. Das einzige was mich noch stört ist das Feld "last error", welches nach der Lösung des Fehlers nicht geleert wird. So wird der Fehler bei mir im vis noch angezeigt, obwohl er nicht mehr besteht. Mal schauen, ob mir in den nächsten Tagen noch was dazu einfällt: HEOS.json
-
@withstu Vielen Dank für die aktuelle Version des Skripts. Während die letzte Fassung bei mir regelmäßig abgestürzt ist (das Pausezeichen in den Skripts war gelb und nichts ging mehr bis ich den Javascript-Adapter wieder gestartet hab), läuft dieses jetzt scheinbar stabil.
Das mit dem last error finde ich nicht ganz so schlimm, da man ja am TimeStamp erkennt, wann der aufgetreten ist. Könnte man vielleicht zum last error einen Flag einführen, ob der Fehler noch anhält oder nicht?
Aber super Skript...
vielen Dank
Christian -
-
Komisch, irgendwas läuft da falsch. Bem Erststart nach ioBroker-Neustart möchte sich das Script mit meiner Hue-Bridge (...72:1255) verbinden. Wie ist das zu erklären, jemand eine Idee?
Beim Neustart des Scripts wird keine Verbindung mehr zur Hue-Bridge gesucht und das Script läuft fehlerfrei.
javascript.1 2020-04-14 18:47:06.030 info (18421) Stop script script.js.java.sonstige.heos javascript.1 2020-04-14 18:46:25.905 error (18421) at process._tickCallback (internal/process/next_tick.js:63:19) javascript.1 2020-04-14 18:46:25.905 error (18421) at emitErrorAndCloseNT (internal/streams/destroy.js:59:3) javascript.1 2020-04-14 18:46:25.904 error (18421) at emitErrorNT (internal/streams/destroy.js:91:8) javascript.1 2020-04-14 18:46:25.904 error (18421) at Socket.emit (events.js:198:13) javascript.1 2020-04-14 18:46:25.904 error (18421) at Socket.net_client.on (script.js.java.sonstige.heos:384:18) javascript.1 2020-04-14 18:46:25.904 error (18421) at Heos.disconnect (script.js.java.sonstige.heos:298:30) javascript.1 2020-04-14 18:46:25.903 error (18421) script.js.java.sonstige.heos: TypeError: this.nodessdp_client.destroy is not a function javascript.1 2020-04-14 18:46:25.902 info (18421) script.js.java.sonstige.heos: [Heos] disconnecting from HEOS ... javascript.1 2020-04-14 18:46:25.901 error (18421) script.js.java.sonstige.heos: [Heos] Error: connect ECONNREFUSED 192.168.178.72:1255 javascript.1 2020-04-14 18:46:25.891 info (18421) script.js.java.sonstige.heos: [Heos] connecting to 192.168.178.72 ... javascript.1 2020-04-14 18:46:25.758 info (18421) script.js.java.sonstige.heos: registered 1 subscription and 0 schedules javascript.1 2020-04-14 18:46:25.754 info (18421) script.js.java.sonstige.heos: [Heos] connecting to HEOS ... javascript.1 2020-04-14 18:46:25.730 info (18421) Start javascript script.js.java.sonstige.heos
-
@Meister-Mopper Welche Version verwendest du denn? Diesen Fehler hatte ich damals auch und hatte den Fehler bereits in folgendem Beitrag gefixt: https://forum.iobroker.net/topic/10420/vorlage-denon-heos-script/93
Das Problem ist, dass das Filtern der Pakete von node-ssdp nicht korrekt durchgeführt wird und versucht wurde sich mit dem ersten Paket was reinkommt zu verbinden. Das kann auch mal ein Hue Paket sein.
Ich hatte bei mir die folgende Überprüfung in die onNodeSSDPResponse Funktion eingebaut.if (headers.ST !== this.ssdpSearchTargetName) { // korrektes SSDP this.logDebug('onNodeSSDPResponse: Getting wrong SSDP entry. Keep trying...'); } else {
-
@withstu Ich nutze eine alte Version aus dem vorigen Jahr, weil ich die neue (aus dem ersten Beitrag) nicht zum Laufen bekomme (s. log)
-
@Meister-Mopper sagte in [Vorlage] Denon HEOS Script:
@withstu Ich nutze eine alte Version aus dem vorigen Jahr, weil ich die neue (aus dem ersten Beitrag) nicht zum Laufen bekomme (s. log)
Änderung: Jetzt habe ich das neue Script in einer zweiten JS-Instanz auf dem Slave-System gestartet, und es funktioniert bisher problemfrei. Das beobachte ich mal.
-
@withstu Hi, das Skript läuft bei mir sehr schön stabil, thumbs up.
Das einzige, was nicht immer klappt, ist der Start mit einem Neustart vom Broker.
Von Zeit zu Zeit stoppe ich meinen Broker manuell, führe die Updates der Adapter durch und starte wieder.
Dabei wird nicht immer das Heos-Skript neu gestartet. Manchmal bekomme ich die Meldung, dass der Socket beendet wurde:
Nach einem weiteren Neustart des Javascript-Adapters startet das Skript dann normal.
Ich könnte mir vorstellen, dass das Skript gestartet wird, bevor irgend eine Abhängigkeit geladen wurde?Ich verwende dein Skript vom 10. April, das trat aber bei der vorhergehenden Version auch schon auf.
Viele Grüße
Christian -
@chrisblu Der Fehler ist mir am Wochenende auch aufgefallen. Konnte ihn aber nicht mehr reproduzieren. Ich habe aber auf Verdacht in der reconnect Methode die stateCONNECTED Prüfung rausgenommen. An der könnte es gelegen haben, dass das Script sich nicht sauber beendet hat: heos.js
In dem Zug habe ich auch die Events für Shuffle und Repeat korrigiert. Die haben bis jetzt noch nie funktioniert. Zudem habe ich zwei Commands hinzugefügt, die alle Player gruppieren bzw. alle Gruppen auflösen. Vielleicht benötigt sie ja jemand:
group/group_all
group/ungroup_all