⚡ Warum diese Schalter?#
Seien wir ehrlich: Das Nachrüsten von Lichtschaltern in einem bestehenden Haus ist eine lästige Angelegenheit. Wände aufstemmen, Kabel ziehen, Löcher patchen, schleifen, streichen — nur um einen Schalter 1,5 Meter nach links zu versetzen, weil dein Schreibtisch jetzt genau dort steht? Nein danke.
Deshalb habe ich mich für kinetische RF-Schalter entschieden, anstatt für herkömmliche WiFi- oder batteriebetriebene Modelle. Diese Schalter sind komplett drahtlos, batteriefrei und werden von purer Knopfdruckenergie betrieben. Ja — wie der Funkenknall an einem Gasfeuerzeug, nur dass statt Propan ein RF-Signal ausgelöst wird.
Es ist genial. Ein Druck auf den Schalter erzeugt genug Energie über ein Piezoelement, um einen winzigen Schaltkreis zu betreiben und ein RF-Code zu senden. Kein Verkabeln. Keine Batterien, die gewechselt werden müssen. Kein Müll. Und noch besser: Das enthaltene Empfangsmodul kann mit bis zu fünf verschiedenen Schaltern gekoppelt werden.
Kurz gesagt, es löst drei große Probleme auf einmal:
- Kein Aufstemmen von Wänden
- Keine leeren Batterien, die ersetzt (oder weggeworfen) werden müssen
- Kein Kompromiss bei der Position des Schalters
Natürlich musste ich das hacken.
🛠️ Was ich gemacht habe#
Ich wollte eine vollständige Integration in mein Home Assistant-Setup via MQTT, was bedeutete, dass ich herausfinden musste, wie der Schalter kommuniziert — und dann selbst antworten konnte.
Schritt 1: Spektrumanalyse mit URH#
Zuerst steckte ich meinen Nooelec RTL-SDR v5 (ein USB Software Defined Radio-Stick) ein und startete den Universal Radio Hacker (URH). URH ist eine komplette Suite zur Untersuchung drahtloser Protokolle mit nativer Unterstützung für viele SDRs. Es macht die Demodulation einfach, erkennt automatisch Modulationsparameter und hilft, die Bits und Bytes, die durch die Luft fliegen, zu visualisieren.
Um zu beginnen, öffnete ich den Spektrumanalyzer in URH und drückte den kinetischen Schalter, während ich auf Spitzen im RF-Spektrum achtete.
Bam — ein klarer Burst jedes Mal, wenn ich drückte. Frequenz festgelegt: 433,92 MHz.
Schritt 2: Signalaufzeichnung#
Nun, da ich die Frequenz hatte, wechselte ich in den „Signal aufnehmen“-Modus von URH. Ich startete die Aufnahme, drückte den Schalter ein paar Mal und voilà — wunderschöne Bursts von modulierten Daten.
Im obigen Screenshot sieht man vier verschiedene Signale — ich drückte den Schalter vier Mal, und jeder Druck führte zu einem Burst von RF-Daten, die im Aufzeichnungsmodus deutlich sichtbar sind.
URH hat seine Magie gewirkt und die Modulation automatisch als ASK (Amplitude Shift Keying) identifiziert. Das hat mich etwas überrascht — ursprünglich hatte ich mit FSK (Frequency Shift Keying) gerechnet, was bei Low-Power-Geräten häufiger vorkommt, da es in lauten Umgebungen energieeffizienter sein kann. Aber in diesem Fall: ASK.
🔍 Kurzer Modulationsprimer: ASK vs. FSK#
ASK (Amplitude Shift Keying): Bits werden durch Ändern der Amplitude des Signals übertragen — eine volle Welle könnte
1
bedeuten, keine Welle bedeutet0
.FSK (Frequency Shift Keying): Bits werden durch Frequenzänderungen dargestellt — zum Beispiel könnte eine leicht höhere Frequenz eine
1
und eine niedrigere Frequenz eine0
bedeuten.
Während ASK einfacher zu implementieren ist, ist es oft anfälliger für Störungen und kann etwas weniger effizient sein, wenn es um die Bits geht (buchstäblich). Trotzdem hat es einen kleineren Schaltkreis — was möglicherweise die Designwahl hier erklärt, da die Übertragung sehr kurz ist und das Ziel eher Einfachheit als Reichweite ist.
Schritt 2.5: Nahansicht – Wiederholende Muster#
Beim Hineinzoomen in einen dieser Bursts wird ein sehr interessantes Detail deutlich: Jeder Druck sendet tatsächlich dieselben Daten mehrmals hintereinander — und zwar viermal, mit kurzen Pausen dazwischen.
Das wird wahrscheinlich aus Gründen der Zuverlässigkeit gemacht — falls eines der Pakete verloren geht oder verzerrt wird, hat der Empfänger noch ein paar Chancen, es zu empfangen.
Allerdings schneidet sich das Signal beim vierten Übertragungsversuch innerhalb des Bursts abrupt ab — es scheint, als ob das kinetische Modul am Ende der Pressung keine Energie mehr hatte. Der letzte Wiederholungsversuch ist unvollständig und wird wahrscheinlich vom Empfänger ignoriert.
Trotzdem sind drei von vier vollständig gesendeten Paketen ziemlich gut für einen batterielosen Schalter, der nur von einem winzigen mechanischen Klick betrieben wird.
Schritt 3: Demodulieren und Extrahieren der Bits#
Als Nächstes habe ich die Daten demoduliert. Das Ergebnis war ein sauberer Bitstream, der die Übertragung darstellt.
Von dort konnte ich die binäre Zeichenkette kopieren und später in meinem ESP-Code verwenden, um das Signal exakt zu replizieren.
Eine Funktion in URH, die mir bei diesem Prozess wirklich geholfen hat, ist, dass sie den demodulierten Teil des Signals live hervorhebt, während man die entsprechenden Bits im Panel darunter auswählt. Man kann wirklich sehen, wo jedes Burst von Binärdaten im Wellenform sitzt, was es viel einfacher macht, wiederholte Muster, Signalgrenzen und Strukturen zu verstehen.
Jetzt kommt der spaßige Teil — und mit „Spaß“ meine ich den Teil, der mehr Stunden meines Lebens gestohlen hat, als ich öffentlich zugeben möchte. 😅
Zuerst dachte ich, dass die führende 1
, gefolgt von einer langen Kette von Nullen, eine Art Preambel oder Initialisierungsimpuls war — sozusagen ein bisschen „Fluff“ vor den eigentlichen Daten. Und ich dachte, das letzte 1
wäre ein schöner Stop-Bit. Wie ordentlich, oder?
Falsch.
Es stellt sich heraus, dass diese 1
tatsächlich das erste Bit der eigentlichen Nachricht ist, und das letzte 1
? Ja, das ist bereits der Beginn der nächsten Übertragung.
Als ich das endlich verstand, machte alles Sinn.
Jedes Signal folgt einer Struktur wie dieser:
1
, gefolgt von 31 Nullen → Das scheint ein fester Header (32 Bits, oder 4 Bytes) zu sein- Dann kommen 96 Bits (12 Bytes) an Daten
- Und die letzten 24 Bits (3 Bytes) waren bei allen drei getesteten kinetischen Schaltern gleich
Es handelt sich also um einen 4-Byte-Header, eine 12-Byte-Nutzlast und einen 3-Byte-Footer, die bei jedem Klick viermal wiederholt werden. Plötzlich war das Protokoll kein Mysterium mehr — es war sauber, kompakt und irgendwie elegant.
Und ich habe vielleicht ein kleines „Juhu!“ von mir gegeben, als alles endlich Sinn machte.
Schritt 4: Selbst übertragen#
Jetzt war es Zeit, vom Lauscher zum Nachahmer zu werden.
Ich schloss ein CC1101 RF-Modul an ein ESP8266 an und schrieb eine Firmware, die:
- Eine Verbindung zu WiFi und MQTT herstellt
- Auf MQTT-Kommandos hört
- Den aufgezeichneten Bitstream auf 433,92 MHz überträgt
Der Schalter, den ich verwendet habe, ist dieser: Link zum Produkt
🧩 Nützliche Hardware: Das CC1101 Shield#
Um das Verdrahten einfach und sauber zu machen, habe ich das CC1101 Shield für Wemos D1 Mini verwendet. Es handelt sich um eine kleine PCB, die perfekt auf den D1 Mini passt und alles korrekt für das CC1101 routet — keine Jumper-Spaghetti, kein Raten.
Ich habe einfach die Gerber-Dateien vom GitHub-Repo genommen, sie an mein Lieblings-Board-Haus geschickt und es zusammengelötet. Es hat auf Anhieb perfekt funktioniert. Es ist immer schön, wenn Hardware tatsächlich funktioniert.
Sehr zu empfehlen, wenn du mit dem D1 Mini und 433 MHz arbeitest.
📸 Das Setup in Kürze#
Es ist ein tolles Beispiel dafür, wie alte RF-Technologie mit modernen Smart-Home-Plattformen verbunden werden kann — und das ganz ohne Wände aufreißen zu müssen.
💻 Code-Schnipsel#
Hier ist die Firmware, die ich verwendet habe, um alles zum Laufen zu bringen. Sie basiert auf dem ESP8266, verwendet IotWebConf für einfache Konfiguration über ein Captive Portal und PubSubClient für MQTT-Nachrichten. Dazu kommt noch ein bisschen Bit-Level-Zauberei, um das Signal im Rohmodus über das CC1101-Modul zu senden.
#include <Arduino.h>
#include <ELECHOUSE_CC1101_SRC_DRV.h>
#include <IotWebConf.h>
#include <IotWebConfUsing.h>
#include <PubSubClient.h>
#include <string.h>
#include <DNSServer.h>
#include <ESP8266WiFi.h>
// IotWebConf configuration
#define CONFIG_PARAM_MAX_LEN 128
#define CONFIG_VERSION "mqt2"
#define CONFIG_PIN 4 // D2 (config button/pin)
#define STATUS_PIN 16 // D0 (status LED)
const char deviceName[] = "redected";
const char apPassword[] = "redacted";
// MQTT configuration parameters
char mqttServerValue[CONFIG_PARAM_MAX_LEN];
char mqttUserNameValue[CONFIG_PARAM_MAX_LEN];
char mqttUserPasswordValue[CONFIG_PARAM_MAX_LEN];
char mqttTopicValue[CONFIG_PARAM_MAX_LEN];
// Set up DNS, web server, and MQTT client for IotWebConf
DNSServer dnsServer;
WebServer server(80);
WiFiClient net;
PubSubClient mqttClient(net);
// Set up IotWebConf and configuration parameter groups
IotWebConf iotWebConf(deviceName, &dnsServer, &server, apPassword, CONFIG_VERSION);
IotWebConfParameterGroup mqttGroup = IotWebConfParameterGroup("mqtt", "MQTT configuration");
IotWebConfTextParameter mqttServerParam = IotWebConfTextParameter("MQTT server", "mqttServer", mqttServerValue, CONFIG_PARAM_MAX_LEN);
IotWebConfTextParameter mqttUserNameParam = IotWebConfTextParameter("MQTT user", "mqttUser", mqttUserNameValue, CONFIG_PARAM_MAX_LEN);
IotWebConfPasswordParameter mqttUserPasswordParam = IotWebConfPasswordParameter("MQTT password", "mqttPass", mqttUserPasswordValue, CONFIG_PARAM_MAX_LEN);
IotWebConfTextParameter mqttTopicParam = IotWebConfTextParameter("MQTT Topic", "mqttTopic", mqttTopicValue, CONFIG_PARAM_MAX_LEN);
// Flag for scheduling a reset after configuration change
bool needReset = false;
// MQTT command topic (set after configuration is loaded)
String commandTopic;
// CC1101 configuration
#define GDO0_Pin 5 // Use GDO0 on pin 5
// Predefined binary message for first signal as a string of '0's and '1's
const char *binaryMessage = "10000000000000000000000000000000100010001000111011101110100011101000100011101110100011101000111010001000100011101000100010001110";
// Buffers to hold the packed data and their lengths
uint8_t dataBuffer[128];
int dataLength = 0;
// Converts a binary string ("0" and "1") to packed bytes (MSB-first)
int binaryStringToBytes(const char *binStr, uint8_t *output)
{
int len = strlen(binStr);
int byteCount = (len + 7) / 8;
memset(output, 0, byteCount);
for (int i = 0; i < len; i++)
{
int byteIndex = i / 8;
int bitIndex = 7 - (i % 8);
if (binStr[i] == '1')
{
output[byteIndex] |= (1 << bitIndex);
}
}
return byteCount;
}
// Minimal raw transmission using the CC1101 TX FIFO.
void sendRawData(uint8_t *txBuffer, uint8_t size)
{
ELECHOUSE_cc1101.SpiWriteBurstReg(CC1101_TXFIFO, txBuffer, size);
ELECHOUSE_cc1101.SpiStrobe(CC1101_SIDLE);
ELECHOUSE_cc1101.SpiStrobe(CC1101_STX);
// Wait for the transmission to complete using GDO0 as an indicator.
while (!digitalRead(GDO0_Pin))
{
yield();
}
while (digitalRead(GDO0_Pin))
{
yield();
}
ELECHOUSE_cc1101.SpiStrobe(CC1101_SFTX);
}
// MQTT callback: when a message is received, check the command and trigger the corresponding transmission.
void mqttCallback(char *topic, byte *payload, unsigned int length)
{
Serial.print("MQTT message received on topic: ");
Serial.println(topic);
// Convert payload to a String
String message;
for (unsigned int i = 0; i < length; i++)
{
message += (char)payload[i];
}
Serial.print("Message: ");
Serial.println(message);
// Check for command and trigger the first signal transmission.
if (String(topic) == commandTopic)
{
if (message == "transmit_01")
{
Serial.println("Transmitting first signal via CC1101...");
sendRawData(dataBuffer, dataLength);
}
else
{
Serial.println("Unknown command received.");
}
}
}
// Called when configuration is saved via the captive portal; schedules a reboot.
void configSaved()
{
Serial.println("Configuration was updated.");
needReset = true;
}
// Validate the IotWebConf configuration form.
bool formValidator(iotwebconf::WebRequestWrapper *webRequestWrapper)
{
Serial.println("Validating form.");
bool valid = true;
int l = webRequestWrapper->arg(mqttServerParam.getId()).length();
if (l < 3)
{
mqttServerParam.errorMessage = "Please provide at least 3 characters!";
valid = false;
}
l = webRequestWrapper->arg(mqttUserNameParam.getId()).length();
if (l < 1)
{
mqttUserNameParam.errorMessage = "Please provide a username!";
valid = false;
}
l = webRequestWrapper->arg(mqttUserPasswordParam.getId()).length();
if (l < 1)
{
mqttUserPasswordParam.errorMessage = "Please provide a password!";
valid = false;
}
l = webRequestWrapper->arg(mqttTopicParam.getId()).length();
if (l < 3)
{
mqttTopicParam.errorMessage = "Please provide at least 3 characters!";
valid = false;
}
return valid;
}
// Attempt to connect to the MQTT broker and subscribe to the command topic.
void connectMqtt()
{
Serial.print("Attempting MQTT connection...");
String clientId = iotWebConf.getThingName();
clientId += "-";
clientId += String(random(0xffff), HEX);
// Use the username and password for MQTT authentication.
if (mqttClient.connect(clientId.c_str(), mqttUserNameValue, mqttUserPasswordValue))
{
Serial.println(" connected");
// Publish connection announcement on a status topic.
String statusTopic = String(mqttTopicValue) + "/status/connection";
mqttClient.publish(statusTopic.c_str(), "connected");
// Define and subscribe to the command topic.
commandTopic = String(mqttTopicValue) + "/command";
mqttClient.subscribe(commandTopic.c_str());
Serial.print("Subscribed to command topic: ");
Serial.println(commandTopic);
}
else
{
Serial.print(" failed, state: ");
Serial.println(mqttClient.state());
delay(5000);
}
}
void setup()
{
Serial.begin(115200);
delay(500);
// Convert the binary string into packed bytes.
dataLength = binaryStringToBytes(binaryMessage, dataBuffer);
Serial.print("First signal data length (bytes): ");
Serial.println(dataLength);
// Initialize the CC1101 module.
if (!ELECHOUSE_cc1101.getCC1101())
{
Serial.println("CC1101 Connection Error!");
while (1)
{
delay(1000);
}
}
Serial.println("CC1101 Connection OK");
ELECHOUSE_cc1101.Init();
pinMode(GDO0_Pin, INPUT);
ELECHOUSE_cc1101.setGDO0(GDO0_Pin);
ELECHOUSE_cc1101.setCCMode(1); // Advanced/raw mode
ELECHOUSE_cc1101.setModulation(2); // ASK (OOK)
ELECHOUSE_cc1101.setMHZ(433.92); // Frequency (adjust as needed)
ELECHOUSE_cc1101.setSyncMode(0); // Disable sync word
ELECHOUSE_cc1101.setCrc(0); // Disable CRC
ELECHOUSE_cc1101.setDRate(30); // Data rate in kbps
ELECHOUSE_cc1101.SpiWriteReg(CC1101_PKTCTRL0, 0x00); // Disable preamble/sync/length
Serial.println("TX Mode Initialized. Ready to transmit.");
// Initialize IotWebConf (captive portal and configuration)
pinMode(CONFIG_PIN, INPUT_PULLUP);
iotWebConf.setStatusPin(STATUS_PIN);
iotWebConf.setConfigPin(CONFIG_PIN);
mqttGroup.addItem(&mqttServerParam);
mqttGroup.addItem(&mqttUserNameParam);
mqttGroup.addItem(&mqttUserPasswordParam);
mqttGroup.addItem(&mqttTopicParam);
iotWebConf.addParameterGroup(&mqttGroup);
iotWebConf.setConfigSavedCallback(&configSaved);
iotWebConf.setFormValidator(&formValidator);
bool validConfig = iotWebConf.init();
if (!validConfig)
{
// Clear saved configuration if it is invalid.
mqttServerValue[0] = '\0';
mqttUserNameValue[0] = '\0';
mqttUserPasswordValue[0] = '\0';
mqttTopicValue[0] = '\0';
}
// Disable AP timeout so the captive portal is always available when needed.
iotWebConf.setApTimeoutMs(0);
server.on("/", []()
{ iotWebConf.handleConfig(); });
server.onNotFound([]()
{ iotWebConf.handleNotFound(); });
// Set up MQTT client with the configured server and our callback.
mqttClient.setServer(mqttServerValue, 1883);
mqttClient.setCallback(mqttCallback);
}
void loop()
{
iotWebConf.doLoop();
if (needReset)
{
Serial.println("Rebooting after 1 second.");
iotWebConf.delay(1000);
ESP.restart();
}
// If WiFi is connected and MQTT is not, try to connect to the broker.
if ((iotWebConf.getState() == iotwebconf::OnLine) && !mqttClient.connected())
{
connectMqtt();
}
mqttClient.loop();
}