Weiter zum Inhalt
  • Home
  • Aktuell
  • 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
    10
    1
    1.2k

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

  • wichtiges UPDATE für controller 7.2.2 im stable
    HomoranH
    Homoran
    11
    1
    3.9k

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

Geplant Angeheftet Gesperrt Verschoben JavaScript
2 Beiträge 1 Kommentatoren 53 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
    H
    hwiedermann
    schrieb am zuletzt editiert von hwiedermann
    #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.

    ⚠️ Das Skript stand hier — ich habe es entfernt.

    Es enthielt einen Fehler, der erst nach Wochen auffällt: Der Startbefehl 2.1 startet einen Lauf ohne Bezug zur gespeicherten Karte. Der Sauger zeichnet dabei jedes Mal eine neue, alle mühsam ermittelten Raum-IDs werden wertlos, und in der App stapeln sich die Karten.

    Die korrigierte und deutlich erweiterte Fassung steht in Beitrag 2 (https://forum.iobroker.net/topic/85226/skript-jonr-saugroboter-lokal-steuern-hier-t5-pro-gen1/2).

    Dort gibt es außerdem die vollständigen Fehler- und Meldungstexte des Geräts im Klartext samt Hinweis, was jeweils zu tun ist, ein Ereignis-Journal mit Quellenangabe pro Befehl — und ein Hilfsskript, mit dem ihr euch dieselben Texte für euer Xiaomi-Gerät ziehen könnt.

    Alles andere in diesem Beitrag gilt weiterhin: Token und Device-ID besorgen, die Nummern-Systematik, die Stolpersteine, das Ermitteln der Raum-IDs.

    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.

    ioBroker js-controller 7.2.2 · 61 Instanzen
    Intel NUC5CPYB · Celeron N3060 · 8 GB · Debian 12 · Node 22
    Netz: FritzBox 7530 · UniFi USW-16-PoE + 2 APs · Synology NAS
    Shelly · Sonoff/Tasmota · Tuya · Zigbee · Nuki · Netatmo · Helios KWL · Anker Solix PV · Gardena · Alexa · Telegram · JONR T5 Pro (eigenes Skript)

    1 Antwort Letzte Antwort
    0
    • H
      H
      hwiedermann
      schrieb am zuletzt editiert von hwiedermann
      #2

      Hallo zusammen,

      seit dem Beitrag oben sind ein paar Tage vergangen, in denen der Sauger täglich gelaufen ist. Und dabei ist einiges aufgefallen, was ich damals schlicht nicht wissen konnte. Ein Fehler war richtig übel, zwei Sachen waren einfach nur ärgerlich — und am Ende bin ich an eine Quelle geraten, die mir alle Fehlertexte des Geräts frei Haus geliefert hat.

      Ganz unten hängt das überarbeitete Skript. Wer das alte im Einsatz hat: bitte tauschen, das hatte einen Fehler, der auf Dauer richtig weh tut.


      1. Der Ärger: Die Raumreinigung hat mir die Karte zerlegt

      Das ist der Grund, warum ich diesen Beitrag überhaupt schreibe.

      Irgendwann fiel meiner Frau auf, dass in der App plötzlich mehrere Karten lagen. Und die Raumnamen stimmten nicht mehr. Ich hab die Karte neu eingeteilt, alles benannt — zwei Tage später dasselbe Bild.

      Ich hab dann erst überall gesucht, nur nicht bei mir selbst: an der App, am WLAN, an der Firmware. Der Übeltäter war mein eigener Startbefehl.

      Im ersten Skript startete ich mit 2.1 — dem allgemeinen „los geht's". Der Haken: dieser Befehl startet einen Lauf ganz ohne Bezug zur gespeicherten Karte. Der Sauger fängt an zu fahren und zeichnet dabei eine neue. Alle Raumnummern, die ich mühsam ermittelt hatte, waren danach wertlos.

      Richtig ist der Raumbefehl 17.1, auch wenn man „alles" will:

      action(17.1, [{ piid: 8, value: 1 },      // 1 = ganze Wohnung
                    { piid: 34, value: "[]" }]) // Raumliste -- LEER, aber als Array!
      

      Der Knackpunkt ist das "[]". Ein leerer String "" reicht nicht, das Gerät antwortet mit einem Fehler. Genau derselbe Stolperstein wie bei den Räumen, wo "6" scheitert und erst "[6]" geht — ich hatte ihn nur nicht auf den Fall „ganze Wohnung" übertragen.

      Wenn ihr nur eine Sache aus diesem Beitrag mitnehmt, dann diese.


      2. „Alexa, Staubsauger aus" — und nichts passiert

      Der zweite Fund ist keiner am Sauger, aber er dürfte einige treffen, die ioBroker mit Alexa verbinden.

      Bei uns kam es immer wieder vor, dass Alexa brav „OK" sagte und der Sauger einfach weiterfuhr. Kein Fehler, kein Logeintrag, nichts. Erst dachte ich an Funkprobleme.

      Es lag an Karteileichen in Alexas Gerätekonto. Vor dem JONR stand hier ein Roborock über den mihome-vacuum-Adapter. Der ist längst weg — aber Alexa hatte sich seine Geräte gemerkt. Elf Stück, darunter eines, das schlicht „Staubsauger" hieß.

      Und jetzt kommt's: Sage ich „Alexa, Staubsauger aus", nimmt Alexa den exakten Gerätenamen-Treffer und nicht meine Routine. Der Befehl geht an die ioBroker-Skill, die findet das Gerät nicht mehr — und Alexa quittiert trotzdem mit „OK".

      Das Fiese: In der Alexa-App sieht man diese Geräte nicht. Sie tauchen nur auf, wenn man im alexa2-Adapter unter Smart-Home-Devices nachschaut. Wer also mal einen Adapter mit Smart-Home-Geräten hatte und ihn wieder losgeworden ist, sollte da mal reingucken.

      ⚠️ Falle beim Aufräumen, ich bin zweimal reingefallen: Alexa schreibt die Gerätenamen intern um — Leerzeichen und Umlaute werden zu Unterstrichen. Vergleicht man stur auf Gleichheit, sehen aktive Geräte wie Leichen aus. Vor dem Löschen also normalisieren und gegenprüfen.


      3. Wie ich sowas überhaupt gefunden habe

      Beide Sachen oben haben eins gemeinsam: Sie passieren, wenn man nicht hinschaut, und hinterher ist nichts mehr da. Das ioBroker-Log wirft nach sieben Tagen weg, und eine Zeitreihe hatte ich gar nicht.

      Es gab hier einen Vorfall — der Sauger hatte sich in einer Gardine verfangen und wurde befreit — den ich am nächsten Tag schlicht nicht mehr rekonstruieren konnte. Danach habe ich das Skript um eine Beobachtung erweitert, und die hat sich seitdem mehrfach bezahlt gemacht:

      Ein Ereignis-Journal. Eine Textdatei pro Monat, die jede Zustandsänderung mitschreibt. Nichts Kompliziertes, fs.appendFileSync reicht — das ist in der javascript-Sandbox erlaubt. So sieht das aus:

      10:58:38 | BEFEHL  | Start Raum Schlafzimmer (Raum-ID 4)  [von: Alexa (iot)]
      10:58:40 | AKTION  | ausgefuehrt: Start Raum Schlafzimmer (Raum-ID 4)
      11:00:22 | BEFEHL  | Pause  [von: Alexa (iot)]
      11:00:26 | AUFTRAG | Raum pausiert (Code 9)
      11:01:16 | BEFEHL  | Fortsetzen  [von: Alexa (iot)]
      11:01:19 | AUFTRAG | Raum fortgesetzt (Code 10)
      

      Das Wichtigste daran ist die eckige Klammer: die Quelle. ioBroker liefert bei jeder State-Änderung mit, wer sie ausgelöst hat — Alexa, die Admin-Oberfläche, VIS oder das Skript selbst. Genau daran habe ich gesehen, dass beim „Staubsauger aus" gar kein Befehl ankam. Ohne diese Zeile hätte ich weiter beim Sauger gesucht.

      Was man dabei wissen sollte: Befehle aus der Hersteller-App und Tasten am Gerät tauchen hier nie auf. Die gehen über die Cloud direkt an den Sauger, an uns vorbei. Ihre Wirkung sieht man trotzdem — der Status ändert sich ja.

      Zum Abfragetakt: Ich frage alle 60 Sekunden, und wenn der Sauger fährt, alle 15. Das ist ein Kompromiss. Man kann den Sauger mit Anfragen nämlich durchaus überfahren — läuft eine noch, gehen die nächsten ins Leere. Und ein Nebeneffekt, über den ich gestolpert bin: Meldungen, die nur ein paar Sekunden anstehen, verpasst man damit. Wer die zuverlässig braucht, muss enger abfragen. Ich lebe damit.


      4. Die Fundgrube: Die Fehlertexte stehen im Mi-Home-Plugin

      Und jetzt der Teil, der auch für Leute mit ganz anderen Xiaomi-Geräten interessant sein dürfte.

      Im ersten Skript standen die Fehlertabellen leer:

      const ERROR = { 0: 'kein Fehler' };
      

      Der Grund: Die Gerätespezifikation sagt bei den Fehlernummern nur „ist eine Zahl zwischen 0 und 65535" — welche Zahl was bedeutet, steht nirgends. Bei anderen Werten wie dem Status steht das sehr wohl da, nur bei den Fehlern eben nicht. Ich hatte mich damit abgefunden, die Bedeutungen nach und nach selbst zu sammeln: bei jeder Störung nachschauen, was die App anzeigt, und eine Zeile nachtragen.

      Das war unnötig.

      Wenn man in der Mi-Home-App auf sein Gerät tippt, lädt sie dafür ein eigenes kleines Programm nach — für jedes Gerätemodell ein anderes. Das ist die Oberfläche mit der Karte, den Knöpfen, den Meldungen. Und dieses Programm muss die Fehlertexte kennen, sonst könnte es sie nicht anzeigen.

      Man kommt frei dran. Bei mir waren es 3,7 MB, und der Text stand da völlig unverschlüsselt drin — in elf Sprachen, Deutsch dabei.

      So kommt ihr an eure eigenen — Schritt für Schritt

      Schritt 1: Den Modellnamen herausfinden

      Jedes Xiaomi-Gerät hat neben seinem hübschen Namen („Jonr T5 Pro") noch einen technischen, der so aussieht: xtl.vacuum.2407a. Der ist der Schlüssel zu allem Weiteren.

      Am einfachsten kommt ihr da über den Token-Extractor ran — dasselbe Werkzeug, mit dem ihr euch im ersten Beitrag (Abschnitt 2) schon Token und Device-ID geholt habt. Der spuckt für jedes Gerät einen Block aus, und da steht er drin:

      Name:     Jonr T5 Pro
      ID:       1234567890
      Token:    a1b2c3d4e5f6...
      Model:    xtl.vacuum.2407a      <-- den brauchen wir
      

      Wer das Werkzeug nicht extra laufen lassen will: Der Name steht auch im ioBroker-Log, wenn man den mihome-vacuum-Adapter einmal auf das Gerät ansetzt. Bei mir war das sogar der ursprüngliche Anlass für das ganze Projekt, da stand nämlich:

      Model xtl.vacuum.2407a not supported
      

      Also: einmal kurz den Adapter dranhängen, ins Log schauen, Modellnamen abschreiben, Adapter wieder weg.

      Schritt 2: Die Spec-Seite eures Geräts aufrufen

      Und hier wird's unintuitiv, deshalb im Detail.

      Achtung, falsche Fährte: Im ersten Beitrag hatte ich miot-spec.org als Quelle genannt. Wenn ihr die Seite aufruft, seht ihr eine weiße Seite mit dem Wort „worked" darauf. Das ist kein Fehler und ihr habt euch nicht vertippt — das ist eine reine Datenschnittstelle für Programme, da gibt es nichts anzuschauen.

      Die Seite für Menschen ist home.miot-spec.com. Die hat auch eine Suche — man muss sie nur richtig füttern. Nach „Jonr" allein oder nach „T5 Pro" zu suchen bringt nichts Brauchbares.

      Was funktioniert: Seite oben auf Englisch umstellen — und falls euer Browser von sich aus eine Google-Übersetzung drüberlegt, die wieder ausschalten, sonst sucht ihr im übersetzten Text. Dann nach vacuum jonr suchen, also Geräteart plus Hersteller. Damit tauchen die Modelle auf.

      Wer seinen Modellnamen aus Schritt 1 schon hat, ist über die Adresszeile allerdings schneller — einfach selbst zusammenbauen:

      https://home.miot-spec.com/spec/xtl.vacuum.2407a
      └──────── immer gleich ────────┘└─ euer Modellname ─┘
      

      Also https://home.miot-spec.com/spec/ in die Adresszeile, den Modellnamen aus Schritt 1 dranhängen, Enter. Fertig. Für den P20 Pro wäre das .../spec/xtl.vacuum.xm2216, und so weiter.

      Auf der Seite, die dann kommt, steht alles, was euer Gerät kann — jede Eigenschaft und jeder Befehl mit seiner Nummer. Das ist übrigens auch die Seite, auf der man nachschlägt, wenn man das Skript an ein anderes Modell anpassen will.

      Schritt 3: Das Plugin herunterladen

      Ziemlich weit oben auf dieser Seite sitzt ein Link auf Chinesisch:

      下载米家插件

      Das heißt „Mi-Home-Plugin herunterladen" — und das ist genau das, was wir wollen. Bei mir kam eine ZIP-Datei mit einem ellenlangen Namen und 3,7 MB Inhalt heraus.

      Schritt 4: Auspacken und nachschauen

      1. ZIP auspacken
      2. Interessant ist darin die Datei android/main.bundle
      3. Das ist eine große Textdatei. Sucht darin nach title: — dort stehen die Fehler, jeweils als
        Nummer mit zwei Verweisen: einer auf die Meldung, einer auf den Lösungsvorschlag
      4. Die Texte selbst stehen weiter unten in derselben Datei, ordentlich nach Sprachen sortiert

      Das muss man aber nicht von Hand machen — ganz unten in diesem Beitrag steht ein kleines Python-Skript, das einem die Fummelei abnimmt. Aufruf:

      python extract-codes.py android/main.bundle
      

      Es sucht sich die deutschen Texte selbst heraus und spuckt am Ende fertige Tabellen aus, die man direkt ins ioBroker-Skript kopieren kann. Braucht nur Python, sonst nichts.

      Falls es bei eurem Gerät die falsche Sprache erwischt, zeigt --sprachen alle gefundenen mit einem Beispielsatz an, und mit --sprache 0 nehmt ihr dann eine andere (0 ist normalerweise Englisch). Ich hatte bei meinen ersten Versuchen prompt Koreanisch im Ergebnis stehen — deshalb die Option. 🙂

      Warum ich vorher nichts gefunden hatte: Ich hatte nach kleinen Zahlen gesucht. Die Fehlercodes liegen im 4000er-Bereich.


      5. Die Tabellen für den T5 Pro

      Falls jemand denselben oder einen verwandten Sauger hat. Bei einem anderen Modell bitte selbst ziehen, die Nummern können abweichen.

      Was am Gerät schiefgehen kann

      Code Meldung Was zu tun ist
      4001 Staubbehälter nicht installiert. Öffnen Sie die Roboterabdeckung und setzen Sie den Staubbehälter ein.
      4003 Walzenbürste verheddert. Nehmen Sie die Abdeckung der Walzenbürste ab, nehmen Sie die Walzenbürste heraus und entfernen Sie verhedderte Haare und andere Verunreinigungen.
      4004 Seitenbürste verheddert. Nehmen Sie die Seitenbürste ab und entfernen Sie die verhedderten Haare und andere Verschmutzungen.
      4005 Antriebsrad blockiert. Drehen Sie das Antriebsrad und befreien Sie es von verhedderten Haaren oder anderen Verunreinigungen.
      4006 Abwurfsensor hat Staub angesammelt. Wischen Sie den Tropfensensor mit einem trockenen Tuch ab und stellen Sie den Roboter in eine sichere Position, um ihn neu zu starten.
      4007 Lasersensor verstopft. Entfernen Sie die Gegenstände, die den Lasersensor behindern.
      4008 Radar des Lasersensors blockiert. Entfernen Sie den Schmutz, der den rotierenden Radar des Lasersensors blockiert, und klopfen Sie vorsichtig auf die Sensorabdeckung.
      4009 Antriebsrad rutscht durch. Wischen Sie das Antriebsrad ab und bringen Sie den Roboter in eine andere Position, um ihn neu zu starten.
      4011 Abdeckung des Lasersensors klemmt. Bitte entfernen Sie Objekte, die die Abdeckung des Lasersensors blockieren, oder bringen Sie den Roboter in eine andere Position, um ihn neu zu starten.
      4012 Roboter steckt fest. Bitte entfernen Sie die Hindernisse um den Roboter herum, oder begeben Sie sich in einen anderen Bereich, um neu zu starten.
      4013 Der Kollisionssensor ist blockiert. Bitte klopfen Sie leicht auf die vordere Stoßstange des Roboters und vergewissern Sie sich, dass die Stoßstange zurückfedert.
      4014 Der Roboter hängt. Starten Sie den Roboter neu, nachdem Sie ihn wieder auf den Boden gestellt haben.
      4016 Roboter gekippt. Starten Sie den Roboter neu, nachdem Sie ihn auf eine ebene Fläche gestellt haben.
      4017 Start in Sperrzone nicht möglich. Starten Sie den Roboter neu, nachdem Sie ihn aus dem Sperrgebiet entfernt haben, oder löschen Sie das aktuelle Sperrgebiet.
      4018 Start auf Teppich nicht möglich. Starten Sie den Roboter neu, nachdem Sie ihn aus dem Teppichbereich entfernt haben, oder ändern Sie die aktuelle Teppichreinigungsstrategie.
      4020 Der Wischmopphalter hat sich verheddert. Nehmen Sie den Wischmopphalter ab und entfernen Sie verhedderte Haare und andere Verunreinigungen.
      4021 Wischmopphalter nicht installiert. Bringen Sie beide Mop-Pad-Halterungen richtig an.

      Was an der Station schiefgehen kann

      Code Meldung Was zu tun ist
      4501 Der Staubbeutel ist nicht installiert. Setzen Sie den Staubbeutel richtig ein.
      4502 Nicht genügend Wasser oder kein Schmutzwassertank installiert. Nehmen Sie den Frischwassertank heraus, füllen Sie eine ausreichende Menge Wasser ein, setzen Sie den Frischwassertank wieder ein und vergewissern Sie sich, dass er ordnungsgemäß installiert ist.
      4503 Das Schmutzwasser ist voll oder ein Schmutzwassertank ist nicht installiert. Nehmen Sie den Schmutzwassertank heraus, leeren Sie das gesamte Schmutzwasser, setzen Sie den Schmutzwassertank wieder ein und vergewissern Sie sich, dass er ordnungsgemäß installiert ist.
      4506 Der Reinigungsschlitz des Mopps ist voll oder nicht installiert. 1. Entfernen Sie den Filter aus dem Mopp-Reinigungsschlitz, reinigen Sie die angesammelten Haare und Flecken auf dem Filter und installieren Sie ihn anschließend wieder; 2. Überprüfen Sie, ob Objekte den Ansauganschluss des Reinigungsbereichs des Wischmopps blockieren; 3. Stellen Sie sicher, dass der Deckel des Abwassertanks fest verschlossen ist; 4. Überprüfen Sie, ob der Abwassertank ordnungsgemäß installiert ist.
      4507 Der Reinigungsschlitz des Mopps ist voll oder nicht installiert. 1. Entnehmen Sie den Filter aus dem Mop-Reinigungsschlitz, reinigen Sie alle Haare und Flecken, die sich auf dem Filter angesammelt haben, und installieren Sie ihn wieder; 2. Überprüfen Sie, ob Objekte den Ansauganschluss des Reinigungsbereichs des Wischmopps blockieren; 3. Nehmen Sie den Schmutzwassertankfilter aus dem Modul für die Wasserversorgung und den Abfluss heraus, reinigen Sie ihn gründlich und setzen Sie ihn wieder ein; 4. Vergewissern Sie sich, dass die Abdeckung des automatischen Wasserzufuhr- und -ablassmoduls fest geschlossen ist.
      4901 Wasser kann nicht automatisch nachgefüllt werden. 1. Prüfen Sie, ob der Wasserdruck im Haushalt normal ist; 2. Überprüfen Sie, ob die Wasserzuleitung richtig installiert ist.
      4902 Sie können das Wasser nicht automatisch ablassen. 1. Prüfen Sie, ob das Abflussrohr richtig installiert ist; 2. Öffnen Sie die Abdeckung des Schmutzwassertanks, nehmen Sie den Filter heraus, reinigen Sie ihn und setzen Sie ihn wieder ein.
      4903 Unzureichende Reinigungslösung. Bitte geben Sie eine ausreichende Menge Reinigungslösung in das Modul für die automatische Wasserzufuhr und den Abfluss.

      Und noch ein Fund nebenbei

      Direkt neben dem Fehlercode sitzt ein zweiter Wert, den ich im alten Skript als „zweiter Fehlerslot, keine Ahnung" mitgeschleppt hatte. Der sprang immer mal auf Zahlen um die 1000.

      In der Spec heißt er schlicht message-notification. Ich hatte den Namen einfach nicht gelesen. Da stehen die Meldungen drin, die die App anzeigt:

      Code Meldung
      1001 Ladevorgang abgeschlossen, Wiederaufnahme der Reinigung ab dem Unterbrechungspunkt
      1002 Niedriger Akkustand, zurück zum Laden
      1003 Firmware-Upgrade fehlgeschlagen, bitte erneut versuchen
      1004 Firmware-Upgrade erfolgreich, probieren Sie es aus!
      1005 Neue Firmware-Version verfügbar, aktualisieren Sie jetzt!
      1006 Batterie ist schwach, schaltet sich bald ab, bitte laden Sie rechtzeitig
      1007 Hauptgerät wird bald abgeschaltet, bitte bringen Sie es rechtzeitig zur Station zurück
      1008 Reinigung abgeschlossen, zurück in die Station
      1009 Bitte reinigen Sie den Schmutzwassertank rechtzeitig, um Geruch zu vermeiden
      1010 Geplante Reinigung gestartet
      1011 Aufgabenkonflikt, geplante Reinigung kann nicht ausgeführt werden
      1012 Die geplante Reinigungsaufgabe kann nicht ausgeführt werden, da sie sich im Nicht-Stören-Zeitraum befindet.
      1013 Niedriger Batteriestand, die geplante Reinigung kann nicht ausgeführt werden
      1014 Ausgewählter Bereich nicht gefunden, Reinigung für diese Zeit beendet
      1015 Verloren, bitte bringen Sie den Roboter zurück in die Station

      Und die Wartungserinnerungen:

      Code Meldung
      2001 Bitte tauschen Sie die Seitenbürste regelmäßig aus
      2002 Bitte ersetzen Sie die Walzenbürste regelmäßig
      2003 Bitte ersetzen Sie regelmäßig das Filterelement des Staubbehälters
      2004 Bitte ersetzen Sie die Wischmopps regelmäßig
      2005 Bitte ersetzen Sie den Staubbeutel regelmäßig
      2006 Bitte reinigen Sie den Reinigungsschlitz des Mopps regelmäßig.
      2007 Bitte reinigen Sie den Filter des Schmutzwassertanks regelmäßig.
      2008 Bitte wischen Sie den Sensor der Haupteinheit regelmäßig ab.

      Für mich war das praktisch nützlich: Die 1009 stand bei mir nach jedem Wischlauf im Log, und ich hatte sie für irgendein Trocknungs-Flag gehalten. Ist sie nicht — der Schmutzwassertank will geleert werden.

      Die Lösungstexte schicke ich mir inzwischen mit ins Telegram. Eine Fehlernummer allein hilft niemandem, der vor dem Gerät steht und wissen will, was er anfassen soll.


      6. Was aus „Was nicht geht" inzwischen geht

      Im ersten Beitrag stand in Abschnitt 10 eine kurze Liste. Zwischenstand:

      Kartenansicht — weiterhin nein. Daran hat sich nichts geändert, die Kartendaten liegen in der Cloud. Das Plugin bringt hier übrigens auch nichts: Es enthält zwar die Grafiken der Oberfläche, aber die Karte selbst holt es sich zur Laufzeit.

      Zonenreinigung per Koordinaten — immer noch nicht ausprobiert.

      Nacharbeit nach einem Abbruch erzwingen — die Aktionen dafür (17.40 Mopp waschen, 17.41 Mopp trocknen, 17.42 Staub absaugen) habe ich weiterhin nicht getestet. Aber ich weiß inzwischen besser, wann die Station von selbst nacharbeitet, und das nimmt dem Punkt viel von seiner Dringlichkeit:

      Nach einem normal beendeten Reinigungslauf fährt sie die volle Kette — Staub absaugen, Mopp waschen, Mopp trocknen. Nach einem abgebrochenen Lauf ging sie bei mir direkt ins Trocknen, ohne Absaugen und Waschen. Ich vermute, sie überspringt schlicht, was nichts bringt: Der Lauf war eine Minute lang und der Mopp frisch gewaschen. Sauber bewiesen ist das aber nicht — ich habe bei dem Test zwei Dinge auf einmal verändert (kurzer Lauf und Abbruch), und damit lässt sich nicht sagen, welches davon den Ausschlag gab. Beobachte ich weiter; das Journal schreibt es ja jetzt mit.

      Neu dazugekommen ist ein Punkt, der oben noch nicht stand: Pause und Fortsetzen. Ich hatte befürchtet, dass das Fortsetzen mit demselben 2.1 ebenfalls die Karte frisst. Tut es nicht — und der Grund ist ganz logisch: Bei einer Pause läuft der Auftrag ja noch, mitsamt Raumliste. 2.1 hängt sich daran an. Nur aus dem Leerlauf heraus, wo es nichts zum Anhängen gibt, fängt es von vorne an — und das ist der Kartenfresser aus Abschnitt 1.


      7. Das überarbeitete Skript

      Steht komplett unten in diesem Beitrag. Gegenüber der ersten Fassung:

      • Räume starten über 17.1 statt 2.1 — der Kartenfehler von oben
      • Fehler-, Stations- und Meldungstexte gefüllt, mit Lösungshinweis im Telegram
      • Ereignis-Journal mit Quelle je Befehl
      • Aufträge werden sauber beendet, bevor ein neuer startet. Vorher verpuffte jeder zweite
        Raumbefehl kommentarlos — man sagt „Bad saugen", während er noch im Flur unterwegs war, Alexa sagt OK, und nichts passiert. Das lag daran, dass der alte Auftrag nie richtig beendet wurde.
      • Die Raumliste des laufenden Auftrags wird mitgelesen. Daran sieht man, ob ein Auftrag seinen
        Raumbezug noch hat — nützlich, wenn man wissen will, ob der Sauger nach einer Störung noch weiß, was er eigentlich tun wollte.
      • Vollständige Statusliste. In der ersten Fassung fehlten zehn Statuswerte, darunter
        ausgerechnet der für „legt gerade eine neue Karte an". Das Skript meldete beim Kartenunfall nur „unbekannt (14)" — hätte es den Text gekannt, wäre mir das Wochen früher aufgefallen.

      Wie gehabt: IP, Token und Device-ID oben im Skript eintragen, Raum-IDs eintragen, fertig.

      t5-saugroboter.js — das komplette Skript

      /*
       * ============================================================================
       *  JONR T5 Pro (xtl.vacuum.2407a) lokal steuern -- ioBroker javascript-Adapter
       *  Fassung 2, September 2026
       * ============================================================================
       *  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.
       *
       *  --- Was sich gegenueber der ersten Fassung geaendert hat ---------------------
       *  * Raeume starten jetzt ueber 17.1 statt 2.1. Der generische Startbefehl legt
       *    eine NEUE KARTE an -- damit waren nach jedem Alexa-Start alle Raum-IDs
       *    hinueber. Die Raumliste muss ein Array-STRING sein: "[3]", auch leer "[]".
       *  * Fehlertexte, Stationsfehler und Meldungen sind gefuellt -- samt dem Text,
       *    was der Nutzer tun soll. Der geht bei Stoerungen mit ins Telegram.
       *  * Ereignis-Journal: schreibt jede Zustandsaenderung in eine Monatsdatei,
       *    mit der QUELLE jedes Befehls (Alexa / App / Oberflaeche / Skript).
       *  * Ein Auftrag wird sauber beendet, bevor ein neuer startet (ensureIdle).
       *    Vorher verpuffte jeder zweite Raumbefehl kommentarlos.
       *  * Die Raumliste des laufenden Auftrags (17.34) wird mitgelesen -- daran
       *    sieht man, ob ein Auftrag seinen Raumbezug noch hat.
       *
       *  --- 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.
       *  7. Fortsetzen NACH einer Pause ist mit 2.1 dagegen in Ordnung -- da haengt
       *     ein Auftrag, an den es sich anhaengen kann. Nur aus dem Leerlauf heraus
       *     ist 2.1 der Kartenfresser.
       * ============================================================================
       */
      
      'use strict';
      
      const dgram  = require('dgram');
      const crypto = require('crypto');
      const fs     = require('fs');
      
      // ---------------------------------------------------------------- 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.T5';
      
      /* Ereignis-Journal. Bewusst NICHT im ioBroker-Log: das rotiert nach 7 Tagen
       * (maxDays: 7) und genau daran ist die Aufarbeitung des Vorfalls vom
       * 02.09.2026 gescheitert -- die Fehlercodes waren weg, bevor jemand danach
       * gesucht hat. Monatsdateien, keine Rotation, Klartext. */
      const JOURNAL_DIR = '/opt/iobroker/log';
      
      /* Instanz des history-Adapters (am 03.09.2026 nachinstalliert). Die States,
       * die unten mit historisch=true angelegt werden, bekommen ihn als custom
       * eingetragen -- damit haengt die Aufzeichnung am Skript und nicht an einer
       * von Hand geklickten Adapterkonfiguration, die keiner mehr nachvollzieht. */
      const HISTORY = 'history.0';
      
      const POLL_IDLE   = 60000;   // ms — Sauger ruht
      const POLL_ACTIVE = 15000;   // ms — Sauger arbeitet
      
      /* Raum-IDs der Karte "Zu Hause" (Stand 2026-08-24).
       * ACHTUNG: Gelten nur für diese Karte. Nach Neukartieren, Teilen oder
       * Zusammenlegen von Räumen werden IDs neu vergeben -> Tabelle neu erarbeiten
       * (Kochrezept steht in der Memory). Die App zeigt jeweils ID minus 2 an. */
      const ROOMS = {
          Badezimmer:    { id:  3, alexa: 'T5 Badezimmer' },
          Schlafzimmer:  { id:  4, alexa: 'T5 Schlafzimmer' },
          Arbeitszimmer: { id:  5, alexa: 'T5 Arbeitszimmer' },
          Kinderzimmer:  { id:  6, alexa: 'T5 Kinderzimmer' },
          Abstellraum:   { id:  7, alexa: 'T5 Abstellraum' },
          Wohnzimmer:    { id:  8, alexa: 'T5 Wohnzimmer' },
          Kueche:        { id:  9, alexa: 'T5 Küche' },
          Flur:          { id: 11, alexa: 'T5 Flur' },
      };
      
      // 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) — VOLLSTÄNDIG laut MIoT-Spec.
       * Bis 30.08.2026 fehlten hier 3, 11, 14, 15, 17, 19, 20, 21, 22 und 23. Genau
       * die drei wichtigen davon zeigten sich am 30.08. beim Kartierungs-Vorfall:
       * der ioBroker meldete nur "unbekannt (14)", statt "legt neue Karte an". */
      const STATUS = {
          1:'Idle', 2:'Busy', 3:'Wartet', 4:'Wischen', 5:'Saugen+Wischen', 6:'Pausiert',
          7:'Saugen', 8:'Fehler', 9:'Lädt', 10:'Fährt zur Basis', 11:'Laden unterbrochen',
          12:'Fährt zum Waschen', 13:'Geladen', 14:'⚠️ Legt NEUE KARTE an', 15:'Update läuft',
          16:'Schläft', 17:'⚠️ Sucht seine Position', 18:'Station arbeitet',
          19:'⚠️ Kartierung pausiert', 20:'Laden abgebrochen', 21:'Waschen unterbrochen',
          22:'Gerät wird verbunden', 23:'Fährt zum Absaugen',
      };
      
      /* Auftrags-Status (Property 17.9, clean-type-status). Das ist die verlässliche
       * Anzeige, ob noch ein Auftrag offen ist — NICHT der grobe Status 2.2:
       * der zeigt "Lädt", während im Hintergrund ein Auftrag pausiert. */
      const CLEAN_TYPE = {
          0:'kein Auftrag', 1:'bereit', 2:'Komplettreinigung', 3:'Komplett pausiert',
          4:'Komplett fortgesetzt', 5:'Schnellkartierung', 6:'Kartierung pausiert',
          7:'Kartierung fortgesetzt', 8:'Raumreinigung', 9:'Raum pausiert',
          10:'Raum fortgesetzt', 11:'Zonenreinigung', 12:'Zone pausiert',
          13:'Zone fortgesetzt', 14:'Teppichreinigung', 15:'Teppich pausiert',
          16:'Teppich fortgesetzt', 17:'Punktreinigung', 18:'Punkt pausiert',
      };
      /* Nur bei diesen beiden Werten ist das Gerät wirklich frei für einen neuen
       * Auftrag. Alles andere lehnt es ab — das ist die Abfrage, die die App als
       * "Der Auftrag muss erst abgebrochen werden" anzeigt. */
      const CT_FREE = [0, 1];
      /* Auftrags-Status, bei denen wirklich gereinigt wird (nicht pausiert).
       * Daran haengt die Anzeige der Alexa-Schalter — siehe poll(). */
      const CT_RUNNING = [2, 4, 5, 7, 8, 10, 11, 13, 14, 16, 17];
      
      /* Fehlercodes (17.35 / 17.79), Loesungswege und Meldungen (17.36).
       * ---------------------------------------------------------------------------
       * HERKUNFT (04.09.2026): Aus dem Mi-Home-GERAETEPLUGIN des T5 gezogen, das
       * ueber home.miot-spec.com frei herunterladbar ist (Bundle
       * signed_10068_1027179_3_ANDROID). Die Texte stehen dort im React-Native-
       * Bundle als Tabellen [code, {title, solution}] bzw. [code, keyword], die
       * Klartexte in elf Sprachen -- Deutsch inbegriffen.
       *
       * Damit ist der Eintrag "die Tabellen muessen empirisch wachsen" ueberholt.
       * Die MIoT-Spec liefert tatsaechlich keine Wertelisten, aber die App muss die
       * Texte ja kennen -- und ihr Plugin ist frei herunterladbar.
       *
       * ⚠️ Beim Firmware-/Plugin-Update kann die Tabelle wachsen. Ein Code, der
       * hier fehlt, landet im Journal als UNBEKANNT -- dann neues Plugin ziehen. */
      
      // Geraetefehler (17.35)
      const ERROR = {
          0: 'kein Fehler',
          4001: 'Staubbehälter nicht installiert.',
          4003: 'Walzenbürste verheddert.',
          4004: 'Seitenbürste verheddert.',
          4005: 'Antriebsrad blockiert.',
          4006: 'Abwurfsensor hat Staub angesammelt.',
          4007: 'Lasersensor verstopft.',
          4008: 'Radar des Lasersensors blockiert.',
          4009: 'Antriebsrad rutscht durch.',
          4011: 'Abdeckung des Lasersensors klemmt.',
          4012: 'Roboter steckt fest.',
          4013: 'Der Kollisionssensor ist blockiert.',
          4014: 'Der Roboter hängt.',
          4016: 'Roboter gekippt.',
          4017: 'Start in Sperrzone nicht möglich.',
          4018: 'Start auf Teppich nicht möglich.',
          4020: 'Der Wischmopphalter hat sich verheddert.',
          4021: 'Wischmopphalter nicht installiert.',
      };
      
      // Stationsfehler (17.79) -- eigener Wertebereich ab 4500
      const STATION_ERROR = {
          0: 'kein Fehler',
          4501: 'Der Staubbeutel ist nicht installiert.',
          4502: 'Nicht genügend Wasser oder kein Schmutzwassertank installiert.',
          4503: 'Das Schmutzwasser ist voll oder ein Schmutzwassertank ist nicht installiert.',
          4506: 'Der Reinigungsschlitz des Mopps ist voll oder nicht installiert.',
          4507: 'Der Reinigungsschlitz des Mopps ist voll oder nicht installiert.',
          4901: 'Wasser kann nicht automatisch nachgefüllt werden.',
          4902: 'Sie können das Wasser nicht automatisch ablassen.',
          4903: 'Unzureichende Reinigungslösung.',
      };
      
      /* Was der Nutzer tun soll. Geht bei Stoerungen mit ins Telegram -- eine
       * Fehlernummer allein hilft niemandem, der vor dem Geraet steht. */
      const FIX = {
          4001: 'Öffnen Sie die Roboterabdeckung und setzen Sie den Staubbehälter ein.',
          4003: 'Nehmen Sie die Abdeckung der Walzenbürste ab, nehmen Sie die Walzenbürste heraus und entfernen Sie verhedderte Haare und andere Verunreinigungen.',
          4004: 'Nehmen Sie die Seitenbürste ab und entfernen Sie die verhedderten Haare und andere Verschmutzungen.',
          4005: 'Drehen Sie das Antriebsrad und befreien Sie es von verhedderten Haaren oder anderen Verunreinigungen.',
          4006: 'Wischen Sie den Tropfensensor mit einem trockenen Tuch ab und stellen Sie den Roboter in eine sichere Position, um ihn neu zu starten.',
          4007: 'Entfernen Sie die Gegenstände, die den Lasersensor behindern.',
          4008: 'Entfernen Sie den Schmutz, der den rotierenden Radar des Lasersensors blockiert, und klopfen Sie vorsichtig auf die Sensorabdeckung.',
          4009: 'Wischen Sie das Antriebsrad ab und bringen Sie den Roboter in eine andere Position, um ihn neu zu starten.',
          4011: 'Bitte entfernen Sie Objekte, die die Abdeckung des Lasersensors blockieren, oder bringen Sie den Roboter in eine andere Position, um ihn neu zu starten.',
          4012: 'Bitte entfernen Sie die Hindernisse um den Roboter herum, oder begeben Sie sich in einen anderen Bereich, um neu zu starten.',
          4013: 'Bitte klopfen Sie leicht auf die vordere Stoßstange des Roboters und vergewissern Sie sich, dass die Stoßstange zurückfedert.',
          4014: 'Starten Sie den Roboter neu, nachdem Sie ihn wieder auf den Boden gestellt haben.',
          4016: 'Starten Sie den Roboter neu, nachdem Sie ihn auf eine ebene Fläche gestellt haben.',
          4017: 'Starten Sie den Roboter neu, nachdem Sie ihn aus dem Sperrgebiet entfernt haben, oder löschen Sie das aktuelle Sperrgebiet.',
          4018: 'Starten Sie den Roboter neu, nachdem Sie ihn aus dem Teppichbereich entfernt haben, oder ändern Sie die aktuelle Teppichreinigungsstrategie.',
          4020: 'Nehmen Sie den Wischmopphalter ab und entfernen Sie verhedderte Haare und andere Verunreinigungen.',
          4021: 'Bringen Sie beide Mop-Pad-Halterungen richtig an.',
          4501: 'Setzen Sie den Staubbeutel richtig ein.',
          4502: 'Nehmen Sie den Frischwassertank heraus, füllen Sie eine ausreichende Menge Wasser ein, setzen Sie den Frischwassertank wieder ein und vergewissern Sie sich, dass er ordnungsgemäß installiert ist.',
          4503: 'Nehmen Sie den Schmutzwassertank heraus, leeren Sie das gesamte Schmutzwasser, setzen Sie den Schmutzwassertank wieder ein und vergewissern Sie sich, dass er ordnungsgemäß installiert ist.',
          4506: '1. Entfernen Sie den Filter aus dem Mopp-Reinigungsschlitz, reinigen Sie die angesammelten Haare und Flecken auf dem Filter und installieren Sie ihn anschließend wieder; 2. Überprüfen Sie, ob Objekte den Ansauganschluss…',
          4507: '1. Entnehmen Sie den Filter aus dem Mop-Reinigungsschlitz, reinigen Sie alle Haare und Flecken, die sich auf dem Filter angesammelt haben, und installieren Sie ihn wieder; 2. Überprüfen Sie, ob Objekte den Ansauganschlu…',
          4901: '1. Prüfen Sie, ob der Wasserdruck im Haushalt normal ist; 2. Überprüfen Sie, ob die Wasserzuleitung richtig installiert ist.',
          4902: '1. Prüfen Sie, ob das Abflussrohr richtig installiert ist; 2. Öffnen Sie die Abdeckung des Schmutzwassertanks, nehmen Sie den Filter heraus, reinigen Sie ihn und setzen Sie ihn wieder ein.',
          4903: 'Bitte geben Sie eine ausreichende Menge Reinigungslösung in das Modul für die automatische Wasserzufuhr und den Abfluss.',
      };
      
      /* Meldungen aus 17.36 (message-notification). KEINE Fehler: Der Kanal traegt
       * auch reine Betriebsmeldungen (1008 "Reinigung abgeschlossen") und
       * Wartungserinnerungen (2001-2008). Loest deshalb bewusst keinen Alarm aus. */
      const MSG = {
          1001: 'Ladevorgang abgeschlossen, Wiederaufnahme der Reinigung ab dem Unterbrechungspunkt',
          1002: 'Niedriger Akkustand, zurück zum Laden',
          1003: 'Firmware-Upgrade fehlgeschlagen, bitte erneut versuchen',
          1004: 'Firmware-Upgrade erfolgreich, probieren Sie es aus!',
          1005: 'Neue Firmware-Version verfügbar, aktualisieren Sie jetzt!',
          1006: 'Batterie ist schwach, schaltet sich bald ab, bitte laden Sie rechtzeitig',
          1007: 'Hauptgerät wird bald abgeschaltet, bitte bringen Sie es rechtzeitig zur Station zurück',
          1008: 'Reinigung abgeschlossen, zurück in die Station',
          1009: 'Bitte reinigen Sie den Schmutzwassertank rechtzeitig, um Geruch zu vermeiden',
          1010: 'Geplante Reinigung gestartet',
          1011: 'Aufgabenkonflikt, geplante Reinigung kann nicht ausgeführt werden',
          1012: 'Die geplante Reinigungsaufgabe kann nicht ausgeführt werden, da sie sich im Nicht-Stören-Zeitraum befindet.',
          1013: 'Niedriger Batteriestand, die geplante Reinigung kann nicht ausgeführt werden',
          1014: 'Ausgewählter Bereich nicht gefunden, Reinigung für diese Zeit beendet',
          1015: 'Verloren, bitte bringen Sie den Roboter zurück in die Station',
          2001: 'Bitte tauschen Sie die Seitenbürste regelmäßig aus',
          2002: 'Bitte ersetzen Sie die Walzenbürste regelmäßig',
          2003: 'Bitte ersetzen Sie regelmäßig das Filterelement des Staubbehälters',
          2004: 'Bitte ersetzen Sie die Wischmopps regelmäßig',
          2005: 'Bitte ersetzen Sie den Staubbeutel regelmäßig',
          2006: 'Bitte reinigen Sie den Reinigungsschlitz des Mopps regelmäßig.',
          2007: 'Bitte reinigen Sie den Filter des Schmutzwassertanks regelmäßig.',
          2008: 'Bitte wischen Sie den Sensor der Haupteinheit regelmäßig ab.',
      };
      
      /* Die vier Bauteil-Zustaende 17.51 bis 17.54. Die Spec liefert zwar die
       * Wertelisten (Normal / Uninstalled / Unusable), aber KEINE Beschreibung,
       * welches Bauteil jeweils gemeint ist. Deshalb heissen sie hier neutral.
       * Klaeren laesst sich das durch Ausprobieren: Mopp abnehmen, Staubbox
       * herausziehen -- und im Journal nachsehen, welcher Buchstabe springt. */
      const PARTS = { '17.51': 'Bauteil A', '17.52': 'Bauteil B',
                      '17.53': 'Bauteil C', '17.54': 'Bauteil D' };
      const PART_STATE = { 0: 'normal', 1: 'nicht eingesetzt', 2: 'unbrauchbar', 3: 'Zustand 3' };
      
      /* Wer hat geschaltet? ioBroker liefert die Quelle bei jedem State-Change mit.
       * Das ist die einzige Stelle, an der wir Alexa von App und Oberflaeche
       * unterscheiden koennen -- Befehle aus der Hersteller-App gehen ueber die
       * Cloud direkt ans Geraet und tauchen hier NIE auf (siehe Kopfkommentar). */
      const QUELLE = {
          'system.adapter.iot.0':        'Alexa (iot)',
          'system.adapter.javascript.0': 'Skript',
          'system.adapter.admin.0':      'Admin-Oberflaeche',
          'system.adapter.web.0':        'VIS/Web',
          'system.adapter.alexa2.0':     'alexa2',
      };
      const quelleText = f => QUELLE[f] || (f || 'unbekannt');
      
      // Sauger arbeitet an einem Reinigungsauftrag -> Schalter "T5" steht auf true
      const RUNNING = [2, 4, 5, 7];
      const PAUSED  = [6];
      /* Sauger ist in Bewegung -> schnelleres Polling. Weiter gefasst als RUNNING:
       * auch Heimfahrt, Kartierung und Positionssuche sollen zügig nachgeführt werden,
       * ohne die Alexa-Schalter anzufassen.
       *
       * ⚠️ Status 18 (Station arbeitet) gehoert BEWUSST NICHT hierher, obwohl er es
       * bis zum 30.08. tat. Die Stationsarbeit ist die mit Abstand laengste Phase
       * (Trocknung bis zu zwei Stunden), und dabei passiert minutenlang nichts. Alle
       * 15 s zu pollen bringt keine Erkenntnis, belegt aber staendig den Funk — und
       * genau dort ist am 30.08. ein Alexa-Raumbefehl verlorengegangen. */
      const ACTIVE  = [2, 3, 4, 5, 6, 7, 10, 12, 14, 17, 19, 23];
      
      // ---------------------------------------------------------------- Journal
      /* Schreibt eine Klartextzeile in die Monatsdatei. Beantwortet die drei Fragen,
       * die das ioBroker-Log NICHT beantwortet:
       *   BEFEHL  -- wer hat geschaltet (Alexa, Oberflaeche, Skript)?
       *   FEHLER  -- welcher Code lag an, und wie lange?
       *   STATUS  -- was hat das Geraet daraus gemacht?
       *
       * Das Journal darf die Steuerung unter keinen Umstaenden aufhalten: schlaegt
       * das Schreiben fehl (Rechte, Platte voll), wird das geschluckt. Ein stummes
       * Journal ist aergerlich, ein blockierter Saugbefehl waere schlimmer. */
      function journal(kat, text) {
          try {
              const d = new Date();
              const p = n => String(n).padStart(2, '0');
              const monat = d.getFullYear() + '-' + p(d.getMonth() + 1);
              const zeit  = monat + '-' + p(d.getDate()) + ' ' +
                            p(d.getHours()) + ':' + p(d.getMinutes()) + ':' + p(d.getSeconds());
              fs.appendFileSync(JOURNAL_DIR + '/t5-journal-' + monat + '.log',
                                zeit + ' | ' + kat.padEnd(7) + ' | ' + text + '\n');
          } catch (e) {
              /* bewusst stumm -- siehe Kommentar oben */
          }
      }
      
      /* Merker fuer die Aenderungserkennung. Ohne den stuende bei 60-Sekunden-Takt
       * jede Minute derselbe Status im Journal. */
      const last = {};
      /** true, wenn sich der Wert seit dem letzten Poll geaendert hat. */
      function geaendert(k, v) {
          const neu = !(k in last) || last[k] !== v;
          last[k] = v;
          return neu;
      }
      
      /* Push nur bei echten Stoerungen, nicht bei jedem Statuswechsel -- sonst ist
       * der Kanal nach einem einzigen Reinigungslauf zugemuellt. Bewusst NICHT
       * gemeldet wird Status 17 ("sucht seine Position"): der tritt auch im
       * Normalbetrieb nach jedem Start auf. Er steht im Journal, das reicht. */
      async function meldung(text) {
          try {
              await sendToAsync('telegram.0', 'send', { text: '🤖 T5: ' + text });
          } catch (e) {
              log('Telegram nicht erreichbar: ' + e.message, 'warn');
          }
      }
      
      // ---------------------------------------------------------------- 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, historisch) {
          const full = BASE + '.' + id;
          const c = Object.assign({ read: true, write: false, role: 'state' }, common);
          await createStateAsync(full, initial === undefined ? null : initial, false, c);
          /* Aufzeichnung im history-Adapter. Bewusst NUR fuer die States, aus denen
           * sich ein Vorfall rekonstruieren laesst -- nicht fuer lastUpdate (aendert
           * sich jede Minute) und nicht fuer die Modus-States. changesOnly, damit
           * Ruhezeiten nichts kosten. */
          if (historisch) {
              try {
                  await extendObjectAsync(full, { common: { custom: { [HISTORY]: {
                      enabled: true, changesOnly: true, debounce: 0,
                      retention: 31536000,          // ein Jahr in Sekunden
                      maxLength: 0, changesRelogInterval: 0, aliasId: '',
                  } } } });
              } catch (e) {
                  log('History fuer ' + id + ' nicht aktivierbar: ' + e.message, 'warn');
              }
          }
          /* 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' }, undefined, true);
          await def('info.statusText',  { name: 'Status',             type: 'string',  role: 'text' },  undefined, true);
          await def('info.battery',     { name: 'Akku',               type: 'number',  role: 'value.battery', unit: '%' }, undefined, true);
          await def('info.error',       { name: 'Fehlercode',         type: 'number',  role: 'value' }, undefined, true);
          await def('info.errorText',   { name: 'Fehler',             type: 'string',  role: 'text' },  undefined, true);
          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.cleanType',     { name: 'Auftrags-Status (Code)', type: 'number',  role: 'value' }, undefined, true);
          await def('info.cleanTypeText', { name: 'Auftrags-Status',        type: 'string',  role: 'text' },  undefined, true);
          await def('info.saveMap',       { name: 'Karte speichern aktiv',  type: 'boolean', role: 'indicator' });
          await def('info.online',      { name: 'erreichbar',         type: 'boolean', role: 'indicator.reachable' }, undefined, true);
          await def('info.lastUpdate',  { name: 'letzte Abfrage',     type: 'string',  role: 'text' });
      
          /* Neu am 03.09.2026: Die Station meldet ihre Stoerungen in einem EIGENEN
           * Fehlercode (17.79, "station-error"). Der wurde bis dahin gar nicht
           * abgefragt -- ein blinder Fleck genau dort, wo der Sauger die meiste Zeit
           * verbringt (Waschen, Trocknen, Absaugen). */
          await def('info.stationError',     { name: 'Stationsfehler (Code)', type: 'number', role: 'value' },  undefined, true);
          await def('info.stationErrorText', { name: 'Stationsfehler',        type: 'string', role: 'text' },   undefined, true);
      
          /* 17.49 liefert die Stationsphase als KLARTEXT-String ("ChargeAsleep",
           * "HotDry", ...). Das ist die einzige Property des Geräts, die von sich
           * aus lesbar ist statt kodiert — und sie sagt genau das, was der grobe
           * Status 18 ("Station arbeitet") verschweigt: WAS die Station tut. */
          await def('info.stationPhase', { name: 'Stationsphase', type: 'string', role: 'text' }, undefined, true);
      
          /* 17.36 ist laut MIoT-Spec "Message Notification" (uint16, Read/Notify) —
           * NICHT der zweite Fehlerkanal, als der er hier anfangs mitgefuehrt wurde.
           * Am 04.09.2026 in der Spec nachgelesen; der Name stand die ganze Zeit da.
           * Er fuehrt Codes im 1000er-Bereich im ganz normalen Betrieb.
           *
           * Beobachtet am 04.09.2026:
           *   1009  waehrend HotDry, wenn ein Moppwasch-Zyklus vorausging (2x).
           *         Bleibt ueber das Ende von HotDry hinaus stehen und faellt erst
           *         beim naechsten Auftrag auf 0.
           *   1008  beim Auftragsende auf der Rueckfahrt — nur 15 Sekunden lang.
           *
           * Der Spec-Name bestaetigt, was die kurze Standzeit von 1008 nahelegte: ein
           * EREIGNIS-Kanal, kein Zustandsmerker. 17.36 ist damit genau das, was ueber
           * 17.63 push-code nicht zu holen war (dort liefert das Geraet keinen Wert)
           * — nur eben lesbar. Die 17.63-Sackgasse ist damit umgangen.
           *
           * Einschraenkung (04.09.): Die App zeigt zu 1008 nichts an. Der
           * Kanal traegt also auch Meldungen, die dem Nutzer nie gezeigt werden.
           *
           * Konsequenz fuers Journal: Bekannte Codes werden im Klartext ausgewiesen,
           * UNBEKANNTE weiterhin markiert — die Tabelle waechst empirisch, genau wie
           * bei den Fehlercodes. Alarm loest 17.36 bewusst nicht aus.
           *
           * ⚠️ Grenze: Bei 15 Sekunden Standzeit kann ein Poll den Wert verpassen.
           * Das Journal zeigt also nicht garantiert jeden Code. Schnelleres Polling
           * waere der Preis — nicht wert, solange die Bedeutung unklar ist. */
          await def('info.error2', { name: 'Meldungscode 17.36 (message-notification)', type: 'number', role: 'value' }, undefined, true);
      
          /* 17.34 traegt die Raumliste des laufenden Auftrags (z. B. "[4]"). Bis zum
           * 04.09.2026 wurde sie nur GESCHRIEBEN, nie gelesen — dabei ist sie der
           * direkte Indikator dafuer, ob ein Auftrag seinen Raumbezug behaelt.
           *
           * Warum das zaehlt: 2.1 START_SWEEP ist kontextabhaengig (siehe Pause-
           * Handler). Aus einem lebenden Auftrag heraus setzt es sauber fort, aus dem
           * Leerlauf legt es eine NEUE KARTE an. Die Power-Taste am Geraet duerfte
           * dasselbe ausloesen. Offene Frage: Wenn der Sauger sich festfaehrt, man ihn
           * befreit und die Taste drueckt — lebt der Raumauftrag dann noch?
           * Mit dieser Aufzeichnung beantwortet der naechste ECHTE Vorfall das von
           * selbst, ohne dass man ihn dafuer absichtlich stecken lassen muesste. */
          await def('info.roomList', { name: 'Raumliste des Auftrags (17.34)', type: 'string', role: 'text' }, undefined, true);
      
          /* Bauteil-Zustaende. Ob ein Mopp fehlt oder die Staubbox nicht sitzt, ist
           * eine der wahrscheinlichsten Ursachen fuer App-Meldungen -- und war
           * bisher unsichtbar. Welcher Buchstabe welches Teil ist, klaert das
           * Journal (siehe PARTS). */
          for (const t of Object.values(PARTS)) {
              await def('info.teile.' + t.replace(/ /g, '_'),
                        { name: t + ' (Zustand)', type: 'string', role: 'text' }, undefined, true);
          }
      
          // --- Alexa-Schalter. Echte Schalter (read+write), nicht Buttons:
          //     dadurch wirkt auch "aus", und die Routine braucht kein Zuruecksetzen.
          await def('T5', {
              name: 'T5 (ganzes Haus, saugen und wischen)', type: 'boolean', role: 'switch',
              write: true, smartName: { de: 'T5', smartType: 'SWITCH' },
          }, false, true);
          await def('T5_zum_Wischen', {
              name: 'T5 zum Wischen (nur wischen)', type: 'boolean', role: 'switch',
              write: true, smartName: { de: 'T5 zum Wischen', smartType: 'SWITCH' },
          }, false, true);
          await def('T5_Pause', {
              name: 'T5 Pause', type: 'boolean', role: 'switch',
              write: true, smartName: { de: 'T5 Pause', smartType: 'SWITCH' },
          }, false, true);
      
          // --- 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, true);
          }
      }
      
      // ---------------------------------------------------------------- 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 neuen Auftrag: das Gerät freiräumen.
       * ---------------------------------------------------------------------------
       * Das Gerät nimmt keinen zweiten Auftrag an, solange der erste läuft oder
       * pausiert — es antwortet dann zwar mit code 0, tut aber nichts. Im Alltag
       * heisst das: "T5 Bad" gesagt, Arbeitszimmer gemeint, sofort korrigiert — und
       * der zweite Befehl verpufft. Die App fragt an dieser Stelle "Der Auftrag muss
       * erst abgebrochen werden"; genau diesen Abbruch holen wir hier selbst nach.
       *
       * Geprüft wird 17.9 (clean-type-status), nicht der grobe Status 2.2 — der zeigt
       * "Lädt", während im Hintergrund noch ein Auftrag pausiert.
       *
       * Abgebrochen wird mit 17.3 stop-clean (NICHT 2.2 — das hält nur die Fahrt an
       * und lässt den Auftrag pausiert stehen, siehe stopAndHome()).
       *
       * Antwortet das Gerät gar nicht, wird NICHT blind abgebrochen: dann lieber den
       * Startbefehl versuchen, als einen laufenden Auftrag auf Verdacht killen. */
      async function ensureIdle(maxTries = 3) {
          for (let i = 0; i < maxTries; i++) {
              const p  = await getProps([{ siid: 17, piid: 9 }]);
              const ct = p['17.9'];
      
              if (ct === undefined) {                    // keine Antwort -> nicht raten
                  log('T5: Auftrags-Status nicht lesbar, starte trotzdem', 'warn');
                  return false;
              }
              if (CT_FREE.includes(ct)) return true;     // frei
      
              log('T5: "' + (CLEAN_TYPE[ct] || ct) + '" ist noch offen -> wird abgebrochen', 'info');
              await action(ACT.STOP_CLEAN);
              await pause(2500);                         // dem Gerät Zeit zum Aufräumen
          }
          log('T5: Auftrag liess sich nicht abbrechen, starte trotzdem', 'warn');
          return false;
      }
      
      /** 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 ueber 17.1 start-clean mit piid 8 = FullClean und piid 34 = "[]".
       * ---------------------------------------------------------------------------
       * 🔴 HIER LAG DER KARTEN-FEHLER (gefunden 30.08.2026).
       *
       * Bis zum 30.08. lief das ueber die parameterlose Action 2.1 (Start Sweep) —
       * und die startet einen Lauf OHNE Kartenbezug: Das Geraet faengt an zu fahren
       * und zeichnet dabei eine NEUE Karte. Alle Raum-IDs werden damit hinfaellig.
       * Jeder Start ueber Alexa hat so die Karte zerlegt, waehrend ein Start aus der
       * App sauber lief.
       *
       * Der Grund fuer den Umweg war ein Trugschluss vom 24.08.: 17.1 wurde damals
       * mit einem LEEREN STRING an piid 34 versucht und mit code -9999 abgelehnt.
       * Daraus wurde geschlossen, 17.1 tauge nicht fuers ganze Haus. Tatsaechlich
       * will das Geraet an piid 34 immer einen ARRAY-STRING, auch wenn er leer ist:
       * "[]" statt "". Genau derselbe Fallstrick wie bei der Raumreinigung, wo "6"
       * scheiterte und erst "[6]" funktionierte.
       *
       * Am 30.08. live verifiziert: 17.1 mit "[]" -> code 0, faehrt drei Sekunden
       * spaeter los, Auftrag "Komplettreinigung", KEINE Neukartierung.
       * Genau das schickt auch die App. */
      const ROOMS_ALL = '[]';                            // leer = ganzes Haus, aber als Array-String!
      
      async function startWholeHome(mode) {
          await ensureIdle();                            // erst freiräumen, dann starten
          await applyDefaults(mode);
          await action(ACT.START_CLEAN,
              [{ piid: 8, value: CLEAN_FULL }, { piid: 34, value: ROOMS_ALL }]);
      }
      
      /* Raumreinigung. piid 34 traegt die Raumliste als Array-String ("[6]") --
       * exakt das Format, das sich das Geraet bei einem Zeitplan-Lauf selbst setzt
       * (am 24.08. in 17.34 abgelesen: "[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 ensureIdle();                            // erst freiräumen, dann starten
          await pause(1500);
          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, alle am 24.08. beobachtet:
       *   - 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);
      }
      
      // ---------------------------------------------------------------- Chronik
      /* Schreibt jede ZUSTANDSAENDERUNG des Geraets ins Journal.
       * ---------------------------------------------------------------------------
       * Das ist der Teil, der auch Befehle sichtbar macht, die gar nicht ueber den
       * ioBroker laufen: Tasten am Geraet und alles aus der Hersteller-App gehen
       * ueber die Cloud direkt an den Sauger, wir sehen sie nie als Befehl -- aber
       * ihre WIRKUNG steht hier. Genau das hat am 02.09.2026 gefehlt.
       *
       * Telegram gibt es nur fuer echte Stoerungen (siehe meldung()). */
      async function chronik(p, st) {
          if (geaendert('status', st)) {
              journal('STATUS', (STATUS[st] || 'unbekannt') + ' (Code ' + st + ')');
              if (st === 8) await meldung('Fehlerzustand — ' + (await textVon('errorText')) +
                                          fixText(letzterFehler));
          }
      
          if (p['17.9'] !== undefined && geaendert('cleanType', p['17.9'])) {
              journal('AUFTRAG', (CLEAN_TYPE[p['17.9']] || 'unbekannt') + ' (Code ' + p['17.9'] + ')' +
                      (raeumeText(p['17.34']) ? '  — Raeume: ' + raeumeText(p['17.34']) : ''));
          }
      
          /* Die Raumliste bekommt eine EIGENE Aenderungserkennung, nicht nur den
           * Anhang oben: Sie kann sich leeren, waehrend 17.9 unveraendert stehen
           * bleibt — und genau dieser Fall ist die offene Frage (Auftrag verliert
           * seinen Raumbezug, ohne dass der Auftragsstatus es verraet). */
          if (p['17.34'] !== undefined && geaendert('roomList', String(p['17.34']))) {
              journal('AUFTRAG', 'Raumliste: ' + (raeumeText(p['17.34']) || 'LEER'));
          }
      
          if (p['17.35'] !== undefined && geaendert('error', p['17.35'])) {
              const ec = p['17.35'];
              const txt = ERROR[ec] !== undefined ? ERROR[ec] : 'UNBEKANNT';
              letzterFehler = p['17.35'];
              journal('FEHLER', 'Geraet: ' + txt + ' (Code ' + ec + ')' +
                      (ERROR[ec] === undefined ? '  <-- Bedeutung nachtragen!' : ''));
              if (ec !== 0) await meldung('Fehler ' + ec + ' (' + txt + ') — Status: ' + (STATUS[st] || st) + fixText(ec));
          }
      
          if (p['17.79'] !== undefined && geaendert('stationError', p['17.79'])) {
              const ec = p['17.79'];
              const txt = STATION_ERROR[ec] !== undefined ? STATION_ERROR[ec] : 'UNBEKANNT';
              journal('FEHLER', 'Station: ' + txt + ' (Code ' + ec + ')' +
                      (STATION_ERROR[ec] === undefined ? '  <-- Bedeutung nachtragen!' : ''));
              if (ec !== 0) await meldung('Stationsfehler ' + ec + ' (' + txt + ')' + fixText(ec));
          }
      
          if (p['17.49'] !== undefined && geaendert('stationPhase', p['17.49'])) {
              journal('STATION', String(p['17.49']));
          }
      
          if (p['17.36'] !== undefined && geaendert('error2', p['17.36'])) {
              journal('CODE', 'Slot 17.36: ' + p['17.36'] +
                      (p['17.36'] === 0        ? ' (aus)'
                       : MSG[p['17.36']]    ? ' — ' + MSG[p['17.36']]
                       : '  <-- UNBEKANNT, Bedeutung nachtragen!'));
          }
      
          for (const [k, t] of Object.entries(PARTS)) {
              if (p[k] === undefined || !geaendert(k, p[k])) continue;
              journal('TEIL', t + ': ' + (PART_STATE[p[k]] !== undefined ? PART_STATE[p[k]] : 'unbekannt') +
                      ' (Code ' + p[k] + ')');
          }
      
          if (p['16.1'] !== undefined && geaendert('akku10', Math.floor(p['16.1'] / 10))) {
              journal('AKKU', p['16.1'] + ' %');
          }
      }
      
      /** Kleiner Helfer fuer die Telegram-Texte. */
      async function textVon(id) {
          try { const v = await getStateAsync(BASE + '.info.' + id); return v && v.val ? String(v.val) : '?'; }
          catch (e) { return '?'; }
      }
      
      // ---------------------------------------------------------------- Polling
      let pollNr = 0;                        // fuer den Langsamtakt der Bauteil-Abfrage
      
      async function poll() {
          if (busy || stopping) return;
          busy = true;
          try {
              /* Die Bauteil-Zustaende aendern sich fast nie -- sie jedes Mal
               * mitzufragen wuerde nur den Funk belegen (getProps macht daraus
               * einen weiteren Batch). Deshalb nur jeden zehnten Durchlauf.
               * Stand 04.09.: 12 Properties = 3 Batches; mit den vier Bauteilen
               * sind es 16 = 4 Batches. Der Aufschlag faellt also nur bei jedem
               * zehnten Poll an. */
              const mitTeilen = (pollNr++ % 10) === 0;
              const liste = [
                  { 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
                  { siid: 17, piid:  9 },   // clean-type-status: haengt ein Auftrag?
                  { siid: 17, piid: 34 },   // Raumliste des Auftrags — lebt der Raumbezug?
                  { siid: 17, piid: 39 },   // save-map: legt er Karten neu an?
                  { siid: 17, piid: 79 },   // station-error: Stoerung der Basisstation
                  { siid: 17, piid: 49 },   // Stationsphase im Klartext ("HotDry" u. a.)
                  { siid: 17, piid: 36 },   // message-notification (Spec!), KEIN Fehlercode
              ];
              if (mitTeilen) for (const k of Object.keys(PARTS)) {
                  const [si, pi] = k.split('.').map(Number);
                  liste.push({ siid: si, piid: pi });
              }
              const p = await getProps(liste);
      
              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);
                  await setStateAsync(BASE + '.info.errorText',
                      ERROR[p['17.35']] !== undefined ? ERROR[p['17.35']] : ('UNBEKANNT (' + p['17.35'] + ')'), true);
              }
              if (p['17.79'] !== undefined) {
                  await setStateAsync(BASE + '.info.stationError', p['17.79'], true);
                  await setStateAsync(BASE + '.info.stationErrorText',
                      STATION_ERROR[p['17.79']] !== undefined ? STATION_ERROR[p['17.79']] : ('UNBEKANNT (' + p['17.79'] + ')'), true);
              }
              if (p['17.49'] !== undefined) await setStateAsync(BASE + '.info.stationPhase', String(p['17.49']), true);
              if (p['17.36'] !== undefined) await setStateAsync(BASE + '.info.error2',        p['17.36'], true);
              if (p['17.34'] !== undefined) await setStateAsync(BASE + '.info.roomList', String(p['17.34']), true);
              for (const [k, t] of Object.entries(PARTS)) {
                  if (p[k] === undefined) continue;
                  await setStateAsync(BASE + '.info.teile.' + t.replace(/ /g, '_'),
                      PART_STATE[p[k]] !== undefined ? PART_STATE[p[k]] : ('unbekannt (' + p[k] + ')'), 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);
      
              /* Auftrags-Status sichtbar machen. Ohne den sieht man im ioBroker "Lädt",
               * während in Wahrheit noch ein Auftrag pausiert — genau das Rätsel vom
               * 24.08. (er fuhr später von selbst wieder los). */
              if (p['17.9'] !== undefined) {
                  await setStateAsync(BASE + '.info.cleanType',     p['17.9'], true);
                  await setStateAsync(BASE + '.info.cleanTypeText',
                      CLEAN_TYPE[p['17.9']] || ('unbekannt (' + p['17.9'] + ')'), true);
              }
              /* Frühwarnung: Steht save-map auf false, legt das Gerät bei jedem Start
               * eine NEUE Karte an und alle Raum-IDs im Skript sind hinfällig. */
              if (p['17.39'] !== undefined) {
                  await setStateAsync(BASE + '.info.saveMap', !!p['17.39'], true);
                  if (!p['17.39']) log('T5: ⚠️ "Karte speichern" ist AUS — Raum-IDs drohen ungültig zu werden!', 'warn');
              }
              await setStateAsync(BASE + '.info.lastUpdate', formatDate(new Date(), 'TT.MM.JJJJ SS:mm:ss'), true);
      
              await chronik(p, st);
      
              /* 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. */
              /* Der Schalter haengt am AUFTRAG, nicht nur am groben Status.
               * Sonst springt er auf "aus", waehrend die Station vor dem Losfahren die
               * Mopps waescht (Status 18) — obwohl der Auftrag laengst angenommen ist.
               * In der Alexa-App sah das aus, als waere der Befehl verpufft.
               * Bewusst additiv: der Schalter geht dadurch nur zusaetzlich AN,
               * nie zusaetzlich aus. */
              const auftragLaeuft = p['17.9'] !== undefined && CT_RUNNING.includes(p['17.9']);
              const running = RUNNING.includes(st) || auftragLaeuft;
              const paused  = PAUSED.includes(st);
              const mopOnly = p['17.10'] === MODE_MOP;
      
              await syncSwitch('T5',             running && !mopOnly);
              await syncSwitch('T5_zum_Wischen', running &&  mopOnly);
              await syncSwitch('T5_Pause',       paused);
              if (!running) for (const n of Object.keys(ROOMS)) await syncSwitch('raum.' + n, false);
      
              /* Polling-Tempo an ACTIVE, nicht an RUNNING: auch Heimfahrt, Kartierung
               * und Positionssuche sollen zügig nachgeführt werden. */
              reschedule(ACTIVE.includes(st) ? 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). */
      /* Was der Nutzer tun soll, an die Telegram-Meldung angehaengt. Eine Nummer
       * allein hilft niemandem, der vor dem Geraet steht — der Text kommt aus dem
       * Mi-Home-Plugin und ist derselbe, den die App anzeigt. */
      let letzterFehler = 0;
      function fixText(code) {
          return FIX[code] ? '\n\n\u2192 ' + FIX[code] : '';
      }
      
      /* Raumliste lesbar machen: "[4]" -> "4 (Schlafzimmer)". Leere Liste -> ''.
       * Das Journal soll ohne Nachschlagen verstaendlich sein — die ID allein sagt
       * niemandem etwas, und die App zeigt ohnehin ID minus 2 an. */
      function raeumeText(roh) {
          if (roh === undefined || roh === null) return '';
          const t = String(roh).trim();
          if (t === '' || t === '[]') return '';
          let ids;
          try { ids = JSON.parse(t); } catch (e) { return t; }
          if (!Array.isArray(ids) || !ids.length) return '';
          const namen = {};
          for (const [n, r] of Object.entries(ROOMS)) namen[r.id] = n;
          return ids.map(i => i + (namen[i] ? ' (' + namen[i] + ')' : ' (unbekannt)')).join(', ');
      }
      
      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, quelle) {
          /* Wer den Befehl gegeben hat, ist die wichtigste Information ueberhaupt --
           * und die einzige, die im ioBroker-Log bisher fehlte. Am 02.09.2026 blieb
           * genau deshalb ungeklaert, wo Sarahs "Alexa, Staubsauger aus" gelandet
           * ist: es gab schlicht keine Zeile dazu. */
          journal('BEFEHL', what + '  [von: ' + quelleText(quelle) + ']');
          /* Auf freien Funk WARTEN statt den Befehl wegzuwerfen.
           * ------------------------------------------------------------------------
           * 🔴 Am 30.08.2026 im Alltag aufgefallen: "Alexa, sauge das Schlafzimmer" ->
           * Alexa quittiert mit OK, am Sauger passiert nichts. Im Log stand nur
           * "Funk gerade belegt, ignoriert". Der Befehl war zufaellig waehrend eines
           * Polling-Zyklus eingetroffen und wurde verworfen.
           *
           * Fuer den Nutzer ist das der schlimmste Fehlerfall: Alexa bestaetigt, das
           * Geraet tut nichts, und es gibt keine Rueckmeldung. Ein Polling-Zyklus
           * dauert nur wenige Sekunden -- also warten wir ihn ab.
           *
           * Der Timeout ist die Notbremse, falls ein Funkdialog haengt: dann lieber
           * eine klare Fehlermeldung im Log als ein Befehl, der Minuten spaeter
           * ueberraschend losfaehrt. */
          const wartenBis = Date.now() + 30000;
          while (busy && !stopping) {
              if (Date.now() > wartenBis) {
                  log('T5: ' + what + ' — Funk 30 s lang belegt, Befehl aufgegeben', 'error');
                  /* Der fuer den Nutzer schlimmste Fall: Alexa quittiert, nichts
                   * passiert. Der gehoert nicht nur ins Journal, sondern gemeldet. */
                  journal('AKTION', 'AUFGEGEBEN — Funk 30 s belegt: ' + what);
                  await meldung('Befehl verworfen (Funk 30 s belegt): ' + what);
                  return;
              }
              await pause(500);
          }
          if (stopping) return;
          busy = true;
          try {
              await fn();
              log('T5: ' + what);
              journal('AKTION', 'ausgefuehrt: ' + 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('T5: ' + what + ' FEHLGESCHLAGEN — ' + e.message + ' (Status war: ' + st + ')', 'error');
              journal('AKTION', 'FEHLGESCHLAGEN: ' + what + ' — ' + e.message + ' (Status: ' + st + ')');
              await meldung('Befehl fehlgeschlagen: ' + what + ' — ' + e.message);
          } finally {
              busy = false;
              reschedule(3000);                              // Status bald nachziehen
          }
      }
      
      on({ id: BASE + '.T5', change: 'any', ack: false }, async obj => {
          const q = obj.state.from;
          if (obj.state.val) await handle('Start ganzes Haus (saugen+wischen)', () => startWholeHome(MODE_BOTH), q);
          else               await handle('Stop und zur Basis',                 () => stopAndHome(), q);
      });
      
      on({ id: BASE + '.T5_zum_Wischen', change: 'any', ack: false }, async obj => {
          const q = obj.state.from;
          if (obj.state.val) await handle('Start nur wischen', () => startWholeHome(MODE_MOP), q);
          else               await handle('Stop und zur Basis', () => stopAndHome(), q);
      });
      
      /* ✅ GEPRUEFT (04.09.2026): "Fortsetzen" ueber 2.1 START_SWEEP ist unbedenklich.
       *
       * Der Verdacht vom 30.08. — 2.1 startet einen Lauf OHNE Kartenbezug, also
       * verwirft es vielleicht auch beim Fortsetzen die Karte — hat sich NICHT
       * bestaetigt. Testlauf 04.09.: Schlafzimmer per Alexa gestartet, per Alexa
       * pausiert (17.9 = 9), per Alexa fortgesetzt. Ergebnis: 17.9 sprang auf 10
       * ("Raum fortgesetzt"), Status 14 ("legt neue Karte an") trat NICHT auf.
       *
       * 2.1 verhaelt sich kontextabhaengig:
       *   - aus dem Leerlauf heraus:            neuer Lauf ohne Kartenbezug  <- 30.08.-Fehler
       *   - aus pausiertem Auftrag (17.9 = 9):  sauberes Fortsetzen          <- hier
       * Den Unterschied macht der lebende Auftragskontext: 17.34 traegt in der Pause
       * weiter die Raumliste, daran haengt sich 2.1 an.
       *
       * 17.2 pause-continue-work wird damit NICHT gebraucht. Der Tausch waere ein
       * Blindflug (piid 55 hat keine Werteliste und ist nicht lesbar) ohne Gewinn. */
      on({ id: BASE + '.T5_Pause', change: 'any', ack: false }, async obj => {
          const q = obj.state.from;
          if (obj.state.val) await handle('Pause',      () => action(ACT.PAUSE), q);
          else               await handle('Fortsetzen', () => action(ACT.START_SWEEP), q);
      });
      
      for (const [name, r] of Object.entries(ROOMS)) {
          on({ id: BASE + '.raum.' + name, change: 'any', ack: false }, async obj => {
              const q = obj.state.from;
              if (obj.state.val) await handle('Start Raum ' + name + ' (Raum-ID ' + r.id + ')',
                                              () => startRooms([r.id]), q);
              else               await handle('Stop und zur Basis', () => stopAndHome(), q);
          });
      }
      
      // ---------------------------------------------------------------- Start / Ende
      onStop(() => {
          journal('SYSTEM', '--- Skript beendet ---');
          stopping = true;
          if (pollTimer) clearTimeout(pollTimer);
          try { if (sock) sock.close(); } catch (e) { /* egal */ }
      }, 2000);
      
      (async () => {
          await createStates();
          openSocket();
          log('T5-Skript gestartet — Sauger ' + IP + ', ' + Object.keys(ROOMS).length + ' Räume bekannt');
          journal('SYSTEM', '--- Skript gestartet (' + Object.keys(ROOMS).length + ' Raeume, Journal aktiv) ---');
          await poll();
      })();
      

      8. Das Hilfsskript: Codes aus dem Plugin ziehen

      Das hier ist das Werkzeug aus Abschnitt 4. Es liest die main.bundle aus dem Mi-Home-Plugin und spuckt fertige Tabellen aus — für euer Gerät, nicht für meins.

      python extract-codes.py android/main.bundle
      

      Es sucht sich die deutschen Texte selbst heraus. Erwischt es die falsche Sprache, zeigt --sprachen alle gefundenen mit einem Beispielsatz, und --sprache 0 nimmt dann eine andere. Braucht nur Python 3, sonst nichts.

      Am Ende stehen fertige const ERROR = { … }-Blöcke auf dem Bildschirm, die ihr direkt ins Skript oben kopieren könnt, plus eine codes.json mit allem drin.

      extract-codes.py

      #!/usr/bin/env python3
      # -*- coding: utf-8 -*-
      """
      Fehler- und Meldungstexte aus einem Mi-Home-Geraeteplugin ziehen
      ================================================================
      
      Die MIoT-Spec sagt bei Fehlernummern nur "uint16" und verraet nicht, welche
      Zahl was bedeutet. Die Mi-Home-App weiss es aber -- sie laedt fuer jedes
      Geraetemodell ein eigenes Plugin nach, und da stehen die Texte im Klartext
      drin. In mehreren Sprachen, Deutsch meist dabei.
      
      WOHER BEKOMME ICH DAS PLUGIN?
        1. Modellname besorgen (z. B. xtl.vacuum.2407a) -- steht im Xiaomi Cloud
           Tokens Extractor in der Zeile "Model:", oder im ioBroker-Log, wenn der
           mihome-vacuum-Adapter "Model xyz not supported" meldet.
        2. https://home.miot-spec.com/spec/<modellname> aufrufen
        3. Dort oben auf den Link "下载米家插件" klicken (= Mi-Home-Plugin laden)
        4. Das ZIP auspacken -- gebraucht wird daraus android/main.bundle
      
      BENUTZUNG
        python extract-codes.py android/main.bundle
        python extract-codes.py android/main.bundle --sprachen    (Auswahl anzeigen)
        python extract-codes.py android/main.bundle --sprache 0   (andere waehlen)
      
      Ausgabe: fertige JS-Tabellen zum Kopieren und eine codes.json daneben.
      Braucht nur Python 3, keine zusaetzlichen Pakete.
      
      Getestet mit dem JONR T5 Pro. Bei anderen Geraeten kann das Bundle anders
      aufgebaut sein -- wenn nichts gefunden wird, lohnt ein Blick hinein: gesucht
      werden die Muster [<zahl>, { title: ... }] und keywordNNN: "text".
      """
      import io, re, sys, json, collections
      
      args = [a for a in sys.argv[1:] if not a.startswith('--')]
      if not args:
          sys.exit(__doc__)
      
      roh = io.open(args[0], 'rb').read().decode('utf-8', errors='replace')
      
      # --- Alle Textbausteine sammeln ------------------------------------------
      # Jeder Text steht mehrfach in der Datei: einmal pro Sprache. Die Sprachen
      # kommen dabei immer in derselben Reihenfolge -- das ist der Trick unten.
      def entschluesseln(t):
          """Aus dem Bundle kommt der ROHTEXT eines JS-Strings -- Umbrueche stehen
          darin noch als die zwei Zeichen Backslash-n. json.loads macht daraus
          echte Umbrueche; ohne das landet spaeter wortwoertlich \\r\\n im Text."""
          try:
              return json.loads('"' + t + '"')
          except ValueError:
              return t
      
      
      varianten = collections.defaultdict(list)
      for m in re.finditer(r'(keyword\d+):\s*"((?:[^"\\]|\\.)*)"', roh):
          if not m.group(2).startswith('K.'):           # blosse Verweise ueberspringen
              varianten[m.group(1)].append(entschluesseln(m.group(2)))
      
      if not varianten:
          sys.exit('Keine Textbausteine gefunden -- ist das wirklich die main.bundle?')
      
      # --- Wie viele Sprachen gibt es? -----------------------------------------
      # Die meisten Bausteine sind vollstaendig uebersetzt. Deren Anzahl ist die
      # Sprachanzahl; unvollstaendige lassen wir fuer die Auswahl aussen vor.
      anzahl = collections.Counter(len(v) for v in varianten.values()).most_common(1)[0][0]
      voll = {k: v for k, v in varianten.items() if len(v) == anzahl}
      
      # --- Welcher Index ist Deutsch? ------------------------------------------
      # Erkennung ueber typische Woerter und Umlaute. Weil die Sprachreihenfolge
      # ueberall gleich ist, reicht EIN Index fuer alle Bausteine.
      MARKER = re.compile(
          r'\b(Bitte|Sie|Ihre|nicht|und|oder|der|die|das|den|dem|ist|sind|wird|werden'
          r'|installiert|reinigen|entfernen|Roboter|Station|Fehler|voll|leer|Wasser)\b'
          r'|[äöüßÄÖÜ]')
      
      
      def sprachwert(i):
          t = [v[i] for v in voll.values()]
          return sum(1 for x in t if MARKER.search(x)) / len(t)
      
      
      werte = [sprachwert(i) for i in range(anzahl)]
      probe = lambda i: next(iter(voll.values()))[i]
      
      if '--sprachen' in sys.argv:
          print('Gefundene Sprachen (%d), mit Beispieltext:\n' % anzahl)
          for i in range(anzahl):
              marke = '  <- als Deutsch erkannt' if werte[i] == max(werte) else ''
              print('  %-2d  %s%s' % (i, probe(i)[:66], marke))
          print('\nMit --sprache <nummer> gezielt eine davon waehlen.')
          sys.exit(0)
      
      if '--sprache' in sys.argv:
          index = int(sys.argv[sys.argv.index('--sprache') + 1])
      else:
          index = werte.index(max(werte))
          print('Sprache %d automatisch als Deutsch erkannt.' % index)
          print('Probe: %s' % probe(index)[:66])
          print('Sieht falsch aus? --sprachen zeigt alle, --sprache <n> waehlt.\n')
      
      # Unvollstaendig uebersetzte Bausteine: den letzten vorhandenen nehmen,
      # lieber ein Text in der falschen Sprache als eine Luecke.
      TEXT = {k: (v[index] if len(v) > index else v[-1]) for k, v in varianten.items()}
      
      # --- Die eigentlichen Tabellen -------------------------------------------
      # Fehler:    [4012, { title: ...keywordX, solution: ...keywordY }]
      fehler = {}
      for code, kt, ks in re.findall(
              r'\[(\d{1,5}),\s*\{\s*title:\s*[^,]*?\.(keyword\d+),'
              r'\s*solution:\s*[^}]*?\.(keyword\d+)\s*\}\]', roh):
          c = int(code)
          if c not in fehler and kt in TEXT:
              fehler[c] = (TEXT[kt], TEXT.get(ks, ''))
      
      # Meldungen: [1008, ...keywordX]
      meldungen = {}
      for code, kw in re.findall(r'\[(\d{1,5}),\s*[^\]]*?\.(keyword\d+)\]', roh):
          c = int(code)
          if c not in meldungen and c not in fehler and kw in TEXT:
              meldungen[c] = TEXT[kw]
      
      if not fehler and not meldungen:
          sys.exit('Keine Codetabellen gefunden. Bei diesem Geraet ist das Bundle\n'
                   'offenbar anders aufgebaut -- bitte von Hand hineinschauen.')
      
      
      def sauber(t):
          t = re.sub(r'\s+', ' ', t).strip()
          # Beim Code 4902 steht im Original "2. 2." -- Tippfehler des Herstellers.
          return re.sub(r'\b(\d+)\.\s+\1\.', r'\1.', t)
      
      
      # --- Ausgabe --------------------------------------------------------------
      json.dump({'fehler': {str(k): {'titel': sauber(v[0]), 'loesung': sauber(v[1])}
                            for k, v in sorted(fehler.items())},
                 'meldungen': {str(k): sauber(v) for k, v in sorted(meldungen.items())}},
                io.open('codes.json', 'w', encoding='utf-8'), ensure_ascii=False, indent=2)
      
      print('%d Fehler, %d Meldungen -> codes.json\n' % (len(fehler), len(meldungen)))
      print('Zum Kopieren ins ioBroker-Skript:\n')
      
      
      def js_block(name, eintraege, kurz=90):
          print('const %s = {' % name)
          for k, v in eintraege:
              v = sauber(v).replace('\\', '\\\\').replace("'", "\\'")
              if len(v) > kurz:
                  v = v[:kurz - 1] + '…'
              print("    %d: '%s'," % (k, v))
          print('};\n')
      
      
      js_block('ERROR',         [(k, v[0]) for k, v in sorted(fehler.items()) if k < 4500])
      js_block('STATION_ERROR', [(k, v[0]) for k, v in sorted(fehler.items()) if k >= 4500])
      js_block('MSG',           sorted(meldungen.items()), kurz=120)
      js_block('FIX',           [(k, v[1]) for k, v in sorted(fehler.items()) if v[1]], kurz=220)
      

      Viele Grüße

      ioBroker js-controller 7.2.2 · 61 Instanzen
      Intel NUC5CPYB · Celeron N3060 · 8 GB · Debian 12 · Node 22
      Netz: FritzBox 7530 · UniFi USW-16-PoE + 2 APs · Synology NAS
      Shelly · Sonoff/Tasmota · Tuya · Zigbee · Nuki · Netatmo · Helios KWL · Anker Solix PV · Gardena · Alexa · Telegram · JONR T5 Pro (eigenes Skript)

      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

      276

      Online

      33.1k

      Benutzende

      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
      • Ungelesen 0
      • Kategorien
      • Unreplied
      • Beliebt
      • GitHub
      • Docu
      • Hilfe