NEWS
[Skript] JONR-Saugroboter lokal steuern (hier: T5 Pro Gen1)
-
Hallo zusammen,
bei mir steht seit ein paar Tagen ein JONR T5 Pro. Der läuft über die Xiaomi-Home-App, also dachte ich, mit
mihome-vacuumist die Anbindung eine Sache von zehn Minuten.War sie nicht. Der Adapter kennt das Modell nicht — im Log steht schlicht
Model xtl.vacuum.2407a not supported. 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.Ich hab mir dann angeschaut, was der Sauger lokal so von sich gibt. Rausgekommen ist ein Skript für den
javascript-Adapter, das direkt per UDP mit ihm redet. Keine Cloud, kein npm-Paket, nurdgramundcrypto— beides ist in Node sowieso drin.Das kann es:
- Starten, Stoppen, Pause, Fortsetzen
- nur Saugen, nur Wischen oder beides gleichzeitig
- Saugstufe und Wassermenge
- einzelne Räume reinigen
- Status auslesen: Akku, Fehlercode, was er gerade macht
- und über den
iot-Adapter läuft das Ganze per Alexa
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.
1. Voraussetzungen
- Sauger in der Xiaomi-Home-App eingerichtet und im WLAN
- Adapter
javascriptinstalliert (getestet mit 9.0.18) - Optional für Sprachsteuerung: Adapter
iot - Feste IP für den Sauger im Router vergeben — er hängt sonst irgendwann woanders
2. Token und Device-ID besorgen
Für die lokale Verbindung braucht man einen 32-stelligen Hex-Token, den das Gerät bei der Einrichtung bekommen hat. Der Token steht nicht in der App — man holt ihn sich über das Xiaomi-Konto.
Dafür gibt es ein etabliertes Werkzeug, den Xiaomi Cloud Tokens Extractor:
👉 https://github.com/PiotrMachowski/Xiaomi-cloud-tokens-extractor
Unter Releases gibt es eine fertige
token_extractor.exefür Windows — Python muss man dafür nicht installieren.Ablauf:
token_extractor.exeherunterladen und starten (Konsolenfenster)- Xiaomi-Konto (E-Mail und Passwort) eingeben
- Server-Region wählen — für Deutschland
de - Das Tool listet alle Geräte des Kontos auf
Für jedes Gerät bekommt man unter anderem:
Name: Jonr T5 Pro ID: 1234567890 <- das ist die DID Token: a1b2c3d4e5f6... <- 32 Hex-Zeichen Model: xtl.vacuum.2407a IP: 192.168.x.y MAC: xx:xx:xx:xx:xx:xxToken, DID und IP brauchen wir gleich. Das
Modelsagt euch, ob euer Gerät überhaupt zu diesem Skript passt.⚠️ Der Token ist ein Zugangsschlüssel zum Gerät. Nicht in öffentliche Repos, Foren oder Screenshots — beim Posten schwärzen.
3. Woher die ganzen Nummern kommen (siid / piid / aiid)
Damit das Skript nicht wie Magie wirkt, kurz die Herkunft — das ist auch der Weg, um es auf andere Modelle anzupassen.
Xiaomi-Geräte beschreiben sich selbst über eine MIoT-Spezifikation, die öffentlich abrufbar ist:
https://miot-spec.org/miot-spec-v2/instance?type=urn:miot-spec-v2:device:vacuum:0000A006:xtl-2407a:2Der entscheidende Teil im URN ist das Modellkürzel (
xtl-2407a). Die passende URN zum eigenen Gerät findet man über die Suche auf https://miot-spec.org/.Die Datei ist JSON und hat drei Ebenen:
Bedeutung Beispiel siidService — ein Funktionsblock 2= Robot Cleaner,16= Battery,17= herstellereigener BlockpiidProperty — ein Wert in diesem Block 2.2= Status,16.1= AkkuaiidAction — etwas, das man auslösen kann 2.1= Start,17.3= stop-clean⚠️ Wichtigste Falle überhaupt:
piidundaiidsind getrennte Nummernkreise. Bei meinem Gerät ist17.46als Actionset-clean-mode, als Property dagegen die Reinigungshistorie. Wer das verwechselt, sucht sich stundenlang tot.Zweite Falle: Viele Einträge haben eine leere
description. Der echte Name steht dann imtype-URN:{ "iid": 34, "type": "urn:xtl-spec:property:clean-values:00000022:xtl-2407a:1", "description": "", "format": "string" }descriptionist leer, aber der URN verrätclean-values— die Raumliste. So habe ich die halbe Landkarte erschlossen.
4. Die wichtigsten Werte (hier vom T5 Pro, andere Modelle ähnlich)
Zum Auslesen:
siid.piid Bedeutung 2.2Status: 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 16.1Akku in % 17.35Fehlercode (0 = alles gut) 17.9clean-type-status — Full/Area/Zone/Spot Clean, jeweils mit Pause und Resume 17.10clean-mode: 0 BothWork · 1 OnlySweep · 2 OnlyMop · 3 SweepFirst · 4 Custom 17.12Wassermenge: 0 Low · 1 Mid · 2 High 17.13Saugstufe: 0 Quiet · 1 Auto · 2 Strong · 3 Max 17.34clean-values — der laufende Auftrag (Raumliste) 17.49Klartextstatus, z. B. "HotDry"Zu
17.10ein Hinweis, weil die deutsche App das unglücklich übersetzt: „saugen&wischen" istBothWork(gleichzeitig), „saugen und wischen" istSweepFirst(erst alles saugen, danach wischen). Die englischen Bezeichner in der Spec sind eindeutiger als die Übersetzung.Zum Steuern — es gibt keine schreibbaren Properties, alles läuft über Actions:
siid.aiid Wirkung Parameter 2.1Start (ganzes Haus) / Fortsetzen — 2.2Fahrt anhalten — 2.7Pause — 16.1Zurück zur Basis — 17.1start-clean (raumweise) piid 8= Art,piid 34= Raumliste17.3stop-clean — Auftrag beenden — 17.26Saugstufe setzen piid 1317.27Wassermenge setzen piid 1217.46Reinigungsart setzen piid 1017.28Anzahl Durchgänge piid 14(1 oder 2)
5. Die vier Stolpersteine
Das hier ist der eigentliche Grund für diesen Beitrag — daran hängt man sonst garantiert fest.
5.1 Nie zwei Clients gleichzeitig
Das Gerät verträgt keine parallelen miIO-Sitzungen. Läuft das Skript (das ja pollt) und man testet parallel etwas per Konsole, quittiert der Sauger Befehle mit
code 0und tut nichts. Das sieht exakt aus wie ein falscher Parameter — ist aber eine Kollision.Mich hat das einen halben Tag gekostet: Ich habe ein Dutzend Parameterformate durchprobiert, obwohl das allererste schon richtig war. Vor manuellen Tests das Skript deaktivieren.
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.
5.2 Befehle brauchen Pausen
Vier Actions direkt hintereinander (Saugstufe → Wasser → Modus → Start) — und der Sauger verschluckt die letzte. 1,5 Sekunden zwischen den Aufrufen genügen.
5.3 Stoppen heißt Auftrag beenden
2.2hält nur die Fahrt an. Der Auftrag bleibt danach im Zustand „Area Pause" stehen (17.9 = 9), und17.34trägt weiter die Raumliste. Folgen:- Die App verweigert einen Moduswechsel („Auftrag muss erst abgebrochen werden")
- Der Sauger fährt später von selbst wieder los — etwa nachdem die Station die Mopps gewaschen hat, nimmt er den pausierten Auftrag wieder auf
- Es läuft keine Nacharbeit, weil der Auftrag nie abgeschlossen wurde
Richtig ist
17.3 stop-clean. Danach geht17.9auf Idle und17.34wird leer.Merksatz: Bei merkwürdigem Verhalten immer
17.9mitlesen —2.2zeigt „Lädt", während im Hintergrund noch ein Auftrag pausiert.5.4 Große Antworten kommen gar nicht an
Wird eine Property-Antwort zu groß, sendet das Gerät überhaupt kein Paket — kein Fehler, einfach Stille. Bei mir passierte das ab etwa acht Zeitplänen in
17.17. Also nicht die dicken JSON-Properties (Historie, Zeitpläne) im Minutentakt pollen.Und noch eine Kleinigkeit: Bei
get_propertieskommen manchmal weniger Ergebnisse zurück als angefragt. Deshalb die Antworten immer über die mitgeliefertensiid/piidzuordnen, nie über die Reihenfolge. Batchgröße 5 hat sich bewährt.
6. Raum-IDs herausfinden
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,
get-map-infosliefert nur eine Referenz.Der Trick: über die Zeitpläne. Ein Zeitplan enthält die Raumliste im Klartext, und Property
17.17gibt die Zeitpläne aus.Vorgehen:
- In der App einen Zeitplan anlegen — deaktiviert, damit nichts losfährt — und genau einen Raum zuweisen
17.17auslesen- In der Ausgabe steht:
{ "id":"1", "on":0, "hour":12, "min":0, "mapId":1, "cleanValues":[6], <-- das ist die gesuchte Raum-ID "workMode":0, "fanMode":3, "waterMode":1, "cleanCount":1, "routePrefer":1 }- Für jeden Raum wiederholen, danach die Test-Zeitpläne wieder löschen
⚠️ Höchstens drei Zeitpläne gleichzeitig im Gerät lassen — sonst wird die Antwort zu groß (siehe 5.4) und
17.17liefert gar nichts mehr.⚠️ Die IDs gelten nur für die aktuelle Karte. Nach einer Neukartierung oder nach dem Teilen/Zusammenlegen von Räumen werden sie neu vergeben, und vorhandene Zeitpläne muss man neu anlegen — die App kann sie nicht umhängen.
Nebenbei: Bei mir zeigte die App die Räume vor dem Umbenennen als „Zimmer1…n" an, und die interne ID lag konstant zwei höher 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.
7. Das Format der Raumliste — der Knackpunkt
piid 34will die Raum-IDs als Array-Notation in einem String:action(17.1, [{ piid: 8, value: 3 }, { piid: 34, value: "[6]" }]) // AreaClean RaumlisteMehrere Räume als
"[3,8,11]". Die Reihenfolge im Array ist die Reinigungsreihenfolge.Was nicht funktioniert (alles durchprobiert):
Versuch Ergebnis nackte Zahl "6"fährt los, kehrt um: „Ausgewählter Bereich nicht gefunden" echtes Array [6]statt String-9999 user ack timeoutJSON-Objekt {"mapId":1,"cleanValues":[6]}still verworfen, keine Fahrt nur piid 34ohnepiid 8-9999 user ack timeoutpositionale Parameter in: [3, "[6]"]-9999 user ack timeoutWie ich das Format gefunden habe — und das ist die Methode, die ich jedem empfehle, der an einem unklaren Parameter hängt:
Das Gerät einmal selbst machen lassen und dabei zuschauen. Ich habe einen Zeitplan auf „in drei Minuten" gestellt und währenddessen
17.34mitgelesen. Da stand dann"[7,6]"— und damit war das Format klar, statt weiter zu raten.
8. Das Skript
Anlegen im javascript-Adapter als normales JavaScript (nicht Blockly). Oben IP, Token und DID eintragen und die Raumtabelle mit den eigenen IDs füllen.
Es legt alle States selbst an unter
0_userdata.0.Saugroboter.Jonr.*— der Pfad steht oben im Skript und ist frei wählbar:Anzeige (
info.*): Status als Code und Klartext, Akku, Fehlercode, Reinigungsart, Saugstufe, Wassermenge, Erreichbarkeit, Zeitpunkt der letzten AbfrageSteuerung (echte Schalter,
read+write):State „ein" „aus" SaugerMax + Mid + BothWork, ganzes Haus stoppen und zur Basis Sauger_Wischennur wischen stoppen und zur Basis Sauger_Pausepausieren fortsetzen raum.<Name>diesen Raum reinigen stoppen und zur Basis Die Namen sind frei wählbar — sie stehen oben im Skript und tauchen genauso bei Alexa auf.
Gepollt wird alle 60 Sekunden, während der Arbeit alle 15.
/* * ============================================================================ * 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 -> 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 => { const c = crypto.createCipheriv('aes-128-cbc', key, iv); return Buffer.concat([c.update(d, 'utf8'), c.final()]); }; const decrypt = b => { 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 => { if (pending) { const p = pending; pending = null; p(m); } }); sock.on('error', e => log('Socket-Fehler: ' + e.message, 'warn')); } function tx(buf, timeout) { return new Promise((res, rej) => { if (!sock || stopping) return rej(new Error('Socket zu')); const t = setTimeout(() => { pending = null; rej(new Error('timeout')); }, timeout); pending = m => { clearTimeout(t); res(m); }; sock.send(buf, 0, buf.length, PORT, IP, e => { if (e) { clearTimeout(t); pending = null; rej(e); } }); }); } async function retry(fn, n = 4) { let last; for (let i = 0; i < n && !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(() => 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 > 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(() => 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 < list.length; i += 5) { // Batchgröße 5 const batch = list.slice(i, i + 5); try { const r = await call('get_properties', batch.map(p => ({ 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 && r.result ? r.result.code : (r && r.error ? r.error.code : -1); if (code !== 0) throw new Error('Action ' + a.siid + '.' + a.aiid + ' -> 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 => new Promise(r => 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 && !mopOnly); await syncSwitch('Sauger_Wischen', running && 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 => { if (obj.state.val) await handle('Start ganzes Haus (saugen+wischen)', () => startWholeHome(MODE_BOTH)); else await handle('Stop und zur Basis', () => stopAndHome()); }); on({ id: BASE + '.Sauger_Wischen', change: 'any', ack: false }, async obj => { if (obj.state.val) await handle('Start nur wischen', () => startWholeHome(MODE_MOP)); else await handle('Stop und zur Basis', () => stopAndHome()); }); on({ id: BASE + '.Sauger_Pause', change: 'any', ack: false }, async obj => { if (obj.state.val) await handle('Pause', () => action(ACT.PAUSE)); else await handle('Fortsetzen', () => action(ACT.START_SWEEP)); }); for (const [name, r] of Object.entries(ROOMS)) { on({ id: BASE + '.raum.' + name, change: 'any', ack: false }, async obj => { if (obj.state.val) await handle('Start Raum ' + name + ' (Raum-ID ' + r.id + ')', () => startRooms([r.id])); else await handle('Stop und zur Basis', () => stopAndHome()); }); } // ---------------------------------------------------------------- Start / Ende onStop(() => { stopping = true; if (pollTimer) clearTimeout(pollTimer); try { if (sock) sock.close(); } catch (e) { /* egal */ } }, 2000); (async () => { await createStates(); openSocket(); log('Saugroboter-Skript gestartet — ' + IP + ', ' + Object.keys(ROOMS).length + ' Räume bekannt'); await poll(); })();
9. Alexa-Anbindung (optional)
Die Schalter tragen bereits ein
smartName, deriot-Adapter meldet sie an Alexa. In der Alexa-App einmal „nach neuen Geräten suchen" — fertig.Warum echte Schalter und keine Buttons: Ein State mit
role: buttonreagiert nur auf „ein" und bleibt danach auftruestehen. Deshalb brauchen Routinen für solche States immer den Dreisprung einschalten → 5 Sekunden warten → ausschalten. 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 auffalse, und „aus" funktioniert wie erwartet.⚠️ Den Geräten keine Alexa-Raumgruppen zuweisen. Läge ein Gerät namens „Sauger Wohnzimmer" in der Alexa-Gruppe Wohnzimmer, würde „Alexa, Wohnzimmer aus" den Sauger mitschalten. Beim Einrichten den Gruppen-Assistenten überspringen.
Für freie Formulierungen („Alexa, starte den Sauger") legt man in der Alexa-App eine Routine mit Sprachauslöser an, die den Schalter ein- oder ausschaltet.
10. Was nicht geht
- Kartenansicht. Die Kartendaten kommen als proprietärer Binärstrom und liegen ohnehin in der Cloud;
get-map-dataliefert lokal nur eine Referenz. - Zonenreinigung per Koordinaten. Nicht probiert.
- Nacharbeit nach einem Abbruch erzwingen. Es gibt Actions dafür (
17.40set-wash-mop,17.41set-dry-mop,17.42set-collect-dust, jeweilsbool), getestet habe ich sie nicht.
11. Anpassung an andere xtl-Modelle
Es gibt mindestens
xm2216(P20 Pro),xt3411(X1 MAX),xt3401,3505,3512. Das Vorgehen:- Modellkürzel aus dem Token-Extractor ablesen
- Passende Spec von https://miot-spec.org/ laden
siid/piid/aiidim Skript gegen die eigene Spec prüfen — Service 2 und 16 sind meist gleich, der herstellereigene Block (bei mir 17) kann abweichen- Raum-IDs über die Zeitplan-Methode aus Abschnitt 6 ermitteln
Wenn jemand das für ein anderes Modell durchzieht: gerne hier posten, dann sammeln wir die Unterschiede.
Getestet ausschließlich mit dem JONR T5 Pro GEN 1 - der Gen2 hat Sprachsteuerung wohl schon inkludiert, kostet aber deutlich mehr.
Keine Gewähr, keine Wartungszusage — aber Rückfragen beantworte ich gern; Über Verbesserungen oder Vorschläge freue ich mich sehr.
Erarbeitet unter Zuhilfenahme von KI.
Hey! Du scheinst an dieser Unterhaltung interessiert zu sein, hast aber noch kein Konto.
Hast du es satt, bei jedem Besuch durch die gleichen Beiträge zu scrollen? Wenn du dich für ein Konto anmeldest, kommst du immer genau dorthin zurück, wo du zuvor warst, und kannst dich über neue Antworten benachrichtigen lassen (entweder per E-Mail oder Push-Benachrichtigung). Du kannst auch Lesezeichen speichern und Beiträge positiv bewerten, um anderen Community-Mitgliedern deine Wertschätzung zu zeigen.
Mit deinem Input könnte dieser Beitrag noch besser werden 💗
Registrieren Anmelden