<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[[Skript] JONR-Saugroboter lokal steuern (hier: T5 Pro Gen1)]]></title><description><![CDATA[<p dir="auto">Hallo zusammen,</p>
<p dir="auto">bei mir steht seit ein paar Tagen ein <strong>JONR T5 Pro</strong>. Der läuft über die Xiaomi-Home-App, also dachte ich, mit <code>mihome-vacuum</code> ist die Anbindung eine Sache von zehn Minuten.</p>
<p dir="auto">War sie nicht. Der Adapter kennt das Modell nicht — im Log steht schlicht <code>Model xtl.vacuum.2407a not supported</code>. Man kann in den Einstellungen zwar von Hand einen anderen Manager erzwingen, aber dann kracht es an der nächsten Stelle, weil das Gerät seine Antworten anders aufbaut als erwartet. Und der bequeme Weg über die Cloud fällt auch aus, der Login kommt nicht mehr durch. Das liegt nicht am Adapter, sondern daran, dass die JONR-Geräte zwar MIoT sprechen, aber eben ihr eigenes Vokabular haben — und das kennt niemand, der das Gerät nicht vor sich stehen hat.</p>
<p dir="auto">Ich hab mir dann angeschaut, was der Sauger lokal so von sich gibt. Rausgekommen ist ein <strong>Skript für den <code>javascript</code>-Adapter</strong>, das direkt per UDP mit ihm redet. Keine Cloud, kein npm-Paket, nur <code>dgram</code> und <code>crypto</code> — beides ist in Node sowieso drin.</p>
<p dir="auto">Das kann es:</p>
<ul>
<li>Starten, Stoppen, Pause, Fortsetzen</li>
<li>nur Saugen, nur Wischen oder beides gleichzeitig</li>
<li>Saugstufe und Wassermenge</li>
<li><strong>einzelne Räume</strong> reinigen</li>
<li>Status auslesen: Akku, Fehlercode, was er gerade macht</li>
<li>und über den <code>iot</code>-Adapter läuft das Ganze per Alexa</li>
</ul>
<p dir="auto">Ich schreib das hier so ausführlich auf, weil ich an ein paar Stellen ziemlich lange im Dunkeln getappt bin. Wer ein anderes JONR-Modell hat, sollte damit deutlich schneller ans Ziel kommen — die Geräte sprechen alle dasselbe Protokoll, nur mit teils anderen Nummern. Wie man die fürs eigene Gerät rausbekommt, steht weiter unten.</p>
<hr />
<h2>1. Voraussetzungen</h2>
<ul>
<li>Sauger in der <strong>Xiaomi-Home-App</strong> eingerichtet und im WLAN</li>
<li>Adapter <strong><code>javascript</code></strong> installiert (getestet mit 9.0.18)</li>
<li>Optional für Sprachsteuerung: Adapter <strong><code>iot</code></strong></li>
<li><strong>Feste IP für den Sauger</strong> im Router vergeben — er hängt sonst irgendwann woanders</li>
</ul>
<hr />
<h2>2. Token und Device-ID besorgen</h2>
<p dir="auto">Für die lokale Verbindung braucht man einen <strong>32-stelligen Hex-Token</strong>, den das Gerät bei der Einrichtung bekommen hat. Der Token steht nicht in der App — man holt ihn sich über das Xiaomi-Konto.</p>
<p dir="auto">Dafür gibt es ein etabliertes Werkzeug, den <strong>Xiaomi Cloud Tokens Extractor</strong>:</p>
<p dir="auto">👉 <a href="https://github.com/PiotrMachowski/Xiaomi-cloud-tokens-extractor" rel="nofollow ugc">https://github.com/PiotrMachowski/Xiaomi-cloud-tokens-extractor</a></p>
<p dir="auto">Unter <em>Releases</em> gibt es eine fertige <strong><code>token_extractor.exe</code></strong> für Windows — Python muss man dafür nicht installieren.</p>
<p dir="auto"><strong>Ablauf:</strong></p>
<ol>
<li><code>token_extractor.exe</code> herunterladen und starten (Konsolenfenster)</li>
<li>Xiaomi-Konto (E-Mail und Passwort) eingeben</li>
<li>Server-Region wählen — für Deutschland <strong><code>de</code></strong></li>
<li>Das Tool listet alle Geräte des Kontos auf</li>
</ol>
<p dir="auto">Für jedes Gerät bekommt man unter anderem:</p>
<pre><code>Name:     Jonr T5 Pro
ID:       1234567890                        &lt;- das ist die DID
Token:    a1b2c3d4e5f6...                   &lt;- 32 Hex-Zeichen
Model:    xtl.vacuum.2407a
IP:       192.168.x.y
MAC:      xx:xx:xx:xx:xx:xx
</code></pre>
<p dir="auto"><strong>Token, DID und IP</strong> brauchen wir gleich. Das <code>Model</code> sagt euch, ob euer Gerät überhaupt zu diesem Skript passt.</p>
<blockquote>
<p dir="auto">⚠️ Der Token ist ein Zugangsschlüssel zum Gerät. Nicht in öffentliche Repos, Foren oder Screenshots — beim Posten schwärzen.</p>
</blockquote>
<hr />
<h2>3. Woher die ganzen Nummern kommen (siid / piid / aiid)</h2>
<p dir="auto">Damit das Skript nicht wie Magie wirkt, kurz die Herkunft — das ist auch der Weg, um es auf <strong>andere Modelle</strong> anzupassen.</p>
<p dir="auto">Xiaomi-Geräte beschreiben sich selbst über eine <strong>MIoT-Spezifikation</strong>, die öffentlich abrufbar ist:</p>
<pre><code>https://miot-spec.org/miot-spec-v2/instance?type=urn:miot-spec-v2:device:vacuum:0000A006:xtl-2407a:2
</code></pre>
<p dir="auto">Der entscheidende Teil im URN ist das Modellkürzel (<code>xtl-2407a</code>). Die passende URN zum eigenen Gerät findet man über die Suche auf <a href="https://miot-spec.org/" rel="nofollow ugc">https://miot-spec.org/</a>.</p>
<p dir="auto">Die Datei ist JSON und hat drei Ebenen:</p>
<table class="table table-bordered table-striped">
<thead>
<tr>
<th></th>
<th>Bedeutung</th>
<th>Beispiel</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong><code>siid</code></strong></td>
<td>Service — ein Funktionsblock</td>
<td><code>2</code> = Robot Cleaner, <code>16</code> = Battery, <code>17</code> = herstellereigener Block</td>
</tr>
<tr>
<td><strong><code>piid</code></strong></td>
<td>Property — ein Wert in diesem Block</td>
<td><code>2.2</code> = Status, <code>16.1</code> = Akku</td>
</tr>
<tr>
<td><strong><code>aiid</code></strong></td>
<td>Action — etwas, das man auslösen kann</td>
<td><code>2.1</code> = Start, <code>17.3</code> = stop-clean</td>
</tr>
</tbody>
</table>
<p dir="auto"><strong>⚠️ Wichtigste Falle überhaupt:</strong> <code>piid</code> und <code>aiid</code> sind <strong>getrennte Nummernkreise</strong>. Bei meinem Gerät ist <code>17.46</code> als <em>Action</em> <code>set-clean-mode</code>, als <em>Property</em> dagegen die Reinigungshistorie. Wer das verwechselt, sucht sich stundenlang tot.</p>
<p dir="auto"><strong>Zweite Falle:</strong> Viele Einträge haben eine <strong>leere</strong> <code>description</code>. Der echte Name steht dann im <code>type</code>-URN:</p>
<pre><code class="language-json">{ "iid": 34, "type": "urn:xtl-spec:property:clean-values:00000022:xtl-2407a:1",
  "description": "", "format": "string" }
</code></pre>
<p dir="auto"><code>description</code> ist leer, aber der URN verrät <code>clean-values</code> — die Raumliste. So habe ich die halbe Landkarte erschlossen.</p>
<hr />
<h2>4. Die wichtigsten Werte (hier vom T5 Pro, andere Modelle ähnlich)</h2>
<p dir="auto"><strong>Zum Auslesen:</strong></p>
<table class="table table-bordered table-striped">
<thead>
<tr>
<th>siid.piid</th>
<th>Bedeutung</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>2.2</code></td>
<td>Status: 1 Idle · 2 Busy · 4 Wischen · 5 Saugen+Wischen · 6 Pausiert · 7 Saugen · 8 Fehler · 9 Lädt · 10 Fährt zur Basis · 12 Fährt zum Waschen · 13 Geladen · 16 Schläft · 18 Station arbeitet</td>
</tr>
<tr>
<td><code>16.1</code></td>
<td>Akku in %</td>
</tr>
<tr>
<td><code>17.35</code></td>
<td>Fehlercode (0 = alles gut)</td>
</tr>
<tr>
<td><code>17.9</code></td>
<td><strong>clean-type-status</strong> — Full/Area/Zone/Spot Clean, jeweils mit Pause und Resume</td>
</tr>
<tr>
<td><code>17.10</code></td>
<td>clean-mode: 0 BothWork · 1 OnlySweep · 2 OnlyMop · 3 SweepFirst · 4 Custom</td>
</tr>
<tr>
<td><code>17.12</code></td>
<td>Wassermenge: 0 Low · 1 Mid · 2 High</td>
</tr>
<tr>
<td><code>17.13</code></td>
<td>Saugstufe: 0 Quiet · 1 Auto · 2 Strong · 3 Max</td>
</tr>
<tr>
<td><code>17.34</code></td>
<td><strong>clean-values</strong> — der laufende Auftrag (Raumliste)</td>
</tr>
<tr>
<td><code>17.49</code></td>
<td>Klartextstatus, z. B. <code>"HotDry"</code></td>
</tr>
</tbody>
</table>
<p dir="auto">Zu <code>17.10</code> ein Hinweis, weil die deutsche App das unglücklich übersetzt: <strong>„saugen&amp;wischen" ist <code>BothWork</code></strong> (gleichzeitig), <strong>„saugen und wischen" ist <code>SweepFirst</code></strong> (erst alles saugen, danach wischen). Die englischen Bezeichner in der Spec sind eindeutiger als die Übersetzung.</p>
<p dir="auto"><strong>Zum Steuern — es gibt keine schreibbaren Properties, alles läuft über Actions:</strong></p>
<table class="table table-bordered table-striped">
<thead>
<tr>
<th>siid.aiid</th>
<th>Wirkung</th>
<th>Parameter</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>2.1</code></td>
<td>Start (ganzes Haus) / Fortsetzen</td>
<td>—</td>
</tr>
<tr>
<td><code>2.2</code></td>
<td>Fahrt anhalten</td>
<td>—</td>
</tr>
<tr>
<td><code>2.7</code></td>
<td>Pause</td>
<td>—</td>
</tr>
<tr>
<td><code>16.1</code></td>
<td>Zurück zur Basis</td>
<td>—</td>
</tr>
<tr>
<td><strong><code>17.1</code></strong></td>
<td><strong>start-clean</strong> (raumweise)</td>
<td><code>piid 8</code> = Art, <code>piid 34</code> = Raumliste</td>
</tr>
<tr>
<td><strong><code>17.3</code></strong></td>
<td><strong>stop-clean</strong> — Auftrag beenden</td>
<td>—</td>
</tr>
<tr>
<td><code>17.26</code></td>
<td>Saugstufe setzen</td>
<td><code>piid 13</code></td>
</tr>
<tr>
<td><code>17.27</code></td>
<td>Wassermenge setzen</td>
<td><code>piid 12</code></td>
</tr>
<tr>
<td><code>17.46</code></td>
<td>Reinigungsart setzen</td>
<td><code>piid 10</code></td>
</tr>
<tr>
<td><code>17.28</code></td>
<td>Anzahl Durchgänge</td>
<td><code>piid 14</code> (1 oder 2)</td>
</tr>
</tbody>
</table>
<hr />
<h2>5. Die vier Stolpersteine</h2>
<p dir="auto">Das hier ist der eigentliche Grund für diesen Beitrag — daran hängt man sonst garantiert fest.</p>
<h3>5.1 Nie zwei Clients gleichzeitig</h3>
<p dir="auto"><strong>Das Gerät verträgt keine parallelen miIO-Sitzungen.</strong> Läuft das Skript (das ja pollt) und man testet parallel etwas per Konsole, quittiert der Sauger Befehle mit <code>code 0</code> und <strong>tut nichts</strong>. Das sieht exakt aus wie ein falscher Parameter — ist aber eine Kollision.</p>
<p dir="auto">Mich hat das einen halben Tag gekostet: Ich habe ein Dutzend Parameterformate durchprobiert, obwohl das allererste schon richtig war. <strong>Vor manuellen Tests das Skript deaktivieren.</strong></p>
<p dir="auto">Parallel darf aber APP und Skript laufen. Die Befehle die ich lokal an den Sauger per Skript sende, werden in der APP ordentlich angezeigt. Z.B. bei einer Zimmerreinigung sind Statusangaben und auch die Hervorhebung der Karte korrekt. Auch können Aufgaben/Befehle die per Skript oder APP kamen, auch auf dem Gegenstück z.B. gestoppt werden. Alles kein Problem.</p>
<h3>5.2 Befehle brauchen Pausen</h3>
<p dir="auto">Vier Actions direkt hintereinander (Saugstufe → Wasser → Modus → Start) — und der Sauger verschluckt die letzte. <strong>1,5 Sekunden zwischen den Aufrufen</strong> genügen.</p>
<h3>5.3 Stoppen heißt Auftrag <em>beenden</em></h3>
<p dir="auto"><code>2.2</code> hält nur die Fahrt an. Der Auftrag bleibt danach im Zustand <strong>„Area Pause"</strong> stehen (<code>17.9 = 9</code>), und <code>17.34</code> trägt weiter die Raumliste. Folgen:</p>
<ul>
<li>Die App verweigert einen Moduswechsel („Auftrag muss erst abgebrochen werden")</li>
<li><strong>Der Sauger fährt später von selbst wieder los</strong> — etwa nachdem die Station die Mopps gewaschen hat, nimmt er den pausierten Auftrag wieder auf</li>
<li>Es läuft <strong>keine Nacharbeit</strong>, weil der Auftrag nie abgeschlossen wurde</li>
</ul>
<p dir="auto">Richtig ist <strong><code>17.3 stop-clean</code></strong>. Danach geht <code>17.9</code> auf Idle und <code>17.34</code> wird leer.</p>
<p dir="auto"><strong>Merksatz:</strong> Bei merkwürdigem Verhalten immer <code>17.9</code> mitlesen — <code>2.2</code> zeigt „Lädt", während im Hintergrund noch ein Auftrag pausiert.</p>
<h3>5.4 Große Antworten kommen gar nicht an</h3>
<p dir="auto">Wird eine Property-Antwort zu groß, sendet das Gerät <strong>überhaupt kein Paket</strong> — kein Fehler, einfach Stille. Bei mir passierte das ab etwa acht Zeitplänen in <code>17.17</code>. Also nicht die dicken JSON-Properties (Historie, Zeitpläne) im Minutentakt pollen.</p>
<p dir="auto">Und noch eine Kleinigkeit: Bei <code>get_properties</code> kommen manchmal <strong>weniger Ergebnisse zurück als angefragt</strong>. Deshalb die Antworten immer über die mitgelieferten <code>siid</code>/<code>piid</code> zuordnen, <strong>nie über die Reihenfolge</strong>. Batchgröße 5 hat sich bewährt.</p>
<hr />
<h2>6. Raum-IDs herausfinden</h2>
<p dir="auto">Für die raumweise Reinigung braucht man die internen IDs. Die stehen weder in der App noch sind sie lokal abrufbar — die Karte liegt in der Xiaomi-Cloud, <code>get-map-infos</code> liefert nur eine Referenz.</p>
<p dir="auto"><strong>Der Trick: über die Zeitpläne.</strong> Ein Zeitplan enthält die Raumliste im Klartext, und Property <code>17.17</code> gibt die Zeitpläne aus.</p>
<p dir="auto"><strong>Vorgehen:</strong></p>
<ol>
<li>In der App einen Zeitplan anlegen — <strong>deaktiviert</strong>, damit nichts losfährt — und <strong>genau einen Raum</strong> zuweisen</li>
<li><code>17.17</code> auslesen</li>
<li>In der Ausgabe steht:</li>
</ol>
<pre><code class="language-json">{ "id":"1", "on":0, "hour":12, "min":0, "mapId":1,
  "cleanValues":[6],        &lt;-- das ist die gesuchte Raum-ID
  "workMode":0, "fanMode":3, "waterMode":1, "cleanCount":1, "routePrefer":1 }
</code></pre>
<ol start="4">
<li>Für jeden Raum wiederholen, danach die Test-Zeitpläne wieder löschen</li>
</ol>
<p dir="auto">⚠️ <strong>Höchstens drei Zeitpläne gleichzeitig im Gerät lassen</strong> — sonst wird die Antwort zu groß (siehe 5.4) und <code>17.17</code> liefert gar nichts mehr.</p>
<p dir="auto">⚠️ <strong>Die IDs gelten nur für die aktuelle Karte.</strong> Nach einer Neukartierung oder nach dem Teilen/Zusammenlegen von Räumen werden sie neu vergeben, und <strong>vorhandene Zeitpläne muss man neu anlegen</strong> — die App kann sie nicht umhängen.</p>
<p dir="auto">Nebenbei: Bei mir zeigte die App die Räume vor dem Umbenennen als „Zimmer1…n" an, und die interne ID lag konstant <strong>zwei höher</strong> als diese Nummer. Ob das allgemein gilt, weiß ich nicht — verifiziert habe ich jede ID einzeln über die Zeitplan-Methode. Verlasst euch nicht auf die Rechnung.</p>
<hr />
<h2>7. Das Format der Raumliste — der Knackpunkt</h2>
<p dir="auto"><code>piid 34</code> will die Raum-IDs als <strong>Array-Notation in einem String</strong>:</p>
<pre><code class="language-js">action(17.1, [{ piid: 8, value: 3 }, { piid: 34, value: "[6]" }])
//                        AreaClean                Raumliste
</code></pre>
<p dir="auto">Mehrere Räume als <code>"[3,8,11]"</code>. <strong>Die Reihenfolge im Array ist die Reinigungsreihenfolge.</strong></p>
<p dir="auto">Was <strong>nicht</strong> funktioniert (alles durchprobiert):</p>
<table class="table table-bordered table-striped">
<thead>
<tr>
<th>Versuch</th>
<th>Ergebnis</th>
</tr>
</thead>
<tbody>
<tr>
<td>nackte Zahl <code>"6"</code></td>
<td>fährt los, kehrt um: „Ausgewählter Bereich nicht gefunden"</td>
</tr>
<tr>
<td>echtes Array <code>[6]</code> statt String</td>
<td><code>-9999 user ack timeout</code></td>
</tr>
<tr>
<td>JSON-Objekt <code>{"mapId":1,"cleanValues":[6]}</code></td>
<td>still verworfen, keine Fahrt</td>
</tr>
<tr>
<td>nur <code>piid 34</code> ohne <code>piid 8</code></td>
<td><code>-9999 user ack timeout</code></td>
</tr>
<tr>
<td>positionale Parameter <code>in: [3, "[6]"]</code></td>
<td><code>-9999 user ack timeout</code></td>
</tr>
</tbody>
</table>
<p dir="auto"><strong>Wie ich das Format gefunden habe</strong> — und das ist die Methode, die ich jedem empfehle, der an einem unklaren Parameter hängt:</p>
<p dir="auto"><strong>Das Gerät einmal selbst machen lassen und dabei zuschauen.</strong> Ich habe einen Zeitplan auf „in drei Minuten" gestellt und währenddessen <code>17.34</code> mitgelesen. Da stand dann <code>"[7,6]"</code> — und damit war das Format klar, statt weiter zu raten.</p>
<hr />
<h2>8. Das Skript</h2>
<p dir="auto">Anlegen im <strong>javascript-Adapter</strong> als normales JavaScript (nicht Blockly). Oben <strong>IP, Token und DID</strong> eintragen und die <strong>Raumtabelle</strong> mit den eigenen IDs füllen.</p>
<p dir="auto">Es legt alle States selbst an unter <code>0_userdata.0.Saugroboter.Jonr.*</code> — der Pfad steht oben im Skript und ist frei wählbar:</p>
<p dir="auto"><strong>Anzeige</strong> (<code>info.*</code>): Status als Code und Klartext, Akku, Fehlercode, Reinigungsart, Saugstufe, Wassermenge, Erreichbarkeit, Zeitpunkt der letzten Abfrage</p>
<p dir="auto"><strong>Steuerung</strong> (echte Schalter, <code>read</code>+<code>write</code>):</p>
<table class="table table-bordered table-striped">
<thead>
<tr>
<th>State</th>
<th>„ein"</th>
<th>„aus"</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>Sauger</code></td>
<td>Max + Mid + BothWork, ganzes Haus</td>
<td>stoppen und zur Basis</td>
</tr>
<tr>
<td><code>Sauger_Wischen</code></td>
<td>nur wischen</td>
<td>stoppen und zur Basis</td>
</tr>
<tr>
<td><code>Sauger_Pause</code></td>
<td>pausieren</td>
<td>fortsetzen</td>
</tr>
<tr>
<td><code>raum.&lt;Name&gt;</code></td>
<td>diesen Raum reinigen</td>
<td>stoppen und zur Basis</td>
</tr>
</tbody>
</table>
<p dir="auto">Die Namen sind frei wählbar — sie stehen oben im Skript und tauchen genauso bei Alexa auf.</p>
<p dir="auto">Gepollt wird alle 60 Sekunden, während der Arbeit alle 15.</p>
<pre><code>/*
 * ============================================================================
 *  JONR T5 Pro (xtl.vacuum.2407a) lokal steuern -- ioBroker javascript-Adapter
 * ============================================================================
 *  Spricht den Sauger direkt per miIO/MIoT ueber UDP an. Keine Cloud, kein
 *  zusaetzliches npm-Paket -- nur die Node-Bordmittel dgram und crypto.
 *
 *  Getestet mit dem JONR T5 Pro. Andere xtl.*-Modelle (P20 Pro = xm2216,
 *  X1 MAX = xt3411 u. a.) sprechen dasselbe Protokoll, koennen aber
 *  abweichende siid/piid haben -- Spec pruefen, siehe Forumsbeitrag.
 *
 *  Keine Gewaehr, keine Wartungszusage. Verbesserungen gerne.
 *
 *  --- Vor dem Start ausfuellen -----------------------------------------------
 *  IP, TOKEN und DID unten eintragen, ROOMS mit den eigenen Raum-IDs fuellen.
 *
 *  --- Fallstricke, alle in der Praxis getroffen ---------------------------------------
 *  1. Action-IIDs und Property-IIDs sind GETRENNTE Nummernkreise!
 *     17.46 als Action = set-clean-mode, 17.46 als Property = Reinigungshistorie.
 *  2. Das Geraet liefert bei get_properties teils WENIGER Ergebnisse als
 *     angefragt. Zuordnung immer ueber die mitgelieferten siid/piid, nie ueber
 *     die Reihenfolge. Batchgroesse 5 hat sich bewaehrt.
 *  3. Grosse Antworten liefert das Geraet GAR NICHT aus -- es sendet dann kein
 *     Paket statt eines Fehlers. Also keine dicken JSON-Properties pollen.
 *  4. NIE zwei miIO-Clients gleichzeitig auf dem Geraet. Laeuft parallel ein
 *     zweites Skript, quittiert der Sauger Befehle mit code 0 und tut nichts.
 *     Das sieht wie ein falscher Parameter aus, ist aber eine Kollision.
 *  5. Befehle nicht ohne Pause hintereinander senden -- der Sauger verschluckt
 *     sonst den naechsten. 1,5 s dazwischen reichen.
 *  6. Stoppen heisst Auftrag BEENDEN (17.3), nicht nur anhalten (2.2).
 *     Sonst bleibt der Auftrag pausiert und der Sauger faehrt spaeter von
 *     selbst weiter.
 * ============================================================================
 */

'use strict';

const dgram  = require('dgram');
const crypto = require('crypto');

// ---------------------------------------------------------------- Konfiguration
const IP    = '192.168.x.y';        // IP des Saugers (feste Adresse im Router vergeben!)
const TOKEN = Buffer.from('DEIN_32_STELLIGER_TOKEN', 'hex');
const DID   = 'DEINE_DID';          // Device-ID, kommt aus demselben Tool wie der Token
const PORT  = 54321;

const BASE = '0_userdata.0.Saugroboter.Jonr';   // frei waehlbar

const POLL_IDLE   = 60000;   // ms — Sauger ruht
const POLL_ACTIVE = 15000;   // ms — Sauger arbeitet

/* Raum-IDs. ACHTUNG: Sie gelten nur fuer die aktuelle Karte -- nach einer
 * Neukartierung oder nach dem Teilen/Zusammenlegen von Raeumen werden sie neu
 * vergeben. Wie man sie ermittelt, steht im Forumsbeitrag (ueber Zeitplaene). */
const ROOMS = {
    // BEISPIEL -- eigene Raeume eintragen! Wie man die IDs ermittelt, steht im Forumsbeitrag.
    // Schluessel = Name im Objektbaum (ASCII), alexa = Name fuer die Sprachsteuerung.
    Raum1: { id: 3, alexa: 'Sauger Raum eins' },
    Raum2: { id: 4, alexa: 'Sauger Raum zwei' },
};

// siid.aiid der Aktionen, die wir auslösen
const ACT = {
    START_SWEEP:  { siid:  2, aiid:  1 },   // parameterlos
    STOP:         { siid:  2, aiid:  2 },   // haelt nur die Fahrt an
    STOP_CLEAN:   { siid: 17, aiid:  3 },   // beendet den AUFTRAG -- siehe stopAndHome()
    PAUSE:        { siid:  2, aiid:  7 },
    CHARGE:       { siid: 16, aiid:  1 },   // zurück zur Basis
    START_CLEAN:  { siid: 17, aiid:  1 },   // in: piid 8 = Art, piid 34 = Räume
    SET_FAN:      { siid: 17, aiid: 26 },   // in: piid 13
    SET_WATER:    { siid: 17, aiid: 27 },   // in: piid 12
    SET_MODE:     { siid: 17, aiid: 46 },   // in: piid 10
};

// Werte
const CLEAN_FULL = 1, CLEAN_AREA = 3;              // piid 8
const MODE_BOTH  = 0, MODE_MOP  = 2;               // piid 10
const FAN_MAX    = 3;                              // piid 13
const WATER_MID  = 1;                              // piid 12

// Statuscodes (Property 2.2)
const STATUS = {
    1:'Idle', 2:'Busy', 4:'Wischen', 5:'Saugen+Wischen', 6:'Pausiert', 7:'Saugen',
    8:'Fehler', 9:'Lädt', 10:'Fährt zur Basis', 12:'Fährt zum Waschen',
    13:'Geladen', 16:'Schläft', 18:'Station arbeitet',
};
// Bei diesen Zuständen ist der Sauger unterwegs -&gt; schnelleres Polling,
// die Schalter stehen dann auf true
const RUNNING = [2, 4, 5, 7];
const PAUSED  = [6];

// ---------------------------------------------------------------- miIO-Protokoll
const key = crypto.createHash('md5').update(TOKEN).digest();
const iv  = crypto.createHash('md5').update(Buffer.concat([key, TOKEN])).digest();

const encrypt = d =&gt; {
    const c = crypto.createCipheriv('aes-128-cbc', key, iv);
    return Buffer.concat([c.update(d, 'utf8'), c.final()]);
};
const decrypt = b =&gt; {
    const d = crypto.createDecipheriv('aes-128-cbc', key, iv);
    return Buffer.concat([d.update(b), d.final()]).toString('utf8').replace(/\0+$/, '');
};

let sock = null;
let devId = null, stamp = 0, stampAt = 0, msgId = 100;
let pending = null;
let stopping = false;

function openSocket() {
    sock = dgram.createSocket('udp4');
    sock.on('message', m =&gt; { if (pending) { const p = pending; pending = null; p(m); } });
    sock.on('error', e =&gt; log('Socket-Fehler: ' + e.message, 'warn'));
}

function tx(buf, timeout) {
    return new Promise((res, rej) =&gt; {
        if (!sock || stopping) return rej(new Error('Socket zu'));
        const t = setTimeout(() =&gt; { pending = null; rej(new Error('timeout')); }, timeout);
        pending = m =&gt; { clearTimeout(t); res(m); };
        sock.send(buf, 0, buf.length, PORT, IP, e =&gt; { if (e) { clearTimeout(t); pending = null; rej(e); } });
    });
}
async function retry(fn, n = 4) {
    let last;
    for (let i = 0; i &lt; n &amp;&amp; !stopping; i++) {
        try { return await fn(); } catch (e) { last = e; }
    }
    throw last || new Error('abgebrochen');
}

function helloPacket() {
    const b = Buffer.alloc(32, 0xff);
    b.writeUInt16BE(0x2131, 0);
    b.writeUInt16BE(32, 2);
    return b;
}

/* Der Zeitstempel des Geräts muss mitgezählt werden. Nach ein paar Minuten
 * Funkstille lieber neu synchronisieren, sonst verwirft das Gerät die Pakete. */
async function handshake() {
    const r = await retry(() =&gt; tx(helloPacket(), 4000));
    devId = Buffer.from(r.subarray(8, 12));
    stamp = r.readUInt32BE(12);
    stampAt = Date.now();
}
async function ensureHandshake() {
    if (!devId || Date.now() - stampAt &gt; 120000) await handshake();
}

function packet(obj) {
    const enc = encrypt(JSON.stringify(obj));
    const h = Buffer.alloc(16);
    h.writeUInt16BE(0x2131, 0);
    h.writeUInt16BE(32 + enc.length, 2);
    h.writeUInt32BE(0, 4);
    devId.copy(h, 8);
    h.writeUInt32BE(stamp + Math.floor((Date.now() - stampAt) / 1000), 12);
    const sum = crypto.createHash('md5').update(Buffer.concat([h, TOKEN, enc])).digest();
    return Buffer.concat([h, sum, enc]);
}

async function call(method, params, timeout = 8000) {
    await ensureHandshake();
    const r = await retry(() =&gt; tx(packet({ id: msgId++, method, params }), timeout));
    return JSON.parse(decrypt(r.subarray(32)));
}

/** Properties lesen. Rückgabe: { "siid.piid": value } — fehlende bleiben weg. */
async function getProps(list) {
    const out = {};
    for (let i = 0; i &lt; list.length; i += 5) {                 // Batchgröße 5
        const batch = list.slice(i, i + 5);
        try {
            const r = await call('get_properties',
                batch.map(p =&gt; ({ did: DID, siid: p.siid, piid: p.piid })));
            for (const v of (r.result || [])) {
                if (v.code === 0) out[v.siid + '.' + v.piid] = v.value;   // s. Fallstrick 2
            }
        } catch (e) {
            log('get_properties fehlgeschlagen: ' + e.message, 'warn');
        }
    }
    return out;
}

/** Aktion auslösen. `params` sind die in-Werte in der Reihenfolge der Spec. */
async function action(a, params = []) {
    const r = await call('action', { did: DID, siid: a.siid, aiid: a.aiid, in: params });
    const code = r &amp;&amp; r.result ? r.result.code : (r &amp;&amp; r.error ? r.error.code : -1);
    if (code !== 0) throw new Error('Action ' + a.siid + '.' + a.aiid + ' -&gt; code ' + code);
    return r;
}

// ---------------------------------------------------------------- States anlegen
/* createStateAsync statt setObjectNotExistsAsync: letzteres ist Adapter-
 * Entwickler-API und in der javascript-Sandbox nicht verfügbar.
 * forceCreation=false lässt vorhandene States unangetastet. */
async function def(id, common, initial) {
    const full = BASE + '.' + id;
    const c = Object.assign({ read: true, write: false, role: 'state' }, common);
    await createStateAsync(full, initial === undefined ? null : initial, false, c);
    /* createStateAsync laesst ein vorhandenes Objekt in Ruhe. Damit spaetere
     * Aenderungen am common (z. B. ein neuer smartName) auch bei bestehenden
     * States ankommen, wird es hier nachgezogen. */
    try {
        await extendObjectAsync(full, { common: c });
    } catch (e) {
        log('common von ' + id + ' nicht aktualisierbar: ' + e.message, 'warn');
    }
}

async function createStates() {
    // --- Anzeige
    await def('info.status',      { name: 'Status (Code)',      type: 'number',  role: 'value' });
    await def('info.statusText',  { name: 'Status',             type: 'string',  role: 'text' });
    await def('info.battery',     { name: 'Akku',               type: 'number',  role: 'value.battery', unit: '%' });
    await def('info.error',       { name: 'Fehlercode',         type: 'number',  role: 'value' });
    await def('info.cleanMode',   { name: 'Reinigungsart',      type: 'number',  role: 'value' });
    await def('info.fanMode',     { name: 'Saugstufe',          type: 'number',  role: 'value' });
    await def('info.waterMode',   { name: 'Wassermenge',        type: 'number',  role: 'value' });
    await def('info.online',      { name: 'erreichbar',         type: 'boolean', role: 'indicator.reachable' });
    await def('info.lastUpdate',  { name: 'letzte Abfrage',     type: 'string',  role: 'text' });

    // --- Alexa-Schalter. Echte Schalter (read+write), nicht Buttons:
    //     dadurch wirkt auch "aus", und die Routine braucht kein Zuruecksetzen.
    await def('Sauger', {
        name: 'Sauger (ganzes Haus, saugen und wischen)', type: 'boolean', role: 'switch',
        write: true, smartName: { de: 'Sauger', smartType: 'SWITCH' },
    }, false);
    await def('Sauger_Wischen', {
        name: 'Sauger wischen (nur wischen)', type: 'boolean', role: 'switch',
        write: true, smartName: { de: 'Sauger wischen', smartType: 'SWITCH' },
    }, false);
    await def('Sauger_Pause', {
        name: 'Sauger Pause', type: 'boolean', role: 'switch',
        write: true, smartName: { de: 'Sauger Pause', smartType: 'SWITCH' },
    }, false);

    // --- Räume. Bewusst OHNE smartName: erst Grundfunktion testen, dann
    //     einzeln fuer Alexa freigeben (smartName im Objekt ergaenzen).
    for (const [name, r] of Object.entries(ROOMS)) {
        await def('raum.' + name, {
            name: name + ' saugen (Raum-ID ' + r.id + ')', type: 'boolean',
            role: 'switch', write: true,
            smartName: { de: r.alexa, smartType: 'SWITCH' },
        }, false);
    }
}

// ---------------------------------------------------------------- Ablaufsteuerung
let pollTimer = null;
let busy = false;              // verhindert überlappende Funkdialoge

/* Kurze Pause zwischen Funkbefehlen. Ohne sie verschluckt das Geraet den
 * naechsten Aufruf -- vier miIO-Dialoge in Folge sind ihm zu viel. */
const pause = ms =&gt; new Promise(r =&gt; setTimeout(r, ms));

/** Vor jedem Start: Saugstufe, Wassermenge und Reinigungsart setzen. */
async function applyDefaults(mode) {
    await action(ACT.SET_FAN,   [{ piid: 13, value: FAN_MAX }]);
    await pause(1500);
    await action(ACT.SET_WATER, [{ piid: 12, value: WATER_MID }]);
    await pause(1500);
    await action(ACT.SET_MODE,  [{ piid: 10, value: mode }]);
    await pause(1500);
}

/* Ganzes Haus laeuft ueber die parameterlose Action 2.1.
 * 17.1 start-clean mit piid 8 = FullClean und leerem piid 34 wird vom Geraet
 * mit code -9999 abgelehnt -- den leeren String mag es nicht.
 * Reinigungsart, Saugstufe und Wassermenge stehen durch applyDefaults() ohnehin
 * schon, 2.1 startet dann einfach mit diesen Einstellungen. */
async function startWholeHome(mode) {
    await applyDefaults(mode);
    await action(ACT.START_SWEEP);
}

/* Raumreinigung. piid 34 traegt die Raumliste als Array-String ("[6]") --
 * exakt das Format, das sich das Geraet bei einem Zeitplan-Lauf selbst setzt
 * (in 17.34 mitzulesen, dort steht dann z. B. "[7,6]").
 *
 * BEWUSST OHNE applyDefaults: Der Raumbefehl scheiterte reproduzierbar, wenn
 * kurz davor die drei Setzbefehle liefen -- als Einzelaufruf funktioniert er.
 * Die Modi stehen ohnehin noch vom letzten Start. */
async function startRooms(ids) {
    await action(ACT.START_CLEAN,
        [{ piid: 8, value: CLEAN_AREA }, { piid: 34, value: JSON.stringify(ids) }]);
}

/* Stoppen heisst: Auftrag BEENDEN, nicht nur anhalten.
 *
 * Action 2.1/2.2 haelt lediglich die Fahrt an -- der Auftrag bleibt danach im
 * Zustand "Area Pause" stehen (17.9 = 9) und 17.34 traegt weiter die Raumliste.
 * Folgen:
 *   - die App verweigert einen Moduswechsel ("Auftrag erst abbrechen")
 *   - der Sauger nimmt die Reinigung spaeter von selbst wieder auf, etwa nachdem
 *     in der Station die Mopps gewaschen wurden
 *   - es laeuft keine Nacharbeit, weil der Auftrag nie abgeschlossen wurde
 *
 * 17.3 stop-clean beendet ihn richtig: 17.9 geht auf Idle, 17.34 wird leer. */
async function stopAndHome() {
    await action(ACT.STOP_CLEAN);
    await pause(1500);
    await action(ACT.CHARGE);
}

// ---------------------------------------------------------------- Polling
async function poll() {
    if (busy || stopping) return;
    busy = true;
    try {
        const p = await getProps([
            { siid:  2, piid:  2 },   // Status
            { siid: 16, piid:  1 },   // Akku
            { siid: 17, piid: 35 },   // Fehlercode
            { siid: 17, piid: 10 },   // clean-mode
            { siid: 17, piid: 13 },   // fan-mode
            { siid: 17, piid: 12 },   // water-mode
        ]);

        const st = p['2.2'];
        if (st === undefined) {                       // gar nichts gekommen
            await setStateAsync(BASE + '.info.online', false, true);
            return;
        }

        await setStateAsync(BASE + '.info.online',     true, true);
        await setStateAsync(BASE + '.info.status',     st, true);
        await setStateAsync(BASE + '.info.statusText', STATUS[st] || ('unbekannt (' + st + ')'), true);
        if (p['16.1']  !== undefined) await setStateAsync(BASE + '.info.battery',   p['16.1'], true);
        if (p['17.35'] !== undefined) await setStateAsync(BASE + '.info.error',     p['17.35'], true);
        if (p['17.10'] !== undefined) await setStateAsync(BASE + '.info.cleanMode', p['17.10'], true);
        if (p['17.13'] !== undefined) await setStateAsync(BASE + '.info.fanMode',   p['17.13'], true);
        if (p['17.12'] !== undefined) await setStateAsync(BASE + '.info.waterMode', p['17.12'], true);
        await setStateAsync(BASE + '.info.lastUpdate', formatDate(new Date(), 'TT.MM.JJJJ SS:mm:ss'), true);

        /* Schalter nachfuehren: faehrt er von selbst in die Station oder wird er
         * in der App gestoppt, sollen die Alexa-Schalter das widerspiegeln —
         * sonst zeigt die App "an", obwohl er laengst laedt. */
        const running = RUNNING.includes(st);
        const paused  = PAUSED.includes(st);
        const mopOnly = p['17.10'] === MODE_MOP;

        await syncSwitch('Sauger',         running &amp;&amp; !mopOnly);
        await syncSwitch('Sauger_Wischen', running &amp;&amp;  mopOnly);
        await syncSwitch('Sauger_Pause',   paused);
        if (!running) for (const n of Object.keys(ROOMS)) await syncSwitch('raum.' + n, false);

        reschedule(running || paused ? POLL_ACTIVE : POLL_IDLE);
    } catch (e) {
        log('Polling-Fehler: ' + e.message, 'warn');
        await setStateAsync(BASE + '.info.online', false, true);
    } finally {
        busy = false;
    }
}

/** Schalter nur anfassen, wenn er wirklich falsch steht (ack=true, kein Trigger). */
async function syncSwitch(id, soll) {
    const cur = await getStateAsync(BASE + '.' + id);
    if (!cur || cur.val !== soll) await setStateAsync(BASE + '.' + id, soll, true);
}

function reschedule(ms) {
    if (pollTimer) clearTimeout(pollTimer);
    if (stopping) return;
    pollTimer = setTimeout(poll, ms);
}

// ---------------------------------------------------------------- Steuer-Handler
/** Gemeinsame Hülle: Fehler abfangen, danach zeitnah nachpollen. */
async function handle(what, fn) {
    if (busy) { log(what + ': Funk gerade belegt, ignoriert', 'info'); return; }
    busy = true;
    try {
        await fn();
        log('Sauger: ' + what);
    } catch (e) {
        /* Der Status hilft bei der Ursachensuche: das Geraet lehnt Befehle auch
         * ab, wenn es gerade in der Station arbeitet (Mopp waschen/trocknen). */
        let st = '?';
        try { const s = await getStateAsync(BASE + '.info.statusText'); if (s) st = s.val; } catch (_) {}
        log('Sauger: ' + what + ' FEHLGESCHLAGEN — ' + e.message + ' (Status war: ' + st + ')', 'error');
    } finally {
        busy = false;
        reschedule(3000);                              // Status bald nachziehen
    }
}

on({ id: BASE + '.Sauger', change: 'any', ack: false }, async obj =&gt; {
    if (obj.state.val) await handle('Start ganzes Haus (saugen+wischen)', () =&gt; startWholeHome(MODE_BOTH));
    else               await handle('Stop und zur Basis',                 () =&gt; stopAndHome());
});

on({ id: BASE + '.Sauger_Wischen', change: 'any', ack: false }, async obj =&gt; {
    if (obj.state.val) await handle('Start nur wischen', () =&gt; startWholeHome(MODE_MOP));
    else               await handle('Stop und zur Basis', () =&gt; stopAndHome());
});

on({ id: BASE + '.Sauger_Pause', change: 'any', ack: false }, async obj =&gt; {
    if (obj.state.val) await handle('Pause',      () =&gt; action(ACT.PAUSE));
    else               await handle('Fortsetzen', () =&gt; action(ACT.START_SWEEP));
});

for (const [name, r] of Object.entries(ROOMS)) {
    on({ id: BASE + '.raum.' + name, change: 'any', ack: false }, async obj =&gt; {
        if (obj.state.val) await handle('Start Raum ' + name + ' (Raum-ID ' + r.id + ')',
                                        () =&gt; startRooms([r.id]));
        else               await handle('Stop und zur Basis', () =&gt; stopAndHome());
    });
}

// ---------------------------------------------------------------- Start / Ende
onStop(() =&gt; {
    stopping = true;
    if (pollTimer) clearTimeout(pollTimer);
    try { if (sock) sock.close(); } catch (e) { /* egal */ }
}, 2000);

(async () =&gt; {
    await createStates();
    openSocket();
    log('Saugroboter-Skript gestartet — ' + IP + ', ' + Object.keys(ROOMS).length + ' Räume bekannt');
    await poll();
})();

</code></pre>
<hr />
<h2>9. Alexa-Anbindung (optional)</h2>
<p dir="auto">Die Schalter tragen bereits ein <code>smartName</code>, der <strong><code>iot</code>-Adapter</strong> meldet sie an Alexa. In der Alexa-App einmal „nach neuen Geräten suchen" — fertig.</p>
<p dir="auto"><strong>Warum echte Schalter und keine Buttons:</strong> Ein State mit <code>role: button</code> reagiert nur auf „ein" und bleibt danach auf <code>true</code> stehen. Deshalb brauchen Routinen für solche States immer den Dreisprung <em>einschalten → 5 Sekunden warten → ausschalten</em>. Bei einem echten Schalter, den das Polling nachführt, entfällt das: Fährt der Sauger von selbst in die Station, springt der Schalter zurück auf <code>false</code>, und „aus" funktioniert wie erwartet.</p>
<p dir="auto">⚠️ <strong>Den Geräten keine Alexa-Raumgruppen zuweisen.</strong> Läge ein Gerät namens „Sauger Wohnzimmer" in der Alexa-Gruppe <em>Wohnzimmer</em>, würde „Alexa, Wohnzimmer aus" den Sauger mitschalten. Beim Einrichten den Gruppen-Assistenten überspringen.</p>
<p dir="auto">Für freie Formulierungen („Alexa, starte den Sauger") legt man in der Alexa-App eine <strong>Routine</strong> mit Sprachauslöser an, die den Schalter ein- oder ausschaltet.</p>
<hr />
<h2>10. Was nicht geht</h2>
<ul>
<li><strong>Kartenansicht.</strong> Die Kartendaten kommen als proprietärer Binärstrom und liegen ohnehin in der Cloud; <code>get-map-data</code> liefert lokal nur eine Referenz.</li>
<li><strong>Zonenreinigung per Koordinaten.</strong> Nicht probiert.</li>
<li><strong>Nacharbeit nach einem Abbruch erzwingen.</strong> Es gibt Actions dafür (<code>17.40</code> set-wash-mop, <code>17.41</code> set-dry-mop, <code>17.42</code> set-collect-dust, jeweils <code>bool</code>), getestet habe ich sie nicht.</li>
</ul>
<hr />
<h2>11. Anpassung an andere xtl-Modelle</h2>
<p dir="auto">Es gibt mindestens <code>xm2216</code> (P20 Pro), <code>xt3411</code> (X1 MAX), <code>xt3401</code>, <code>3505</code>, <code>3512</code>. Das Vorgehen:</p>
<ol>
<li>Modellkürzel aus dem Token-Extractor ablesen</li>
<li>Passende Spec von <a href="https://miot-spec.org/" rel="nofollow ugc">https://miot-spec.org/</a> laden</li>
<li><code>siid</code>/<code>piid</code>/<code>aiid</code> im Skript gegen die eigene Spec prüfen — Service 2 und 16 sind meist gleich, der herstellereigene Block (bei mir 17) kann abweichen</li>
<li>Raum-IDs über die Zeitplan-Methode aus Abschnitt 6 ermitteln</li>
</ol>
<p dir="auto">Wenn jemand das für ein anderes Modell durchzieht: gerne hier posten, dann sammeln wir die Unterschiede.</p>
<hr />
<p dir="auto"><em>Getestet ausschließlich mit dem JONR T5 Pro <strong>GEN 1</strong> - der Gen2 hat Sprachsteuerung wohl schon inkludiert, kostet aber deutlich mehr.<br />
Keine Gewähr, keine Wartungszusage — aber Rückfragen beantworte ich gern; Über Verbesserungen oder Vorschläge freue ich mich sehr.<br />
Erarbeitet unter Zuhilfenahme von KI.</em></p>
]]></description><link>https://forum.iobroker.net/topic/85226/skript-jonr-saugroboter-lokal-steuern-hier-t5-pro-gen1</link><generator>RSS for Node</generator><lastBuildDate>Thu, 27 Aug 2026 05:56:16 GMT</lastBuildDate><atom:link href="https://forum.iobroker.net/topic/85226.rss" rel="self" type="application/rss+xml"/><pubDate>Mon, 24 Aug 2026 17:47:53 GMT</pubDate><ttl>60</ttl></channel></rss>