- [x] Scan/Copy
+- [x] [Wireless M-Bus: passive T1/C1 reception](docs/wireless-mbus.md)
- [x] [Custom SubGhz](https://wiki.bruce.computer/features/rf/#replay-payloads-like-flipper)
- [x] Spectrum
- [x] Jammer Full (sends a full squared wave into output)
diff --git a/docs/wireless-mbus.de.md b/docs/wireless-mbus.de.md
new file mode 100644
index 0000000000..61d788dacb
--- /dev/null
+++ b/docs/wireless-mbus.de.md
@@ -0,0 +1,162 @@
+# Wireless M-Bus für Bruce
+
+[English instructions](wireless-mbus.md)
+
+Diese experimentelle Erweiterung ergänzt das RF-Menü von Bruce um einen rein
+empfangenden Wireless-M-Bus-Empfänger für den CC1101. Sie sendet nicht, nimmt keine
+Schlüssel entgegen und entschlüsselt keine geschützten Zählerwerte.
+Es ist eine native Firmware-Erweiterung, kein JavaScript zum Kopieren auf die
+SD-Karte. Die vorhandene Bruce-JavaScript-API stellt den benötigten Paketempfang
+nicht bereit.
+
+## Wofür Wireless M-Bus verwendet wird
+
+Wireless M-Bus (wM-Bus, EN 13757-4) überträgt Zählerdaten per Funk, zum Beispiel
+von Wasser-, Gas-, Wärme- und Stromzählern oder Heizkostenverteilern zu einem
+Lesegerät oder Gateway. Dadurch lassen sich Verbrauchsdaten aus der Ferne erfassen.
+
+Es ist besonders in Europa verbreitet. Regionale Profile und Frequenzbänder
+unterscheiden sich; diese Implementierung empfängt gezielt T1/C1 auf 868,95 MHz.
+Siehe die [M-Bus-Übersicht](https://m-bus.com/) und
+[TIs Wireless-M-Bus-Übersicht](https://www.ti.com/en-us/technologies/wired-wireless-connectivity/wireless-m-bus).
+
+## Hardware und Teststand
+
+- Getestet wurde ein **LilyGO T-Embed CC1101 Plus** mit dem Build-Ziel
+ `lilygo-t-embed-cc1101`. Ein eigenes Plus-Ziel gibt es in diesem Quellstand nicht.
+- Implementiert sind **T1 und C1 auf 868,95 MHz**, bei C1 die Frameformate A und B.
+- Der Gerätebesitzer hat am **11. November 2026** einen sehr gut funktionierenden
+ Funkempfang bestätigt. Damit sind noch nicht alle Modi, Zählertypen,
+ Datenfelder und SD-Funktionen einzeln geprüft.
+- Andere Boards und externe CC1101-Aufbauten wurden hier nicht auf Hardware getestet.
+- Für Decoder und Empfänger gibt es lokale C++-Tests; siehe unten.
+
+## Firmware bauen und installieren
+
+Baue den Checkout, der diese Erweiterung enthält. Die ursprüngliche Entwicklungsbasis ist
+der offizielle Bruce-Commit `a59213f3ec6cd302fa32c2042b2700c9c24d991a`.
+
+Der lokale Build wurde mit **Python 3.13**, **PlatformIO 6.2.0** und dem Ziel
+`lilygo-t-embed-cc1101` durchgeführt. Der Branch enthält außerdem die für den
+lokalen Windows-Build benötigte Festlegung auf FastLED 3.10.3 und eine Korrektur
+am objcopy-Build-Hook.
+
+Beispiel für Windows PowerShell im Repository-Verzeichnis, mit verfügbarem Python 3.13:
+
+```powershell
+py -3.13 -m venv .tools\venv
+$env:PLATFORMIO_CORE_DIR = "$PWD\.tools\platformio"
+$env:PLATFORMIO_CACHE_DIR = "$PWD\.tools\platformio-cache"
+$env:TEMP = "$PWD\.tools\tmp"
+$env:TMP = $env:TEMP
+New-Item -ItemType Directory -Path $env:TEMP -Force | Out-Null
+& .\.tools\venv\Scripts\python.exe -m pip install platformio==6.2.0 --cache-dir .tools\pip-cache
+& .\.tools\venv\Scripts\python.exe -m platformio run -e lilygo-t-embed-cc1101
+```
+
+Danach liegen das reine App-Image unter
+`.pio/build/lilygo-t-embed-cc1101/firmware.bin` und das zusammengefügte Komplettimage
+`Bruce-lilygo-t-embed-cc1101.bin` im Repository-Hauptverzeichnis.
+Für eine normale Installation als einzige Firmware auf dem ESP32-S3 wird das
+Komplettimage mit esptool ab Adresse **`0x0`** geschrieben. Das reine App-Image darf
+**nicht** an Adresse `0x0` geschrieben werden. Wähle den COM-Port deines Geräts.
+
+Beispiel zum Flashen als einzelne Firmware:
+
+```powershell
+& .\.tools\venv\Scripts\python.exe -m pip install esptool --cache-dir .tools\pip-cache
+$devicePort = 'COM7' # Durch den COM-Port deines Geräts ersetzen.
+& .\.tools\venv\Scripts\python.exe -m esptool --chip esp32s3 --port $devicePort write-flash 0x0 Bruce-lilygo-t-embed-cc1101.bin
+```
+
+Das Ende des Schreibvorgangs und die Datenprüfung abwarten. Anschließend das
+RF-Menü auf dem Gerät öffnen.
+
+Das Komplettimage enthält Bootloader, Partitionstabelle und Anwendung für Bruce
+als einzelne Firmware auf dem ESP32-S3 mit 16 MB Flash. Beim dokumentierten
+Hardwaretest wurde der interne Flash gelöscht und Bruce anschließend neu installiert.
+
+Beim Build bleiben Warnungen aus vorhandenem Bruce-, Display-, Bluetooth- und
+Audiocode bestehen. Ein erfolgreicher Build bestätigt nicht alle Funktionen der
+gesamten Firmware.
+
+## Bedienung
+
+1. Falls erforderlich unter **Radio Frequency > Config** den CC1101 als RF-Modul auswählen.
+2. **Radio Frequency > Wireless M-Bus > Scan T1/C1 868.95** öffnen.
+3. Auf vollständige Telegramme mit gültigen Link-Layer-Prüfsummen warten.
+ Die Sendeintervalle eines Zählers können lang sein.
+4. Mit dem Drehrad ein Gerät auswählen und durch Klicken die Details öffnen.
+5. In den Details mit dem Drehrad scrollen. Währenddessen pausiert der Empfang.
+ Klicken oder Zurück setzt ihn fort.
+6. Zurück in der Scanansicht stoppt den Empfang und öffnet das App-Menü.
+7. **Export devices to SD** speichert die Geräteübersicht; **Clear results** leert
+ sie. Beim Verlassen der App endet die Sitzung und die RAM-Liste wird verworfen.
+
+Die Liste enthält höchstens **96 unterschiedliche Geräte**. Gruppiert wird nach
+Hersteller und vollständiger Link-Layer-Adresse aus ID, Version und Typ.
+Angezeigt werden unter anderem unterstützte Header, RSSI, LQI, Telegrammzahl und
+Empfangszeit seit Gerätestart. Bekannte numerische DIF/VIF-Datensätze werden nur
+bei ausdrücklich unverschlüsselten Anwendungsdaten interpretiert.
+Unbekannte Formate bleiben als Rohdaten sichtbar. Der Hexdump kann auch
+verschlüsselte Bytes enthalten und ist entsprechend beschriftet.
+
+## SD-Export
+
+Die Dateien heißen `/wmbus/devices--.jsonl`. Jede JSON-Zeile
+enthält ein Gerät mit seinem letzten gültigen Telegramm und Metadaten. Das ist
+eine Momentaufnahme, keine lückenlose Telegrammaufzeichnung.
+Bei `frame_hex` sind die DLL-Prüfsummen entfernt und das L-Byte normalisiert;
+es handelt sich nicht um eine unveränderte Aufzeichnung des Funksignals.
+Der ursprüngliche L-Wert bleibt in `wire_l` erhalten. Zeiten sind Millisekunden
+seit Gerätestart, keine Uhrzeiten.
+
+## Grenzen und Rückmeldungen
+
+Unterstützt werden nur die genannten Modi und die genannte Frequenz. S-, R- und
+N-Modi, 169 MHz und andere Frequenzen sind nicht implementiert. Geschützte Werte
+bleiben geschützt; Herstellerformate, Kompakttelegramme und nicht unterstützte
+Datensätze lassen sich nicht universell interpretieren.
+Display, SD und Funkmodul teilen sich einen SPI-Bus. Bei Bildschirmupdates sind
+Empfangslücken möglich. Ungültige Prüfsummen, unvollständige Telegramme und
+FIFO-Überläufe werden verworfen. Die Tabelle ist auf 96 Geräte begrenzt.
+Eine gültige CRC ist keine Authentifizierung des Senders.
+
+Für weitere Tests sind besonders hilfreich: Vergleich von T1/C1-A/C1-B mit einem
+Referenzempfänger, wiederholte Telegramme, offene und geschützte Datensätze,
+SD-Export samt Fehlerfällen, Bedienung und andere RF-Apps nach dem Verlassen.
+Bei Rückmeldungen bitte Board, Firmware-Commit, bekannten Empfangsmodus und
+Schritte zum Nachstellen nennen. Geräte-IDs und persönliche Verbrauchsdaten in
+öffentlichen Beispielen unkenntlich machen.
+
+## Lokale Tests und Quellen
+
+Mit Python 3 und einem GCC-/Clang-C++17-Compiler mit UndefinedBehaviorSanitizer:
+
+```sh
+python tests/wmbus/run.py
+```
+
+Mit `--cxx /pfad/zum/compiler` lässt sich ein anderer Compiler wählen.
+Alternativ das Python-Paket `ziglang` in einer lokalen virtuellen Umgebung
+installieren und `python tests/wmbus/run.py --zig` ausführen. Dieser Weg wurde
+für die lokalen Windows-Tests verwendet. Ausgaben liegen unter
+`.pio/wmbus-host-tests` und gehören nicht zum Quellcode.
+
+Geprüft werden CRC, T1-/C1-A/B-Framing, beschädigte und abgeschnittene Telegramme,
+Verschlüsselungsgrenzen, 20.000 Zufallseingaben, FIFO-Stückelung, lange Frames,
+Erholung nach Überlauf/Timeout und Wiederherstellung der Funkregister.
+Der Test mit simuliertem CC1101 übersetzt die tatsächliche Receiver-Implementierung;
+er prüft weder Funkausbreitung noch Benutzeroberfläche.
+
+Referenzen: [TI AN067](https://www.ti.com/lit/an/swra234a/swra234a.pdf),
+[TI C/T-Framing](https://www.ti.com/lit/an/swra522d/swra522d.pdf),
+[CC1101-Datenblatt](https://www.ti.com/lit/ds/symlink/cc1101.pdf),
+[CC1101-Errata](https://www.ti.com/lit/er/swrz020e/swrz020e.pdf),
+[wMbus-lib](https://github.com/SzczepanLeon/wMbus-lib),
+[wmbusmeters](https://github.com/wmbusmeters/wmbusmeters) und
+[libmbus](https://github.com/rscada/libmbus).
+Die Quellen dienten dem Abgleich von Funkparametern und Telegrammformaten;
+es wird keine Entschlüsselungsbibliothek eingebunden. Die Implementierung
+entstand mit Unterstützung von Codex. Lizenz: AGPL-3.0-or-later;
+siehe [LICENSE](../LICENSE).
diff --git a/docs/wireless-mbus.md b/docs/wireless-mbus.md
new file mode 100644
index 0000000000..d02bf53b60
--- /dev/null
+++ b/docs/wireless-mbus.md
@@ -0,0 +1,143 @@
+# Wireless M-Bus for Bruce
+
+[Deutsche Anleitung](wireless-mbus.de.md)
+
+This experimental native RF app adds passive Wireless M-Bus reception through
+the CC1101. It does not transmit, accept keys, or decrypt protected meter values.
+It is a firmware extension, not a JavaScript app that can be copied to an SD card.
+Bruce's current JavaScript interface does not provide the packet reception needed here.
+
+## What Wireless M-Bus is used for
+
+Wireless M-Bus (wM-Bus, EN 13757-4) carries meter readings over radio, for example
+from water, gas, heat and electricity meters or heat-cost allocators to a reader
+or gateway. This enables remote collection of consumption data.
+
+It is particularly widespread in Europe. Regional profiles and frequency bands
+vary; this implementation specifically receives T1/C1 at 868.95 MHz.
+See the [M-Bus overview](https://m-bus.com/) and
+[TI's Wireless M-Bus overview](https://www.ti.com/en-us/technologies/wired-wireless-connectivity/wireless-m-bus).
+
+## Hardware and validation
+
+- Tested hardware: LilyGO T-Embed CC1101 Plus, using the
+ `lilygo-t-embed-cc1101` build target. This source has no separate Plus target.
+- T1 and C1 at 868.95 MHz; C1 frame formats A and B are implemented.
+- On 11 November 2026, the device owner reported very good reception on this board.
+ This does not separately verify every mode, meter type, data field or SD operation.
+- Other boards and external CC1101 arrangements have not been tested by this project.
+- The implemented receiver and decoder have host tests; see below.
+
+## Build and installation
+
+Build the checkout containing this feature. The original development base is
+`a59213f3ec6cd302fa32c2042b2700c9c24d991a`.
+
+The tested local build used Python 3.13, PlatformIO 6.2.0 and the
+`lilygo-t-embed-cc1101` environment. The branch also includes the FastLED 3.10.3
+pin and an objcopy build-hook compatibility fix needed by the local Windows build.
+
+One Windows PowerShell setup, run from the repository root with Python 3.13 available:
+
+```powershell
+py -3.13 -m venv .tools\venv
+$env:PLATFORMIO_CORE_DIR = "$PWD\.tools\platformio"
+$env:PLATFORMIO_CACHE_DIR = "$PWD\.tools\platformio-cache"
+$env:TEMP = "$PWD\.tools\tmp"
+$env:TMP = $env:TEMP
+New-Item -ItemType Directory -Path $env:TEMP -Force | Out-Null
+& .\.tools\venv\Scripts\python.exe -m pip install platformio==6.2.0 --cache-dir .tools\pip-cache
+& .\.tools\venv\Scripts\python.exe -m platformio run -e lilygo-t-embed-cc1101
+```
+
+The build creates the app at `.pio/build/lilygo-t-embed-cc1101/firmware.bin`
+and the merged image `Bruce-lilygo-t-embed-cc1101.bin` in the repository root.
+For a normal standalone ESP32-S3 installation, write the merged image at address
+`0x0` using esptool. Never write the app-only image at `0x0`.
+Choose the COM port that belongs to your device.
+
+A standalone flash example is:
+
+```powershell
+& .\.tools\venv\Scripts\python.exe -m pip install esptool --cache-dir .tools\pip-cache
+$devicePort = 'COM7' # Replace with your device's port.
+& .\.tools\venv\Scripts\python.exe -m esptool --chip esp32s3 --port $devicePort write-flash 0x0 Bruce-lilygo-t-embed-cc1101.bin
+```
+
+Wait for the write verification to finish and then open the RF menu on the device.
+
+The full image contains the bootloader, partition table and application for
+standalone Bruce on an ESP32-S3 with 16 MB flash. The documented hardware test
+used an internal-flash erase followed by a fresh Bruce installation.
+
+The build still reports warnings in existing Bruce/display/Bluetooth/audio code.
+Successful compilation is not evidence that every existing firmware function works.
+
+## Use
+
+1. In **Radio Frequency > Config**, select the CC1101 RF module if necessary.
+2. Open **Radio Frequency > Wireless M-Bus > Scan T1/C1 868.95**.
+3. Wait for complete telegrams with valid link-layer CRCs. Meter intervals can be long.
+4. Use the wheel to select a device; click to open its details.
+5. Scroll through the details. Reception pauses in this view; click or go back to resume.
+6. Go back from scanning to stop reception and return to the app menu.
+7. Select **Export devices to SD** to save the device overview, or **Clear results**
+ to reset it. Leaving the app discards the in-memory session.
+
+The list holds up to 96 distinct devices, grouped by manufacturer and complete
+link-layer address (ID, version and type). Details include supported headers,
+RSSI, LQI, count and reception time since boot. Known numeric DIF/VIF records
+are interpreted only when the application data is explicitly unencrypted.
+Unknown formats remain raw bytes. A hex dump may include ciphertext and is labelled accordingly.
+
+## SD export
+
+Files are written as `/wmbus/devices--.jsonl`, with one JSON object
+per device containing its latest valid telegram and metadata. This is a snapshot,
+not continuous packet logging. `frame_hex` omits DLL CRC bytes and normalizes the
+L byte; it is not a byte-for-byte recording of the on-air signal. The original L
+value is retained in `wire_l`. Timestamps are milliseconds since boot.
+
+## Limits and feedback
+
+Only the stated modes and frequency are supported. S/R/N modes, 169 MHz and other
+frequencies are outside this implementation. Encrypted values remain encrypted;
+manufacturer-specific, compact and unsupported records are not universally decoded.
+The shared display/SD/radio SPI bus can cause reception gaps during display updates.
+Invalid CRCs, truncated packets and FIFO overflows are discarded. The device table
+is limited to 96 entries. A valid CRC does not authenticate the sender.
+
+Further testing should cover T1/C1-A/C1-B against a reference receiver, repeated
+telegram counts, unencrypted and protected records, SD export/error handling,
+navigation and use of other RF apps after exiting. When reporting a result,
+include board, firmware commit, mode if known, and steps to reproduce; redact meter
+IDs and personal consumption data from public examples.
+
+## Host tests and sources
+
+With Python 3 and a GCC/Clang C++17 compiler supporting UndefinedBehaviorSanitizer:
+
+```sh
+python tests/wmbus/run.py
+```
+
+Use `--cxx /path/to/compiler` to select a compiler. Alternatively, install the
+`ziglang` Python package in a local virtual environment and run
+`python tests/wmbus/run.py --zig` (the local Windows tests use this route).
+Outputs go under `.pio/wmbus-host-tests` and are not source files.
+
+Tests cover CRC, T1/C1 A/B framing, corruption, truncation, encryption boundaries,
+20,000 randomized inputs, FIFO fragmentation, long frames, overflow/timeout
+recovery and radio-register restoration. The fake-radio test compiles the actual
+production Receiver implementation with a simulated CC1101; it does not test RF propagation or the UI.
+
+Protocol references: [TI AN067](https://www.ti.com/lit/an/swra234a/swra234a.pdf),
+[TI C/T framing](https://www.ti.com/lit/an/swra522d/swra522d.pdf),
+[CC1101 datasheet](https://www.ti.com/lit/ds/symlink/cc1101.pdf),
+[CC1101 errata](https://www.ti.com/lit/er/swrz020e/swrz020e.pdf),
+[wMbus-lib](https://github.com/SzczepanLeon/wMbus-lib),
+[wmbusmeters](https://github.com/wmbusmeters/wmbusmeters), and
+[libmbus](https://github.com/rscada/libmbus).
+These projects were consulted for radio settings and format definitions; no
+decryption library is integrated. The implementation was developed with Codex
+assistance. Code is distributed under AGPL-3.0-or-later; see [LICENSE](../LICENSE).
diff --git a/patch.py b/patch.py
index f8538e5dbe..0d2671b622 100644
--- a/patch.py
+++ b/patch.py
@@ -1,6 +1,7 @@
import hashlib
from typing import TYPE_CHECKING, Any
import requests
+import subprocess
if TYPE_CHECKING:
Import: Any = None
@@ -33,10 +34,21 @@
elif mcu=="esp32p4":
"""Do nothing"""
else:
- env.Execute(
- "pio pkg exec -p toolchain-xtensa-%s -- xtensa-%s-elf-objcopy --weaken-symbol=ieee80211_raw_frame_sanity_check %s %s"
- % (mcu, mcu, original_file, patched_file)
- )
+ # Current pioarduino uses one Xtensa package for all three chips.
+ # Calling the old package name through an external `pio` can fail on
+ # fresh Windows installations; fail before renaming any input archive.
+ toolchain_dir = env.PioPlatform().get_package_dir("toolchain-xtensa-esp-elf")
+ if not toolchain_dir:
+ toolchain_dir = env.PioPlatform().get_package_dir("toolchain-xtensa-%s" % mcu)
+ if not toolchain_dir:
+ raise RuntimeError("Xtensa toolchain not found")
+ objcopy = join(toolchain_dir, "bin", "xtensa-%s-elf-objcopy" % mcu)
+ if isfile(objcopy + ".exe"):
+ objcopy += ".exe"
+ subprocess.run([
+ objcopy, "--weaken-symbol=ieee80211_raw_frame_sanity_check",
+ original_file, patched_file,
+ ], check=True)
if isfile("%s.old" % (original_file)):
remove("%s.old" % (original_file))
diff --git a/platformio.ini b/platformio.ini
index 49d7584766..fb2a2d7129 100644
--- a/platformio.ini
+++ b/platformio.ini
@@ -202,7 +202,7 @@ lib_deps =
bitbank2/AnimatedGIF
bitbank2/PNGdec @ ^1.1.2
ESP32Async/ESPAsyncWebServer
- fastled/FastLED @^3.10.3
+ fastled/FastLED @3.10.3
jgromes/RadioLib @ ^7.4.0
; Pinned to fixed commits so upstream changes never break our patches
; (applied by patch_library_conflicts.py): IRQ-less I2C polling for
diff --git a/src/core/menu_items/RFMenu.cpp b/src/core/menu_items/RFMenu.cpp
index f283a197f8..d7558be104 100644
--- a/src/core/menu_items/RFMenu.cpp
+++ b/src/core/menu_items/RFMenu.cpp
@@ -11,12 +11,14 @@
#include "modules/rf/rf_utils.h"
#include "modules/rf/rf_spectrum.h"
#include "modules/rf/rf_waterfall.h"
+#include "modules/rf/rf_wmbus.h"
void RFMenu::optionsMenu() {
options = {
{"Scan/copy", [=]() { RFScan(); } },
#if !defined(LITE_VERSION)
{"Record RAW", rf_raw_record }, // Pablo-Ortiz-Lopez
+ {"Wireless M-Bus", rf_wmbus },
{"Custom SubGhz", sendCustomRF },
#endif
{"Spectrum", rf_spectrum },
diff --git a/src/modules/rf/rf_wmbus.cpp b/src/modules/rf/rf_wmbus.cpp
new file mode 100644
index 0000000000..07fb277401
--- /dev/null
+++ b/src/modules/rf/rf_wmbus.cpp
@@ -0,0 +1,297 @@
+// SPDX-License-Identifier: AGPL-3.0-or-later
+#include "rf_wmbus.h"
+#include "wmbus_codec.h"
+#include "rf_utils.h"
+#include "core/display.h"
+#include "core/utils.h"
+#include
+
+namespace {
+constexpr size_t MAX_DEVICES = 96;
+struct Device {
+ wmbus::Frame frame;
+ uint32_t count = 0, first = 0, last = 0;
+};
+struct Session {
+ std::vector devices;
+ uint32_t valid = 0, rejected = 0, overflows = 0, timeouts = 0, tableFull = 0;
+};
+
+// Packet FIFO mode, 100 kchip/s 2-FSK, 325 kHz BW, sync 0x543D.
+// CC1101 parameters: TI AN067 / wMbus-lib. C/T framing: TI SWRA522
+// (a CC13xx application note, used here only for the on-air discriminator).
+// Packet control differs from the TI example: no appended status bytes,
+// infinite reception with a software length limit, no auto TX.
+const uint8_t preset[][2] = {
+ {0x00,0x06}, {0x01,0x2E}, {0x02,0x00}, {0x03,0x07},
+ {0x04,0x54}, {0x05,0x3D}, {0x06,0xFF}, {0x07,0x00},
+ {0x08,0x02}, {0x09,0x00}, {0x0A,0x00}, {0x0B,0x08},
+ {0x0C,0x00}, {0x0D,0x21}, {0x0E,0x6B}, {0x0F,0xD0},
+ {0x10,0x5C}, {0x11,0x04}, {0x12,0x06}, {0x13,0x22},
+ {0x14,0xF8}, {0x15,0x44}, {0x16,0x07}, {0x17,0x00},
+ {0x18,0x18}, {0x19,0x2E}, {0x1A,0xBF}, {0x1B,0x43},
+ {0x1C,0x09}, {0x1D,0xB5}, {0x21,0xB6}, {0x22,0x10},
+ {0x23,0xEA}, {0x24,0x2A}, {0x25,0x00}, {0x26,0x1F},
+ {0x2C,0x81}, {0x2D,0x35}, {0x2E,0x09}
+};
+
+class Receiver {
+ uint8_t saved[0x2F] = {};
+ uint8_t raw[435] = {};
+ size_t used = 0, expected = 0;
+ bool active = false, savedValid = false;
+ uint32_t started = 0;
+ bool waitState(uint8_t state) {
+ const uint32_t start = millis();
+ do {
+ if ((ELECHOUSE_cc1101.SpiReadStatus(CC1101_MARCSTATE) & 31) == state) return true;
+ delay(1);
+ } while (millis() - start < 30);
+ return false;
+ }
+ uint8_t fifoCount() {
+ // Status registers can be incoherent during updates (CC1101 errata).
+ uint8_t a = ELECHOUSE_cc1101.SpiReadStatus(CC1101_RXBYTES);
+ for (int i = 0; i < 4; ++i) {
+ const uint8_t b = ELECHOUSE_cc1101.SpiReadStatus(CC1101_RXBYTES);
+ if (a == b) return b;
+ a = b;
+ }
+ return 0; // No coherent sample yet: retry on the next poll, never guess a count.
+ }
+public:
+ bool begin() {
+ if (bruceConfigPins.rfModule != CC1101_SPI_MODULE) {
+ displayError("Select CC1101 in RF Config", true);
+ return false;
+ }
+ // Uses Bruce's board pin configuration, SPI bus and antenna switch.
+ if (!initRfModule("rx", 868.95f)) { deinitRfModule(); return false; }
+ active = true;
+ if (!ELECHOUSE_cc1101.SpiStrobe(CC1101_SIDLE) || !waitState(1)) { end(); return false; }
+ for (uint8_t i = 0; i < sizeof(saved); ++i) saved[i] = ELECHOUSE_cc1101.SpiReadReg(i);
+ savedValid = true;
+ for (const auto &r : preset) {
+ if (!ELECHOUSE_cc1101.SpiWriteReg(r[0], r[1])) { end(); return false; }
+ }
+ if (!ELECHOUSE_cc1101.SpiStrobe(CC1101_SCAL) || !waitState(1)) { end(); return false; }
+ return restart();
+ }
+ bool restart() {
+ used = expected = 0;
+ started = 0;
+ if (!ELECHOUSE_cc1101.SpiStrobe(CC1101_SIDLE) || !waitState(1)) return false;
+ if (!ELECHOUSE_cc1101.SpiStrobe(CC1101_SFRX) ||
+ !ELECHOUSE_cc1101.SpiWriteReg(CC1101_PKTCTRL0, 0x02) ||
+ !ELECHOUSE_cc1101.SpiWriteReg(CC1101_PKTLEN, 0xFF) ||
+ !ELECHOUSE_cc1101.SpiStrobe(CC1101_SRX)) return false;
+ return waitState(13);
+ }
+ void end() {
+ if (!active) return;
+ ELECHOUSE_cc1101.SpiStrobe(CC1101_SIDLE);
+ waitState(1);
+ ELECHOUSE_cc1101.SpiStrobe(CC1101_SFRX);
+ if (savedValid)
+ for (uint8_t i = 0; i < sizeof(saved); ++i) ELECHOUSE_cc1101.SpiWriteReg(i, saved[i]);
+ deinitRfModule();
+ active = false;
+ savedValid = false;
+ }
+ ~Receiver() { end(); }
+ bool busy() { return used != 0 || (fifoCount() & 0x7F) != 0; }
+ // -1 = unrecoverable receiver state, 0 = pending, 1 = validated frame.
+ int poll(wmbus::Frame &frame, Session &stats) {
+ const uint8_t status = fifoCount();
+ if (status & 0x80) {
+ ++stats.overflows;
+ return restart() ? 0 : -1;
+ }
+ const size_t available = status & 0x7F;
+ const uint8_t state = ELECHOUSE_cc1101.SpiReadStatus(CC1101_MARCSTATE) & 31;
+ if (!started && available) started = millis();
+ if (started && millis() - started > 80) {
+ ++stats.timeouts;
+ return restart() ? 0 : -1;
+ }
+ if (available) {
+ // Leave one byte while RX is running (CC1101 RX FIFO erratum).
+ // Stop explicitly once the final bytes are buffered. Infinite mode
+ // avoids the 8-bit hardware packet counter limit for long T1 frames.
+ const bool final = expected && available >= expected - used;
+ if (final) {
+ if (!ELECHOUSE_cc1101.SpiStrobe(CC1101_SIDLE) || !waitState(1)) return -1;
+ }
+ size_t n = final ? expected - used : state == 1 ? available : available - 1;
+ // First establish length before consuming a potentially corrupt frame.
+ if (!expected) n = std::min(n, size_t(3) - used);
+ if (used + n > sizeof(raw) || (expected && used + n > expected)) {
+ ++stats.rejected;
+ return restart() ? 0 : -1;
+ }
+ if (n) {
+ if (!ELECHOUSE_cc1101.SpiReadBurstReg(CC1101_RXFIFO, raw + used, uint8_t(n))) return -1;
+ used += n;
+ }
+ if (!expected && used >= 3) {
+ wmbus::Mode mode;
+ if (!wmbus::packetSize(raw, used, expected, mode)) {
+ ++stats.rejected;
+ return restart() ? 0 : -1;
+ }
+ }
+ }
+ if (expected && used == expected) {
+ const bool ok = wmbus::decode(raw, used, frame);
+ const int r = ELECHOUSE_cc1101.SpiReadStatus(CC1101_RSSI);
+ frame.rssi = (r >= 128 ? r - 256 : r) / 2 - 74;
+ frame.lqi = ELECHOUSE_cc1101.SpiReadStatus(CC1101_LQI) & 0x7F;
+ if (!ok) ++stats.rejected;
+ if (!restart()) return -1;
+ return ok ? 1 : 0;
+ }
+ return 0;
+ }
+};
+
+void remember(Session &s, const wmbus::Frame &frame) {
+ ++s.valid;
+ const auto key = wmbus::deviceKey(frame);
+ for (auto &d : s.devices) {
+ if (wmbus::deviceKey(d.frame) == key) {
+ d.frame = frame; ++d.count; d.last = millis(); return;
+ }
+ }
+ if (s.devices.size() == MAX_DEVICES) { ++s.tableFull; return; }
+ Device d;
+ d.frame = frame; d.count = 1; d.first = d.last = millis();
+ s.devices.push_back(d);
+}
+
+int rows() { return std::max(1, (int(tftHeight) - 40) / 10); }
+void screen(const char *title) {
+ tft.fillScreen(bruceConfig.bgColor);
+ tft.setTextSize(1);
+ tft.setTextColor(bruceConfig.priColor, bruceConfig.bgColor);
+ tft.setCursor(4, 3); tft.print(title);
+}
+void drawList(const Session &s, size_t selected) {
+ screen("Wireless M-Bus T1/C1 868.95 MHz");
+ tft.setCursor(4, 15);
+ tft.printf("Devices:%u RX:%lu Bad:%lu OV:%lu", unsigned(s.devices.size()),
+ (unsigned long)s.valid, (unsigned long)s.rejected, (unsigned long)s.overflows);
+ if (s.devices.empty()) {
+ tft.setCursor(4, 40); tft.print("Waiting for valid telegrams...");
+ tft.setCursor(4, 54); tft.print("Meters may transmit infrequently.");
+ }
+ const size_t start = selected / rows() * rows();
+ for (size_t i = start; i < s.devices.size() && i < start + rows(); ++i) {
+ const auto &d = s.devices[i];
+ tft.setCursor(4, 28 + int(i - start) * 10);
+ tft.printf("%c %s %s %4d dBm #%lu", i == selected ? '>' : ' ',
+ wmbus::identity(d.frame).c_str(), wmbus::manufacturer(d.frame).c_str(),
+ d.frame.rssi, (unsigned long)d.count);
+ }
+ tft.setCursor(4, tftHeight - 10);
+ tft.print(s.tableFull ? "Table full! Esc: stop/export" : "Wheel: select Click: detail Esc: stop");
+}
+void details(const Device &d) {
+ auto source = wmbus::describe(d.frame);
+ source.insert(source.begin(), "Packets: " + std::to_string(d.count) + " Last boot ms: " + std::to_string(d.last));
+ std::vector lines;
+ const size_t columns = std::max(8, (int(tftWidth) - 8) / 6);
+ for (const auto &line : source) {
+ for (size_t p = 0; p < line.size(); p += columns) lines.push_back(line.substr(p, columns));
+ }
+ size_t top = 0;
+ bool redraw = true;
+ while (!returnToMenu) {
+ if (redraw) {
+ screen("Wireless M-Bus details (RX paused)");
+ for (size_t i = top; i < lines.size() && i < top + rows(); ++i) {
+ tft.setCursor(4, 24 + int(i - top) * 10); tft.print(lines[i].c_str());
+ }
+ tft.setCursor(4, tftHeight - 10); tft.print("Wheel: scroll Click/Esc: resume");
+ redraw = false;
+ }
+ if (check(EscPress) || check(SelPress)) break;
+ if (check(NextPress) && top + rows() < lines.size()) { ++top; redraw = true; }
+ if (check(PrevPress) && top) { --top; redraw = true; }
+ delay(20);
+ }
+}
+void scan(Session &s) {
+ Receiver rx;
+ if (!rx.begin()) { displayError("wM-Bus receiver init failed", true); return; }
+ size_t selected = 0;
+ uint32_t lastDraw = 0;
+ bool dirty = true;
+ drawList(s, selected);
+ while (!returnToMenu) {
+ wmbus::Frame f;
+ const int result = rx.poll(f, s);
+ if (result < 0) { displayError("CC1101 state timeout", true); break; }
+ if (result == 1) { remember(s, f); dirty = true; }
+ // Drain FIFO without screen or SD access while a packet is in flight.
+ if (rx.busy()) { delayMicroseconds(80); continue; }
+ if (check(EscPress)) break;
+ if (check(NextPress) && !s.devices.empty()) { selected = (selected + 1) % s.devices.size(); dirty = true; }
+ if (check(PrevPress) && !s.devices.empty()) { selected = (selected + s.devices.size() - 1) % s.devices.size(); dirty = true; }
+ if (check(SelPress) && !s.devices.empty()) {
+ rx.end();
+ details(s.devices[selected]);
+ if (returnToMenu || !rx.begin()) break;
+ dirty = true;
+ }
+ if ((dirty || millis() - lastDraw > 2000) && millis() - lastDraw > 300) {
+ drawList(s, selected); dirty = false; lastDraw = millis();
+ }
+ delay(1);
+ }
+ rx.end();
+}
+void exportSD(const Session &s) {
+ if (s.devices.empty()) { displayInfo("No devices yet", true); return; }
+ if (!setupSdCard()) { displayError("SD card not available", true); return; }
+ if (!SD.exists("/wmbus") && !SD.mkdir("/wmbus")) { displayError("Cannot create /wmbus", true); return; }
+ String path = "/wmbus/devices-" + String(millis());
+ unsigned suffix = 0;
+ String fileName;
+ do { fileName = path + "-" + String(suffix++) + ".jsonl"; } while (SD.exists(fileName));
+ File out = SD.open(fileName, FILE_WRITE);
+ if (!out) { displayError("Cannot create export", true); return; }
+ bool ok = true;
+ for (const auto &d : s.devices) {
+ const auto &f = d.frame;
+ String row = "{\"id\":\"" + String(wmbus::identity(f).c_str()) + "\",\"manufacturer\":\"" +
+ String(wmbus::manufacturer(f).c_str()) + "\",\"mode\":\"" +
+ (f.mode == wmbus::Mode::T1 ? "T1-A" : f.mode == wmbus::Mode::C1A ? "C1-A" : "C1-B") +
+ "\",\"count\":" + String(d.count) + ",\"first_ms\":" + String(d.first) +
+ ",\"last_ms\":" + String(d.last) + ",\"rssi_dbm\":" + String(f.rssi) +
+ ",\"lqi\":" + String(f.lqi) + ",\"wire_l\":" + String(f.wireLength) +
+ ",\"crc_valid\":true,\"dll_crc_removed\":true,\"frame_hex\":\"" +
+ String(wmbus::hex(f.bytes.data(), f.bytes.size()).c_str()) + "\"}\n";
+ if (out.print(row) != row.length()) { ok = false; break; }
+ }
+ out.flush();
+ if (out.getWriteError()) ok = false;
+ out.close();
+ if (ok) displaySuccess("Saved " + fileName, true);
+ else displayError("SD write failed: partial export", true);
+}
+} // namespace
+
+void rf_wmbus() {
+ Session session;
+ session.devices.reserve(MAX_DEVICES);
+ while (!returnToMenu) {
+ std::vector