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-vacuum ist 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, nur dgram und crypto — 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 javascript installiert (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.exe für Windows — Python muss man dafür nicht installieren.
Ablauf:
token_extractor.exe herunterladen 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:xx
Token, DID und IP brauchen wir gleich. Das Model sagt 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:2
Der 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
siid
Service — ein Funktionsblock
2 = Robot Cleaner, 16 = Battery, 17 = herstellereigener Block
piid
Property — ein Wert in diesem Block
2.2 = Status, 16.1 = Akku
aiid
Action — etwas, das man auslösen kann
2.1 = Start, 17.3 = stop-clean
⚠️ Wichtigste Falle überhaupt: piid und aiid sind getrennte Nummernkreise. Bei meinem Gerät ist 17.46 als Action set-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 im type-URN:
{ "iid": 34, "type": "urn:xtl-spec:property:clean-values:00000022:xtl-2407a:1",
"description": "", "format": "string" }
description ist leer, aber der URN verrät clean-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.2
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
16.1
Akku in %
17.35
Fehlercode (0 = alles gut)
17.9
clean-type-status — Full/Area/Zone/Spot Clean, jeweils mit Pause und Resume
17.10
clean-mode: 0 BothWork · 1 OnlySweep · 2 OnlyMop · 3 SweepFirst · 4 Custom
17.12
Wassermenge: 0 Low · 1 Mid · 2 High
17.13
Saugstufe: 0 Quiet · 1 Auto · 2 Strong · 3 Max
17.34
clean-values — der laufende Auftrag (Raumliste)
17.49
Klartextstatus, z. B. "HotDry"
Zu 17.10 ein Hinweis, weil die deutsche App das unglücklich übersetzt: „saugen&wischen" ist BothWork (gleichzeitig), „saugen und wischen" ist SweepFirst (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.1
Start (ganzes Haus) / Fortsetzen
—
2.2
Fahrt anhalten
—
2.7
Pause
—
16.1
Zurück zur Basis
—
17.1
start-clean (raumweise)
piid 8 = Art, piid 34 = Raumliste
17.3
stop-clean — Auftrag beenden
—
17.26
Saugstufe setzen
piid 13
17.27
Wassermenge setzen
piid 12
17.46
Reinigungsart setzen
piid 10
17.28
Anzahl 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 0 und 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.2 hält nur die Fahrt an. Der Auftrag bleibt danach im Zustand „Area Pause" stehen (17.9 = 9), und 17.34 trä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 geht 17.9 auf Idle und 17.34 wird leer.
Merksatz: Bei merkwürdigem Verhalten immer 17.9 mitlesen — 2.2 zeigt „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_properties kommen manchmal weniger Ergebnisse zurück als angefragt. Deshalb die Antworten immer über die mitgelieferten siid/piid zuordnen, 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-infos liefert nur eine Referenz.
Der Trick: über die Zeitpläne. Ein Zeitplan enthält die Raumliste im Klartext, und Property 17.17 gibt die Zeitpläne aus.
Vorgehen:
In der App einen Zeitplan anlegen — deaktiviert, damit nichts losfährt — und genau einen Raum zuweisen
17.17 auslesen
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.17 liefert 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 34 will die Raum-IDs als Array-Notation in einem String:
action(17.1, [{ piid: 8, value: 3 }, { piid: 34, value: "[6]" }])
// AreaClean Raumliste
Mehrere 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 timeout
JSON-Objekt {"mapId":1,"cleanValues":[6]}
still verworfen, keine Fahrt
nur piid 34 ohne piid 8
-9999 user ack timeout
positionale Parameter in: [3, "[6]"]
-9999 user ack timeout
Wie 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.34 mitgelesen. 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 Abfrage
Steuerung (echte Schalter, read+write):
State
„ein"
„aus"
Sauger
Max + Mid + BothWork, ganzes Haus
stoppen und zur Basis
Sauger_Wischen
nur wischen
stoppen und zur Basis
Sauger_Pause
pausieren
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, der iot-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: button reagiert nur auf „ein" und bleibt danach auf true stehen. 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 auf false, 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-data liefert lokal nur eine Referenz.
Zonenreinigung per Koordinaten. Nicht probiert.
Nacharbeit nach einem Abbruch erzwingen. Es gibt Actions dafür (17.40 set-wash-mop, 17.41 set-dry-mop, 17.42 set-collect-dust, jeweils bool), 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/aiid im 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.