From b60655db5758ad9b0b66d0cfa04ca81167b587e0 Mon Sep 17 00:00:00 2001 From: E-Mixer <115429443+E-Mixer@users.noreply.github.com> Date: Wed, 11 Nov 2026 21:08:45 +0100 Subject: [PATCH 1/3] fix(build): support current Xtensa tools and pin compatible FastLED --- patch.py | 20 ++++++++++++++++---- platformio.ini | 2 +- 2 files changed, 17 insertions(+), 5 deletions(-) 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 From c66e48a3a047f0ecbbffb88889bb1518787df6b1 Mon Sep 17 00:00:00 2001 From: E-Mixer <115429443+E-Mixer@users.noreply.github.com> Date: Wed, 11 Nov 2026 21:09:37 +0100 Subject: [PATCH 2/3] feat(rf): add passive Wireless M-Bus receiver with English and German guides --- .gitignore | 1 + README.md | 6 + docs/wireless-mbus.de.md | 163 ++++++++++++++++ docs/wireless-mbus.md | 139 ++++++++++++++ src/core/menu_items/RFMenu.cpp | 2 + src/modules/rf/rf_wmbus.cpp | 297 +++++++++++++++++++++++++++++ src/modules/rf/rf_wmbus.h | 3 + src/modules/rf/wmbus_codec.cpp | 291 ++++++++++++++++++++++++++++ src/modules/rf/wmbus_codec.h | 28 +++ tests/wmbus/fake_cc1101.h | 60 ++++++ tests/wmbus/run.py | 50 +++++ tests/wmbus/test_receiver_main.cpp | 99 ++++++++++ tests/wmbus/test_wmbus.cpp | 130 +++++++++++++ 13 files changed, 1269 insertions(+) create mode 100644 docs/wireless-mbus.de.md create mode 100644 docs/wireless-mbus.md create mode 100644 src/modules/rf/rf_wmbus.cpp create mode 100644 src/modules/rf/rf_wmbus.h create mode 100644 src/modules/rf/wmbus_codec.cpp create mode 100644 src/modules/rf/wmbus_codec.h create mode 100644 tests/wmbus/fake_cc1101.h create mode 100644 tests/wmbus/run.py create mode 100644 tests/wmbus/test_receiver_main.cpp create mode 100644 tests/wmbus/test_wmbus.cpp diff --git a/.gitignore b/.gitignore index f3f594b915..8e646cdb61 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .pio +/.tools/ .vscode/.browse.c_cpp.db* .vscode/c_cpp_properties.json .vscode/launch.json diff --git a/README.md b/README.md index e87968ed23..9b9d813f6c 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,11 @@ # :shark: Bruce +> **Experimental Wireless M-Bus development fork.** Native receive-only T1/C1 +> support for CC1101; reception tested on a LilyGO T-Embed CC1101 Plus. +> [English instructions](docs/wireless-mbus.md) | +> [Deutsche Anleitung](docs/wireless-mbus.de.md). + Bruce is a versatile ESP32 firmware packed with offensive-security tools, built to make Red Team operations fast and portable. It also supports [M5Stack](https://shop.m5stack.com), [LILYGO](https://lilygo.cc) , [RockBase IoT](https://www.rockbaseiot.com) and [Elecrow](https://www.elecrow.com) products, and works great with the Cardputer, Sticks, M5Cores, T-Decks and T-Embeds. @@ -99,6 +104,7 @@ Also, [read our FAQ](https://wiki.bruce.computer/faq/)

RF

- [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..922aab624f --- /dev/null +++ b/docs/wireless-mbus.de.md @@ -0,0 +1,163 @@ +# 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. + +## 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 + +Verwende in diesem Fork den Branch **`feature/wireless-mbus`**. Grundlage ist der +offizielle Bruce-Commit `a59213f3ec6cd302fa32c2042b2700c9c24d991a`. + +Quellcode herunterladen und in das Verzeichnis wechseln: + +```sh +git clone --branch feature/wireless-mbus https://github.com/E-Mixer/firmware.git +cd firmware +``` + +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. + +Nach Prüfung der Installationshinweise unten ist dies ein 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. + +**Bei vorhandenem Dualboot oder Launcher muss die Installationsmethode dazu passen.** +Das Komplettimage enthält die Partitionstabelle für Bruce als einzelne Firmware +und kann eine vorhandene andere Aufteilung ersetzen. Beim hier dokumentierten +Hardwaretest wurde der interne Flash ausdrücklich vollständig gelöscht und +anschließend nur Bruce installiert. Dadurch entfielen das bisherige Dualboot und +die internen Einstellungen. Ein vollständiges Löschen ist keine grundsätzliche +Voraussetzung der Empfangs-App. + +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..18168e32f2 --- /dev/null +++ b/docs/wireless-mbus.md @@ -0,0 +1,139 @@ +# 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. + +## 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 + +Use this fork's `feature/wireless-mbus` branch. The original upstream base is +`a59213f3ec6cd302fa32c2042b2700c9c24d991a`. + +```sh +git clone --branch feature/wireless-mbus https://github.com/E-Mixer/firmware.git +cd firmware +``` + +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. + +After checking the installation notes below, 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. + +**Existing dualboot or launcher installations need their own installation method.** +The merged image contains Bruce's standalone partition table; writing it can +replace an existing partition layout. The hardware test for this project used a +full internal-flash erase followed by standalone installation, removing the old +dualboot and internal settings. A full erase is not a prerequisite of the RF app. + +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/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