2026-10-25

Tretmann X-Bike smart machen: Trittfrequenz mit ESP32 und Bluetooth an die Amazfit Bip 6 übertragen

DIY-Projekt · ESP32 · Bluetooth Low Energy · Arduino · Firmware 1.2.0

Mein Tretmann X-Bike besitzt einen Trainingscomputer, der unter anderem die Trittfrequenz anzeigt. Die Messwerte sollten zusätzlich auf meiner Amazfit Bip 6 erfasst und in der Zepp-App gespeichert werden. Statt einen weiteren Magneten und Sensor zu montieren, verwende ich den bereits eingebauten Drehimpulsgeber und einen ESP32 als Bluetooth-Trittfrequenzsensor.

 


 

Ergebnis: Die Uhr erkennt den ESP32 als externen Trittfrequenzsensor, zeichnet die Werte beim Training „Drinnen Radfahren“ auf und verbindet sich auch nach mehr als zehn Minuten Ruhe bei einem späteren Training erneut.

1. Vorhandenen Sensor untersuchen

Vom Pedalantrieb führt ein zweiadriges Kabel zu einem zweipoligen Klinkenstecker. Bei abgezogenem Stecker habe ich den Widerstand zwischen Spitze und Schaft gemessen. Einmal pro vollständiger Pedalumdrehung schließt der Kontakt mit etwa 2–3 Ω, ansonsten ist er hochohmig. Das spricht für einen einfachen Reed-Kontakt. Die Messung bestätigt außerdem: Ein Impuls entspricht einer Pedalumdrehung.

Die Widerstandsstufe des X-Bikes wird über diesen Kontakt nicht mit übertragen. Für eine spätere Leistungsschätzung müsste sie separat erfasst werden.

2. Benötigte Bauteile

BauteilAufgabe
Tretmann X-BikeHeimtrainer mit integriertem Reed-Kontakt
ESP32 DevKit (ESP32-WROOM-32)Impulsauswertung und BLE-GATT-Server
2-polige KlinkenbuchseAnschluss an das vorhandene Sensorkabel
LED und 330-Ω-WiderstandOptische Impulskontrolle
USB-Netzteil 5 VStromversorgung des ESP32
Amazfit Bip 6Empfang und Aufzeichnung der Trittfrequenz

3. Verdrahtung

AnschlussVerbindung
Klinkenbuchse, äußerer Kontakt 1GPIO27
Klinkenbuchse, äußerer Kontakt 2GND
LED-Anode (+)GPIO26 über 330 Ω
LED-Kathode (−)GND
ESP32-StromversorgungUSB 5 V
X-Bike-Sensor (Reed-Kontakt)
  Klinkenbuchse Kontakt 1 ─── GPIO27 (INPUT_PULLUP)
  Klinkenbuchse Kontakt 2 ─── GND

ESP32 GPIO26 ─── 330 Ω ─── LED Anode (+)
ESP32 GND    ───────────── LED Kathode (−)

ESP32 USB ─── 5-V-USB-Netzteil

Bei meiner Klinkenbuchse waren die beiden äußeren Lötpins die richtigen Sensorkontakte. Das ist keine allgemeingültige Pinbelegung: Bei anderen Buchsen müssen die Anschlüsse durchgemessen werden. Der ursprüngliche Trainingscomputer ist während dieses Aufbaus vom Sensor getrennt. 

4. Bluetooth-Protokoll

Der ESP32 verwendet das standardisierte Bluetooth Cycling Speed and Cadence Profile (CSC). Er muss keinen proprietären Garmin-Sensor imitieren. Der ESP32 arbeitet als BLE-Peripheral mit GATT-Server, die Uhr als Empfänger. Alle 1.000 ms sendet der ESP32 die kumulierten Kurbelumdrehungen und den Zeitpunkt des letzten Impulses als Notification. Aus diesen Daten ermittelt die Uhr die Trittfrequenz.

UUIDBedeutung
0x1816Cycling Speed and Cadence Service
0x2A5BCSC Measurement: Kurbelumdrehungen und Ereigniszeit
0x2A5CCSC Feature
0x2A5DSensor Location
0x180ADevice Information Service
0x180FBattery Service (simulierte 100 %)

Zusätzlich meldet die Firmware den Hersteller „XBike DIY“, die Softwareversion „1.2.0“ und einen fest simulierten Akkustand von 100 %. Dieser Akkustand ist keine Messung, weil der ESP32 über USB betrieben wird.

5. Arduino IDE vorbereiten

  1. Ein passendes ESP32-Boardpaket in der Arduino IDE installieren und das ESP32-DevKit auswählen.
  2. Über den Bibliotheksverwalter NimBLE-Arduino 2.x installieren.
  3. Den folgenden Sketch einfügen, kompilieren und auf den ESP32 hochladen.
  4. Den seriellen Monitor auf 115200 Baud einstellen.

6. Vollständiger Arduino-Code – Firmware 1.2.0

Die Firmware zählt die Impulse über einen Interrupt, lässt die Kontroll-LED kurz aufleuchten, überträgt CSC-Daten und startet nach einer Bluetooth-Trennung das Advertising erneut. Zusätzlich prüft sie regelmäßig, ob sie ohne aktive Verbindung wieder auffindbar ist.

#include <Arduino.h>
#include <NimBLEDevice.h>

/* Tretmann X-Bike BLE Cadence Sensor – Firmware 1.2.0
 * ESP32 DevKit / NimBLE-Arduino 2.x
 * Reed-Kontakt: GPIO27 gegen GND
 * Kontroll-LED: GPIO26 über 330 Ohm gegen GND
 */
#define REED_PIN 27
#define LED_PIN 26
#define DEVICE_NAME "XBike Cadence"
#define FIRMWARE_VERSION "1.2.0"
#define DEBOUNCE_US 80000
#define LED_DURATION_MS 100
#define BLE_INTERVAL_MS 1000

volatile uint16_t revolutions = 0;
volatile uint16_t eventTime = 0;
volatile uint32_t lastPulse = 0;
NimBLEServer* bleServer = nullptr;
NimBLECharacteristic* cscMeasurement = nullptr;
uint32_t lastLedPulse = 0;
uint32_t lastSend = 0;
uint32_t lastAdvertisingCheck = 0;
uint16_t lastDisplayedRevs = 0;

void IRAM_ATTR reedInterrupt() {
  uint32_t now = micros();
  if ((uint32_t)(now - lastPulse) < DEBOUNCE_US) return;
  lastPulse = now;
  revolutions++;
  eventTime = (uint16_t)(((uint64_t)now * 1024ULL) / 1000000ULL);
}

class ServerCallbacks : public NimBLEServerCallbacks {
  void onConnect(NimBLEServer* server, NimBLEConnInfo& connInfo) override {
    Serial.println("BLE: Verbunden");
  }
  void onDisconnect(NimBLEServer* server, NimBLEConnInfo& connInfo,
                    int reason) override {
    Serial.printf("BLE: Getrennt (Grund: %d)\n", reason);
    NimBLEDevice::startAdvertising();
    Serial.println("BLE: Advertising neu gestartet");
  }
};

void setup() {
  Serial.begin(115200);
  delay(300);
  pinMode(LED_PIN, OUTPUT);
  digitalWrite(LED_PIN, LOW);
  pinMode(REED_PIN, INPUT_PULLUP);
  attachInterrupt(digitalPinToInterrupt(REED_PIN), reedInterrupt, FALLING);

  NimBLEDevice::init(DEVICE_NAME);
  bleServer = NimBLEDevice::createServer();
  bleServer->setCallbacks(new ServerCallbacks());

  NimBLEService* cscService = bleServer->createService("1816");
  cscMeasurement = cscService->createCharacteristic(
    "2A5B", NIMBLE_PROPERTY::NOTIFY);
  auto* feature = cscService->createCharacteristic(
    "2A5C", NIMBLE_PROPERTY::READ);
  uint8_t featureData[2] = {0x02, 0x00};
  feature->setValue(featureData, 2);
  auto* location = cscService->createCharacteristic(
    "2A5D", NIMBLE_PROPERTY::READ);
  uint8_t sensorLocation = 0x05; // Left Crank
  location->setValue(&sensorLocation, 1);
  cscService->start();

  NimBLEService* deviceInfo = bleServer->createService("180A");
  auto* manufacturer = deviceInfo->createCharacteristic(
    "2A29", NIMBLE_PROPERTY::READ);
  manufacturer->setValue("XBike DIY");
  auto* model = deviceInfo->createCharacteristic(
    "2A24", NIMBLE_PROPERTY::READ);
  model->setValue("ESP32 Cadence");
  auto* firmware = deviceInfo->createCharacteristic(
    "2A26", NIMBLE_PROPERTY::READ);
  firmware->setValue(FIRMWARE_VERSION);
  auto* software = deviceInfo->createCharacteristic(
    "2A28", NIMBLE_PROPERTY::READ);
  software->setValue(FIRMWARE_VERSION);
  deviceInfo->start();

  NimBLEService* battery = bleServer->createService("180F");
  auto* batteryLevel = battery->createCharacteristic(
    "2A19", NIMBLE_PROPERTY::READ);
  uint8_t level = 100; // simuliert, USB-Versorgung
  batteryLevel->setValue(&level, 1);
  battery->start();

  NimBLEAdvertising* advertising = NimBLEDevice::getAdvertising();
  advertising->addServiceUUID("1816");
  advertising->setName(DEVICE_NAME);
  NimBLEDevice::startAdvertising();
  Serial.println("XBike Cadence gestartet – Firmware " FIRMWARE_VERSION);
}

void loop() {
  uint16_t revs, timeValue;
  noInterrupts();
  revs = revolutions;
  timeValue = eventTime;
  interrupts();

  if (revs != lastDisplayedRevs) {
    lastDisplayedRevs = revs;
    lastLedPulse = millis();
    digitalWrite(LED_PIN, HIGH);
    Serial.printf("Pedalumdrehungen: %u\n", revs);
  }
  if (millis() - lastLedPulse >= LED_DURATION_MS)
    digitalWrite(LED_PIN, LOW);

  if (millis() - lastSend >= BLE_INTERVAL_MS) {
    lastSend = millis();
    uint8_t data[5] = {
      0x02,
      (uint8_t)(revs & 0xFF), (uint8_t)(revs >> 8),
      (uint8_t)(timeValue & 0xFF), (uint8_t)(timeValue >> 8)
    };
    cscMeasurement->setValue(data, 5);
    if (bleServer->getConnectedCount() > 0) cscMeasurement->notify();
  }

  if (millis() - lastAdvertisingCheck >= 5000) {
    lastAdvertisingCheck = millis();
    if (bleServer->getConnectedCount() == 0) {
      NimBLEAdvertising* advertising = NimBLEDevice::getAdvertising();
      if (!advertising->isAdvertising()) {
        Serial.println("BLE: Advertising inaktiv - Neustart");
        NimBLEDevice::startAdvertising();
      }
    }
  }
  delay(5);
}

7. Amazfit Bip 6 verbinden und testen

  1. ESP32 über USB einschalten.
  2. Auf der Amazfit Bip 6 die Einstellungen für externe Geräte öffnen.
  3. Nach einem Trittfrequenzsensor suchen und XBike Cadence auswählen.
  4. Ein Training „Drinnen Radfahren“ starten und die Pedale bewegen.
  5. Nach dem Training die Trittfrequenzkurve in der Zepp-App kontrollieren.

Bei meinem Test wurden in 3:26 Minuten eine durchschnittliche Trittfrequenz von 61 U/min und ein Höchstwert von 69 U/min aufgezeichnet. Die Uhr zeigt außerdem Hersteller, Softwareversion und den simulierten Akkustand an.

 

8. Fehlerbehebung und Wiederverbindung

In einer früheren Firmware konnte die Uhr den Sensor nach dem Beenden eines Trainings zeitweise nicht erneut finden. Ein Aus- und Einschalten des ESP32 stellte die Erkennung wieder her. Version 1.2.0 ergänzt deshalb einen onDisconnect()-Callback und eine regelmäßige Advertising-Kontrolle. Bei einem späteren Test konnte die Uhr den Sensor nach über zehn Minuten Ruhe wieder verbinden, ohne dass der ESP32 neu gestartet werden musste.

Falls der Sensor nicht auftaucht: USB-Stromversorgung kurz trennen, den ESP32 erneut starten und die Suche wiederholen. Für eine weitergehende Diagnose im seriellen Monitor auf Meldungen wie „BLE: Verbunden“ und „BLE: Getrennt“ achten. Ein fehlender Suchtreffer kann auch bedeuten, dass der Sensor noch verbunden ist.

9. Fazit und mögliche Erweiterungen

Mit dem vorhandenen Reed-Kontakt, einem ESP32 und wenigen Bauteilen lässt sich das Tretmann X-Bike um eine standardisierte Bluetooth-Trittfrequenzmessung erweitern. Die Übertragung und Aufzeichnung mit der Amazfit Bip 6 wurden praktisch bestätigt.

Als spätere Erweiterung ist die Erfassung der festen Widerstandsstufen denkbar. Daraus könnte zusammen mit der Trittfrequenz eine geschätzte Leistung in Watt berechnet werden. Ohne Kalibrierung wäre dies allerdings kein verlässlicher Leistungsmesswert.

Den Einbau in ein entsprechendes Gehäuse überlasse ich jetzt jedem selbst. Alternativ tut es auch etwas Klebeband ;-) 

Hinweis: Der Aufbau wurde mit dem beschriebenen Tretmann X-Bike und einer Amazfit Bip 6 getestet. Die Funktion anderer Geräte, Sensorbeschaltungen und Firmwarestände ist nicht automatisch gewährleistet.

Kindle 4 als E-Ink-Display für Home Assistant

Praxisanleitung · Kindle 4 Non-Touch (D01100) · Firmware 4.1.4 · 600 × 800 Pixel 

 

1. Ziel und Ergebnis

Wir haben einen Kindle 4 (Baujahr 2011) zu einem dauerhaft betriebenen Home-Assistant-E-Ink-Display umgebaut. Statt der normalen Kindle-Oberfläche zeigt das Gerät ein von Home Assistant erzeugtes Dashboard an.

  • Automatischer Dashboard-Start nach dem Einschalten
  • Vollbildanzeige ohne Kindle-Statusleiste
  • Bildprüfung alle fünf Minuten; Aktualisierung nur bei Änderungen
  • Übertragung des Kindle-Akkustands an Home Assistant
  • Automatische Ladesteuerung über eine Zigbee-Steckdose
  • Fernwartung über WLAN und SSH
  • Dauerhaft deaktivierter Bildschirmschoner
  • Rückkehr zur Kindle-Oberfläche über SSH möglich

Hinweis zur Genauigkeit: Die Anleitung fasst unsere tatsächlich umgesetzten Schritte zusammen, nicht die zwischenzeitlichen Tests.

2. Verwendete Hardware und Software

Komponente Unsere Installation
E-Ink-Gerät Kindle 4 Non-Touch, Modell D01100
Firmware 4.1.4
Display 600 × 800 Pixel
Smart-Home-System Home Assistant OS
Konfigurationsrechner Linux Mint
Kindle-Erweiterungen Jailbreak, KUAL 2.7.37, MRPI, USBNetwork 0.57.N, NiLuJe ScreenSaver Hack
Bildausgabe curl, cmp, eips
SSH-Server Dropbear
Netzteil USB-Netzteil über Zigbee-Steckdose

Unsere Netzwerkadressen (für andere Installationen anpassen):

Gerät/Dienst Adresse
Home Assistant 192.168.0.xx
Kindle 192.168.0.yy
Linux Mint 192.168.0.zz
Dashboard-Server http://192.168.0.xx:5000/

3. Kindle vorbereiten

3.1 Jailbreak und Erweiterungen

Wir haben einen zur Firmware 4.1.4 passenden Jailbreak installiert und anschließend KUAL, MRPI, USBNetwork und den NiLuJe ScreenSaver Hack eingerichtet.

3.2 WLAN und SSH

Der Kindle wurde ins WLAN eingebunden. In /mnt/us/usbnet/etc/config wurden folgende Einstellungen verwendet. Die genannte IP-Adresse wird nur für das USNnet verwendet, nicht für die WLAN-Verbindung:

HOST_IP=192.168.15.201
KINDLE_IP=192.168.15.244
K3_WIFI="true"
K3_WIFI_SSHD_ONLY="false"
USE_OPENSSH="false"
USE_VOLUMD="true"
QUIET_DROPBEAR="false"
TWEAK_MAC_ADDRESS="false"

Der Zugriff erfolgt per RSA-Schlüssel über Dropbear. Auf Linux Mint ist ein SSH-Alias eingerichtet:

ssh kindle

Wichtig: Den USBNetwork-Autostart haben wir nicht aktiviert. /mnt/us/usbnet/DISABLED_auto ist vorhanden; /mnt/us/usbnet/auto fehlt. So wird der USB-Massenspeichermodus nicht automatisch durch USB-Ethernet ersetzt.

4. Dashboard in Home Assistant bereitstellen

Wir verwenden das Add-on sibbl/hass-lovelace-kindle-screensaver. Es erzeugt aus dem Home-Assistant-Dashboard ein Bild, das der Kindle über das lokale Netzwerk abruft:

http://192.168.0.xx:5000/

Ein Browser auf dem Kindle ist nicht nötig. Das Dashboard wurde für 600 × 800 Pixel eingerichtet; 

 

5. Dashboard-Skript auf dem Kindle

Datei: /mnt/us/ha_display.sh

Dieses Skript liest den Akkustand aus, sendet ihn an Home Assistant, lädt das Dashboard-Bild herunter und aktualisiert das E-Ink-Display nur, wenn sich das Bild verändert hat:

#!/bin/sh

HA_IP="192.168.0.xx"
IMAGE_URL="http://$HA_IP:5000/"
BATTERY_URL="http://$HA_IP:8123/api/webhook/kindle_4_battery_8f3a71c2"

IMAGE="/tmp/ha_display.png"
TEMP="/tmp/ha_display_new.png"
CHILD=""

cleanup() {
    if [ -n "$CHILD" ]; then
        kill "$CHILD" 2>/dev/null
    fi
    exit 0
}
trap cleanup TERM INT HUP

while true; do
    if [ ! -f /mnt/us/ha_disabled ]; then
        BATTERY=$(lipc-get-prop com.lab126.powerd battLevel)

        curl -s --max-time 15 \
            -X POST \
            -H "Content-Type: application/json" \
            -d "{\"battery\":$BATTERY}" \
            "$BATTERY_URL" >/dev/null 2>&1

        if curl -s --max-time 60 -o "$TEMP" "$IMAGE_URL"; then
            if [ -s "$TEMP" ] && ! cmp -s "$TEMP" "$IMAGE"; then
                mv "$TEMP" "$IMAGE"
                eips -f -g "$IMAGE"
            fi
        fi
    fi

    sleep 300 &
    CHILD=$!
    wait "$CHILD"
    CHILD=""
done

Ausführbar machen:

chmod +x /mnt/us/ha_display.sh

Eine Sicherung liegt unter /mnt/us/ha_display.sh.bak.

Technischer Hintergrund: eips -f -g schreibt das Bild direkt auf das E-Ink-Display. cmp verhindert unnötige Display-Refreshs. Die Wartezeit beträgt 300 Sekunden.

6. Mehrfachstarts verhindern und Cron einrichten

Datei: /mnt/us/ha_start.sh

Der Wrapper verwendet ein Lock-Verzeichnis und eine PID-Datei. Dadurch wird verhindert, dass mehrere Dashboard-Instanzen gleichzeitig laufen:

#!/bin/sh

LOCK="/tmp/ha_display.lock"
PIDFILE="$LOCK/pid"
CHILD=""

if ! mkdir "$LOCK" 2>/dev/null; then
    if [ -f "$PIDFILE" ]; then
        PID=$(cat "$PIDFILE")
        if kill -0 "$PID" 2>/dev/null; then
            exit 0
        fi
    fi
    rm -rf "$LOCK"
    mkdir "$LOCK" 2>/dev/null || exit 1
fi

echo $$ > "$PIDFILE"

cleanup() {
    if [ -n "$CHILD" ]; then
        kill "$CHILD" 2>/dev/null
        wait "$CHILD" 2>/dev/null
    fi
    rm -rf "$LOCK"
    exit 0
}
trap cleanup TERM INT HUP

/bin/sh /mnt/us/ha_display.sh &
CHILD=$!
wait "$CHILD"
cleanup

Ausführbar machen:

chmod +x /mnt/us/ha_start.sh

Eine Sicherung liegt unter /mnt/us/ha_start.sh.bak.

In /etc/crontab/root haben wir folgende Zeile ergänzt:

* * * * * /mnt/us/ha_start.sh >> /mnt/us/ha_cron.log 2>&1

Cron anschließend neu starten:

/etc/init.d/cron restart

Cron ruft den Wrapper jede Minute auf. Die Lock-Prüfung verhindert Doppelstarts; die eigentliche Bildprüfung erfolgt innerhalb von ha_display.sh alle fünf Minuten.

7. Akkustand und Ladesteuerung

Den Akkuwert liefert die Kindle-Energieverwaltung:

lipc-get-prop com.lab126.powerd battLevel

Das Dashboard-Skript sendet ihn per HTTP-POST an den Home-Assistant-Webhook:

http://192.168.0.xx:8123/api/webhook/kindle_4_battery_nnnnnnn

Sicherheitshinweis: Die Webhook-ID ist installationsspezifisch, hier nur beispielhaft.

Dieser Ladezustand wird in einem Helfer gespeichert und damit eien Automation gesteuert, die die Steckdose des Netzteils für den Kindle bei einem Ladezustand von über 80% abschaltet und unter 30% einschaltet 

8. SSH-Autostart und Bildschirmschoner

Datei: /mnt/us/ha_ssh_start.sh

Dies ist das SSH-Startskript:

#!/bin/sh

# Bildschirmschoner deaktivieren
lipc-set-prop com.lab126.powerd preventScreenSaver 1

DROPBEAR="/usr/bin/dropbear"
PIDFILE="/mnt/us/usbnet/run/sshd.pid"

# SSH-Port ueber WLAN freigeben
if ! iptables -L INPUT -n -v 2>/dev/null | grep -q 'wlan0.*tcp dpt:22'; then
    iptables -A INPUT -i wlan0 -p tcp --dport 22 -j ACCEPT
fi

# Laufenden SSH-Server erkennen
if [ -f "$PIDFILE" ]; then
    PID=$(cat "$PIDFILE")
    if kill -0 "$PID" 2>/dev/null; then
        exit 0
    fi
    rm -f "$PIDFILE"
fi

# SSH-Server starten
"$DROPBEAR" -P "$PIDFILE" -K 15

Datei ausführbar machen:

chmod +x /mnt/us/ha_ssh_start.sh

Für den automatischen Start haben wir den symbolischen Link /etc/rc5.d/S90ha-ssh angelegt. Dazu wird das Root-Dateisystem kurzzeitig beschreibbar geschaltet:

mntroot rw
ln -s /mnt/us/ha_ssh_start.sh /etc/rc5.d/S90ha-ssh
mntroot ro

Der SSH-Start wurde nach einem vollständigen Neustart bestätigt. Der Kindle startet den SSH-Dienst vor dem Framework-Aufruf (S95framework).

Hinweis: Das Skript ist keine allgemeine Härtungsvorlage für öffentlich erreichbare SSH-Server. Port 22 sollte ausschließlich aus einem vertrauenswürdigen lokalen Netz erreichbar sein.

Bildschirmschoner deaktivieren

Der folgende Befehl setzt den Kindle-Status auf 1:

lipc-set-prop com.lab126.powerd preventScreenSaver 1

Kontrolle:

lipc-get-prop com.lab126.powerd preventScreenSaver

Erwartete Ausgabe:

1

Da der Wert nach einem Neustart zunächst wieder 0 war, wurde die Einstellung in ha_ssh_start.sh aufgenommen. Nach einem erneuten Neustart stand der Wert korrekt auf 1.

9. Vollbildmodus ohne Kindle-Oberfläche

Der entscheidende Schritt verwendet eine originale Firmware-Funktion. Das Kindle-Skript /etc/init.d/framework prüft beim Start, ob diese Datei vorhanden ist:

/mnt/base-us/DONT_START_FRAMEWORK

Ist sie vorhanden, wird das Kindle-Framework nicht gestartet. Unser Dashboard läuft trotzdem und zeichnet über eips direkt auf das Display. Wir mussten dafür keine Framework-Systemdatei bearbeiten.

Vollbildmodus aktivieren:

touch /mnt/base-us/DONT_START_FRAMEWORK
reboot

Beim Start erscheint zunächst kurz die Firmware-Meldung, dass das Framework nicht gestartet wird. Sobald unser Dashboard-Skript das erste Bild überträgt, erscheint die Home-Assistant-Anzeige ohne Kindle-Statusleiste.

Zur normalen Kindle-Oberfläche zurückkehren

Über die funktionierende SSH-Verbindung:

rm -f /mnt/base-us/DONT_START_FRAMEWORK
reboot

Wichtig: Dieser Rückweg setzt erreichbares WLAN-SSH voraus. Ein davon unabhängiger USB-Rettungsweg wurde in unserem Projekt nicht abschließend bestätigt.

10. Übersicht der dauerhaft verwendeten Dateien

Datei oder Pfad Aufgabe
/mnt/us/ha_display.sh Akku melden, Bild herunterladen, vergleichen und anzeigen
/mnt/us/ha_start.sh Start-Wrapper mit Lock/PID
/mnt/us/ha_ssh_start.sh WLAN-SSH und Bildschirmschoner-Sperre
/etc/crontab/root Cron-Autostart des Dashboards
/etc/rc5.d/S90ha-ssh SSH-Autostart beim Booten
/mnt/base-us/DONT_START_FRAMEWORK Normale Kindle-Oberfläche deaktivieren
/mnt/us/usbnet/etc/config USBNetwork-Konfiguration
/mnt/us/ha_cron.log Protokoll des Cron-Aufrufs
/tmp/ha_display.lock Lock-Verzeichnis für Dashboard-Wrapper

Die Dateien ha_recovery.sh und ha_recovery_timer.sh entstanden nur im Rahmen der Absicherung und Tests. Der Timer-Aufruf wurde vor dem dauerhaften Betrieb wieder aus dem SSH-Startskript entfernt; beide Skripte sind nicht Bestandteil des regulären Bootablaufs.

11. Was passiert nach einem Neustart?

  1. Der Kindle startet seine grundlegenden Systemdienste und verbindet sich mit dem WLAN.
  2. S90ha-ssh startet den SSH-Server und deaktiviert den Bildschirmschoner.
  3. S95framework erkennt DONT_START_FRAMEWORK und überspringt die normale Oberfläche.
  4. Cron ruft ha_start.sh auf.
  5. Der Wrapper startet einmalig ha_display.sh.
  6. Das Skript sendet den Akkustand, lädt das Dashboard und zeigt neue Bilder mit eips an.
  7. Nach 300 Sekunden wird erneut geprüft.

12. Wartung und Administration

Aufgabe Befehl
SSH-Verbindung ssh kindle
Dashboard-Prozesse anzeigen ps -ef \| grep ha_
Dropbear-Prozesse anzeigen ps -ef \| grep dropbear
Cron-Protokoll ansehen tail /mnt/us/ha_cron.log
Dashboard pausieren touch /mnt/us/ha_disabled
Dashboard fortsetzen rm -f /mnt/us/ha_disabled
Bildschirmschoner prüfen lipc-get-prop com.lab126.powerd preventScreenSaver
Kindle neu starten reboot

13. Fazit

Der Kindle 4 funktioniert nun als eigenständiges Home-Assistant-E-Ink-Dashboard mit automatischem Start, Vollbildanzeige, SSH-Fernwartung und gesteuerter USB-Stromversorgung. Die wesentlichen Bausteine sind die Bildausgabe über eips, die zuverlässige Startlogik über Cron und SysV sowie die Firmware-eigene Datei DONT_START_FRAMEWORK.

Für Nachbauer: Die gezeigten IP-Adressen, Webhook-Kennung, Dashboard-Adresse und Home-Assistant-Entitäten sind installationsspezifisch. Vor der Übernahme anpassen.

⬇ Kindle 4 – Skripte herunterladen

2026-10-13

Alten Roomba smart machen

Die neuen Saug-Wisch-Roboter sind schon genial. Wir haben uns einen Mova Z70 zugelegt. Hammerteil.

Aber was machen wir mit unserem alten Roomba?

  • Er ist über 10 (!) Jahre alt.
  • Läuft immer noch.
  • Saugt halt nur.
  • Sensorik eingeschränkt.
  • Kann über eine Fernbedienung gesteuert werden.

Meine Lösung:

Die Fernsteuerung wird zerlegt. In ein separates Gehäuse kommen Ultraschall-Entfernungs-Sensoren, ein Relais, ein Touch-Taster und eine RGB-LED (WS2812B). Zur Steuerung genügt ein ESP8266.

Fix mal alles verdrahten. Die Roomba-Fernsteuerung wird über 3,3V vom ESP versorgt.

Alles in ein Gehäuse.

Die Docking wird über eine Halterung an der Wand oberhalb der Sockelleiste fest montiert, dann wandert da nichts.

 

Konfiguration des ESP:

esphome:
  name: esp21roomba

esp8266:
  board: nodemcuv2
  framework:
    version: recommended


################################################################################
# RoombaBot Küche
################################################################################

wifi:
  networks:
    - ssid: !secret wifi_ssid
      password: !secret wifi_password

  manual_ip:
    static_ip: 192.168.x.y
    gateway: 192.168.x.1
    subnet: 255.255.255.0

captive_portal:

# Enable logging
logger:

# Enable Home Assistant API
api:

ota:
  - platform: esphome

switch:
# Roomba starten
  - platform: gpio
    pin: D1
    id: relay
  - platform: template
    name: "esp21_roomba"
    icon: "mdi:robot-vacuum"
    turn_on_action:
    - switch.turn_on: relay
    - delay: 500ms
    - switch.turn_off: relay

  - platform: restart
    name: "restart_esp21"

sensor:
  - platform: ultrasonic
    trigger_pin: D5
    echo_pin: D6
    name: "roomba_present"
    id: roomba_present

binary_sensor:
  - platform: template
    name: roomba_at_home
    lambda: !lambda |-
      if (id(roomba_present).state < 0.07) {
          return true;
      } else {
        return false;
      }
 # Taster: Staubbehälter wurde geleert
  - platform: gpio
    pin:
      number: D2
      mode: INPUT_PULLUP
    #  inverted: true
    name: "Roomba Behälter geleert"
    id: roomba_geleert
    filters:
      - delayed_on: 50ms
      - delayed_off: 50ms
light:
  - platform: neopixelbus
    type: GRB
    variant: WS2812
    pin: D7
    num_leds: 1
    name: "Roomba Status LED"
    id: roomba_led
    restore_mode: ALWAYS_OFF

Verdrahtungsplan:

Helfer zur Steuerung:

Typ 

Name 

Entity-ID 

Zähler 

Roomba Wartungszähler 

counter.roomba_wartung 

Dropdown 

Roomba Wartungsintervall 

input_select.roomba_wartungsintervall 

Datum und Uhrzeit 

Roomba letzte Leerung 

input_datetime.roomba_letzte_leerung 

Datum und Uhrzeit 

Roomba letzte Wartung 

input_datetime.roomba_letzte_wartung 

Zeitplan 

Roomba Reinigung 

schedule.roomba_reinigung 

Boolean 

Roomba unterwegs 

input_boolean.roomba_unterwegs 

Zähler 

Roomba Reinigungen 

counter.roomba_reinigungen 

Zähler 

Roomba Gesamt Reinigungen 

counter.roomba_gesamt_reinigungen 

 

 


Scripte:

alias: Roomba Behälter geleert
description: Setzt den Leerungszähler zurück und speichert den Zeitpunkt
sequence:
  - action: counter.reset
    target:
      entity_id: counter.roomba_reinigungen
  - action: input_datetime.set_datetime
    target:
      entity_id: input_datetime.roomba_letzte_leerung
    data:
      timestamp: '{{ now().timestamp() }}'
mode: single

 

alias: Roomba Gesamtwartung durchgeführt
description: Setzt den Wartungszähler zurück und speichert den Zeitpunkt
sequence:
  - action: counter.reset
    target:
      entity_id: counter.roomba_wartung
  - action: input_datetime.set_datetime
    target:
      entity_id: input_datetime.roomba_letzte_wartung
    data:
      timestamp: '{{ now().timestamp() }}'
mode: single
 

Automatisierungen:

 alias: Roomba - Status LED
description: Grün unter 4, Rot ab 4 Reinigungen
triggers:
  - trigger: state
    entity_id: counter.roomba_reinigungen
  - trigger: homeassistant
    event: start
actions:
  - choose:
      - conditions:
          - condition: numeric_state
            entity_id: counter.roomba_reinigungen
            above: 3
        sequence:
          - action: light.turn_on
            target:
              entity_id:
                - light.kuche_esp21roomba_roomba_status_led
            data:
              rgb_color:
                - 255
                - 0
                - 0
              brightness_pct: 10
    default:
      - action: light.turn_on
        target:
          entity_id:
            - light.kuche_esp21roomba_roomba_status_led
        data:
          rgb_color:
            - 0
            - 255
            - 0
          brightness_pct: 10
mode: restart

 

alias: Roomba - Reinigungsläufe zählen
description: Zählt Reinigungsläufe seit Leerung und insgesamt
triggers:
  - trigger: state
    entity_id: binary_sensor.roomba_at_home
    from: 'on'
    to: 'off'
    for:
      seconds: 15
    id: verlassen
  - trigger: state
    entity_id: binary_sensor.roomba_at_home
    from: 'off'
    to: 'on'
    for:
      seconds: 15
    id: angekommen
actions:
  - choose:
      - conditions:
          - condition: trigger
            id: verlassen
        sequence:
          - action: input_boolean.turn_on
            target:
              entity_id: input_boolean.roomba_unterwegs
      - conditions:
          - condition: trigger
            id: angekommen
          - condition: state
            entity_id: input_boolean.roomba_unterwegs
            state: 'on'
        sequence:
          - action: counter.increment
            target:
              entity_id:
                - counter.roomba_reinigungen
                - counter.roomba_gesamt_reinigungen
                - counter.roomba_wartung
            data: {}
          - action: input_boolean.turn_off
            target:
              entity_id: input_boolean.roomba_unterwegs
mode: queued

 

alias: Roomba - Start nach Zeitplan
description: Startet den Roomba bei aktiviertem Zeitplan
triggers:
  - trigger: state
    entity_id: schedule.roomba_reinigung
    from: 'off'
    to: 'on'
conditions:
  - condition: state
    entity_id: binary_sensor.roomba_at_home
    state: 'on'
actions:
  - action: switch.turn_on
    target:
      entity_id: switch.esp21_roomba
mode: single

 

alias: Roomba - Behälter gereinigt
description: Setzt den Reinigungszähler zurück
triggers:
  - trigger: state
    entity_id:
      - binary_sensor.kuche_esp21roomba_roomba_behalter_geleert
    from:
      - 'off'
    to:
      - 'on'
actions:
  - action: counter.reset
    target:
      entity_id: counter.roomba_reinigungen
mode: single

 

alias: Roomba Gesamtwartung fällig
description: Meldung bei erreichtem Wartungsintervall
triggers:
  - trigger: state
    entity_id:
      - counter.roomba_wartung
      - input_select.roomba_wartungsintervall
conditions:
  - condition: template
    value_template: >
      {% set count = states('counter.roomba_wartung') | int(0) %} {% set limit =
      states('input_select.roomba_wartungsintervall') | int(25) %} {{ count >=
      limit }}
actions:
  - action: persistent_notification.create
    data:
      notification_id: roomba_gesamtwartung
      title: Roomba – Gesamtwartung erforderlich
      message: >
        Der Roomba hat {{ states('counter.roomba_wartung') }} Reinigungen seit
        der letzten Wartung durchgeführt. Bitte Wartung durchführen.
mode: single
 

Dashbord-Kacheln:

Hier als RAW-Definition:

      - type: grid
        cards:
          - type: vertical-stack
            cards:
              - type: heading
                heading: Roomba – Reinigung
                heading_style: title
                icon: mdi:robot-vacuum
              - type: horizontal-stack
                cards:
                  - type: tile
                    entity: binary_sensor.roomba_at_home
                    name: Dockingstation
                    icon: mdi:home
                    color: green
                    vertical: true
                    features_position: bottom
                  - type: tile
                    entity: counter.roomba_reinigungen
                    name: Seit Leerung
                    icon: mdi:counter
                    color: green
                    vertical: true
                    features_position: bottom
              - type: conditional
                conditions:
                  - condition: numeric_state
                    entity: counter.roomba_reinigungen
                    above: 3
                card:
                  type: markdown
                  content: |
                    ## 🔴 Behälter leeren
                    Der Roomba hat mindestens vier
                    Reinigungen durchgeführt.
                    Bitte Staubbehälter leeren.
              - type: conditional
                conditions:
                  - condition: numeric_state
                    entity: counter.roomba_reinigungen
                    below: 4
                card:
                  type: markdown
                  content: |
                    ## 🟢 Behälter OK
                    **{{ states('counter.roomba_reinigungen') }} / 4**
                    Reinigungen seit letzter Leerung.
              - type: horizontal-stack
                cards:
                  - type: button
                    name: Roomba starten
                    icon: mdi:play-circle
                    show_state: false
                    tap_action:
                      action: call-service
                      service: switch.turn_on
                      target:
                        entity_id: switch.esp21_roomba
              - type: entities
                title: Reinigungsstatistik
                show_header_toggle: false
                entities:
                  - entity: counter.roomba_gesamt_reinigungen
                    name: Reinigungen insgesamt
                    icon: mdi:chart-line
          - type: vertical-stack
            cards:
              - type: heading
                heading: Roomba – Wartung
                heading_style: title
                icon: mdi:tools
              - type: markdown
                content: >-
                  {% set n = states('counter.roomba_reinigungen') | int(0) %} {%
                  set last = states('input_datetime.roomba_letzte_leerung') %}
                  ### {{ '🔴 Behälter leeren' if n >= 4 else '🟢 Behälter OK' }}

                  **{{ n }} / 4 Reinigungen**

                  {% if last not in ['unknown','unavailable','none'] %} Letzte
                  Leerung vor: {{ relative_time(as_datetime(last)) }} {% else %}
                  Letzte Leerung: Noch nicht erfasst {% endif %}
              - show_name: true
                show_icon: true
                type: button
                name: Behälter geleert – Reset
                icon: mdi:delete-empty
                tap_action:
                  action: perform-action
                  perform_action: script.roomba_behalter_geleert
                  confirmation:
                    text: Wurde der Staubbehälter geleert?
                  target: {}
              - type: markdown
                content: >-
                  {% set n = states('counter.roomba_wartung') | int(0) %} {% set
                  limit = states('input_select.roomba_wartungsintervall') |
                  int(25) %} {% set rest = [limit - n, 0] | max %} {% set last =
                  states('input_datetime.roomba_letzte_wartung') %}

                  ### {{ '🔴 Wartung erforderlich' if n >= limit else '🟢
                  Wartung OK' }}

                  **{{ n }} / {{ limit }} Reinigungen**

                  {% if n >= limit %} Wartungsintervall erreicht! {% else %}
                  Noch {{ rest }} Reinigungen bis zur Wartung. {% endif %}

                  {% if last not in ['unknown','unavailable','none'] %} Letzte
                  Wartung vor: {{ relative_time(as_datetime(last)) }} {% else %}
                  Letzte Wartung: Noch nicht erfasst {% endif %}
              - show_name: true
                show_icon: true
                type: button
                name: Gesamtwartung durchgeführt
                icon: mdi:tools
                tap_action:
                  action: perform-action
                  perform_action: script.roomba_gesamtwartung_durchgefuhrt
                  confirmation:
                    text: Wurde die Gesamtwartung durchgeführt?
                  target: {}
              - type: entities
                title: Gesamtwartung
                show_header_toggle: false
                entities:
                  - entity: counter.roomba_wartung
                    name: Reinigungen seit Wartung
                    icon: mdi:counter
                  - entity: input_select.roomba_wartungsintervall
                    name: Wartung alle
                    icon: mdi:cog-clock


 Schaut dann so aus:

 

 

Das Gehäuse für den ESP mitsamt der Elektronik gibt es auf printables.com.