Weiter zum Inhalt
  • Home
  • Aktuell
  • Tags
  • 0 Ungelesen 0
  • Kategorien
  • Unreplied
  • Beliebt
  • GitHub
  • Docu
  • Hilfe
Skins
  • Hell
  • Brite
  • Cerulean
  • Cosmo
  • Flatly
  • Journal
  • Litera
  • Lumen
  • Lux
  • Materia
  • Minty
  • Morph
  • Pulse
  • Sandstone
  • Simplex
  • Sketchy
  • Spacelab
  • United
  • Yeti
  • Zephyr
  • Dunkel
  • Cyborg
  • Darkly
  • Quartz
  • Slate
  • Solar
  • Superhero
  • Vapor

  • Standard: (Kein Skin)
  • Kein Skin
Einklappen
ioBroker Logo

Community Forum

donate donate
  1. ioBroker Community Home
  2. Deutsch
  3. Skripten / Logik
  4. JavaScript
  5. [Skript] JONR-Saugroboter lokal steuern (hier: T5 Pro Gen1)

NEWS

  • Monatsrückblick Juli / August 2026 ist online!
    BluefoxB
    Bluefox
    7
    1
    144

  • Der neue Monatsrückblick für Mai und Juni 2026 ist online!
    BluefoxB
    Bluefox
    8
    1
    1.3k

  • wichtiges UPDATE für controller 7.2.2 im stable
    HomoranH
    Homoran
    10
    1
    3.8k

[Skript] JONR-Saugroboter lokal steuern (hier: T5 Pro Gen1)

Geplant Angeheftet Gesperrt Verschoben JavaScript
1 Beiträge 1 Kommentatoren 22 Aufrufe 1 Beobachtet
  • Älteste zuerst
  • Neuste zuerst
  • Meiste Stimmen
Antworten
  • In einem neuen Thema antworten
Anmelden zum Antworten
Dieses Thema wurde gelöscht. Nur Nutzer mit entsprechenden Rechten können es sehen.
  • H Offline
    H Offline
    hwiedermann
    schrieb am zuletzt editiert von
    #1

    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:

    1. token_extractor.exe herunterladen und starten (Konsolenfenster)
    2. Xiaomi-Konto (E-Mail und Passwort) eingeben
    3. Server-Region wählen — für Deutschland de
    4. 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:

    1. In der App einen Zeitplan anlegen — deaktiviert, damit nichts losfährt — und genau einen Raum zuweisen
    2. 17.17 auslesen
    3. 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 }
    
    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:

    1. Modellkürzel aus dem Token-Extractor ablesen
    2. Passende Spec von https://miot-spec.org/ laden
    3. 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
    4. 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.

    1 Antwort Letzte Antwort
    0

    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
    Antworten
    • In einem neuen Thema antworten
    Anmelden zum Antworten
    • Älteste zuerst
    • Neuste zuerst
    • Meiste Stimmen


    Support us

    ioBroker
    Community Adapters
    Donate

    300

    Online

    33.0k

    Benutzer

    83.7k

    Themen

    1.3m

    Beiträge
    Community
    Impressum | Datenschutz-Bestimmungen | Nutzungsbedingungen | Einwilligungseinstellungen
    ioBroker Community 2014-2026
    logo
    • Anmelden

    • Du hast noch kein Konto? Registrieren

    • Anmelden oder registrieren, um zu suchen
    • Erster Beitrag
      Letzter Beitrag
    0
    • Home
    • Aktuell
    • Tags
    • Ungelesen 0
    • Kategorien
    • Unreplied
    • Beliebt
    • GitHub
    • Docu
    • Hilfe