Merge remote-tracking branch 'origin/lora-refactor' into lora-refactor

Resolve merge conflict in src/main.cpp by accepting the refactored version
from origin. The remote branch includes significant architectural improvements:

- New app context and state machine structures
- Refactored receiver and sender logic
- Library reorganization (dd3_legacy_core, dd3_transport_logic)
- Test framework enhancements
- Code quality improvements

The local WiFi reconnection feature (commit 32cd065) will be
re-integrated if needed in the new architecture.
This commit is contained in:
2026-03-11 17:05:39 +01:00
44 changed files with 4098 additions and 1828 deletions

View File

@@ -8,9 +8,13 @@ Firmware for LilyGO T3 v1.6.1 (`ESP32 + SX1276 + SSD1306`) that runs in two role
- Single codebase, role selected at boot by `detect_role()` (`src/config.cpp`). - Single codebase, role selected at boot by `detect_role()` (`src/config.cpp`).
- LoRa transport is wrapped with firmware-level CRC16-CCITT (`src/lora_transport.cpp`). - LoRa transport is wrapped with firmware-level CRC16-CCITT (`src/lora_transport.cpp`).
- Sender meter ingest is decoupled from LoRa waits via FreeRTOS meter reader task + queue on ESP32 (`src/main.cpp`). - Sender meter ingest is decoupled from LoRa waits via FreeRTOS meter reader task + queue on ESP32 (`src/sender_state_machine.cpp`).
- Batch payload codec is schema `v3` with a 30-bit `present_mask` over `[t_last-29, t_last]` (`src/payload_codec.cpp`). - Batch payload codec is schema `v3` with a 30-bit `present_mask` over `[t_last-29, t_last]` (`lib/dd3_legacy_core/src/payload_codec.cpp`).
- Sender retries reuse cached encoded payload bytes (no re-encode on retry path).
- Sender ACK receive windows adapt from observed ACK RTT + miss streak.
- Sender catch-up mode drains backlog with immediate extra sends when more than one batch is queued (still ACK-gated, single inflight batch).
- Sender only starts normal metering/transmit flow after valid time bootstrap from receiver ACK. - Sender only starts normal metering/transmit flow after valid time bootstrap from receiver ACK.
- Sender fault counters are reset at first valid time sync and again at each UTC hour boundary.
- Receiver runs STA mode if stored config is valid and connects, otherwise AP fallback. - Receiver runs STA mode if stored config is valid and connects, otherwise AP fallback.
## LoRa Protocol ## LoRa Protocol
@@ -44,6 +48,7 @@ Payload codec (`schema=3`, magic `0xDDB3`) carries:
- `flags bit0`: `time_valid` - `flags bit0`: `time_valid`
- ACK is repeated (`ACK_REPEAT_COUNT=3`, `ACK_REPEAT_DELAY_MS=200`) - ACK is repeated (`ACK_REPEAT_COUNT=3`, `ACK_REPEAT_DELAY_MS=200`)
- Sender sets local time only if `time_valid=1` and `epoch >= MIN_ACCEPTED_EPOCH_UTC` (`2026-02-01 00:00:00 UTC`) - Sender sets local time only if `time_valid=1` and `epoch >= MIN_ACCEPTED_EPOCH_UTC` (`2026-02-01 00:00:00 UTC`)
- Sender ACK wait windows are adaptive (short first window, expanded second window on miss)
## Time Bootstrap and Timezone ## Time Bootstrap and Timezone
@@ -55,8 +60,12 @@ Sender boot starts in sync-only mode:
After valid ACK time: After valid ACK time:
- `time_set_utc()` is called - `time_set_utc()` is called
- `g_time_acquired=true` - `g_time_acquired=true`
- sender fault counters are reset once (`err_m`, `err_d`, `err_tx`, last-error state)
- normal 1 Hz sampling + periodic batch transmission starts - normal 1 Hz sampling + periodic batch transmission starts
After initial sync:
- sender fault counters are reset again once per UTC hour when the hour index changes (`HH:00 UTC` boundary)
Timezone: Timezone:
- `TIMEZONE_TZ` from `include/config.h` is applied in `time_manager`. - `TIMEZONE_TZ` from `include/config.h` is applied in `time_manager`.
- Web/OLED local-time rendering uses this timezone. - Web/OLED local-time rendering uses this timezone.
@@ -64,10 +73,12 @@ Timezone:
## Sender Meter Path ## Sender Meter Path
Implemented by `src/meter_driver.cpp` and sender loop in `src/main.cpp`: Implemented by `src/meter_driver.cpp` and sender loop in `src/sender_state_machine.cpp`:
- UART: `Serial2`, `GPIO34`, `9600 7E1` - UART: `Serial2`, `GPIO34`, `9600 7E1`
- ESP32 RX buffer enlarged to `8192` - ESP32 RX buffer enlarged to `8192`
- Frame detection `/ ... !`, timeout `METER_FRAME_TIMEOUT_MS=3000` - Frame detection `/ ... !`, timeout `METER_FRAME_TIMEOUT_MS=3000`
- Single-pass OBIS line dispatch (no repeated multi-key scans per line)
- Fixed-point decimal parser (dot/comma decimals), with early-exit once all required OBIS fields are captured
- Parsed OBIS fields: - Parsed OBIS fields:
- `0-0:96.8.0*255` meter Sekundenindex (hex u32) - `0-0:96.8.0*255` meter Sekundenindex (hex u32)
- `1-0:1.8.0` total energy (auto scales Wh -> kWh when unit is Wh) - `1-0:1.8.0` total energy (auto scales Wh -> kWh when unit is Wh)
@@ -80,16 +91,27 @@ Timestamp derivation:
- jump checks: rollback, wall-time delta mismatch, anchor drift - jump checks: rollback, wall-time delta mismatch, anchor drift
Sender builds sparse 30-slot windows and sends every `METER_SEND_INTERVAL_MS` (`30s`). Sender builds sparse 30-slot windows and sends every `METER_SEND_INTERVAL_MS` (`30s`).
When backlog is present (`batch_q > 1`), sender transmits additional queued batches immediately after ACK to reduce lag, while keeping stop-and-wait ACK semantics.
Sender diagnostics (serial debug mode):
- periodic structured `diag:` line with:
- meter parser counters (`ok/parse_fail/overflow/timeout`)
- meter queue stats (`depth/high-watermark/drops`)
- ACK stats (`last RTT`, `EWMA RTT`, `miss streak`, timeout/retry totals)
- sender runtime totals (`rx window ms`, `sleep ms`)
- diagnostics are local-only (serial); LoRa payload schema/fields are unchanged.
## Receiver Behavior ## Receiver Behavior
For decoded `BatchUp`: For decoded `BatchUp`:
1. Reassemble and decode. 1. Reassemble and decode.
2. Send `AckDown` immediately. 2. Validate sender identity (`EXPECTED_SENDER_IDS` and payload sender ID mapping).
3. Track duplicates per configured sender (`EXPECTED_SENDER_IDS`). 3. Reject unknown/mismatched senders before ACK and before SD/MQTT/web updates.
4. If duplicate: update duplicate counters/time, skip data write/publish. 4. Send `AckDown` promptly for accepted senders.
5. If `n==0`: sync request path only. 5. Track duplicates per configured sender.
6. Else reconstruct each sample timestamp from `t_last + present_mask`, then: 6. If duplicate: update duplicate counters/time, skip data write/publish.
7. If `n==0`: sync request path only.
8. Else reconstruct each sample timestamp from `t_last + present_mask`, then:
- append to SD CSV - append to SD CSV
- publish MQTT state - publish MQTT state
- update web status and last batch table - update web status and last batch table
@@ -102,7 +124,7 @@ State topic:
Fault topic (retained): Fault topic (retained):
- `smartmeter/<device_id>/faults` - `smartmeter/<device_id>/faults`
State JSON (`src/json_codec.cpp`) includes: State JSON (`lib/dd3_legacy_core/src/json_codec.cpp`) includes:
- `id`, `ts`, `e_kwh` - `id`, `ts`, `e_kwh`
- `p_w`, `p1_w`, `p2_w`, `p3_w` - `p_w`, `p1_w`, `p2_w`, `p3_w`
- `bat_v`, `bat_pct` - `bat_v`, `bat_pct`
@@ -110,28 +132,43 @@ State JSON (`src/json_codec.cpp`) includes:
- `err_last`, `rx_reject`, `rx_reject_text` - `err_last`, `rx_reject`, `rx_reject_text`
- non-zero fault counters when available - non-zero fault counters when available
Sender fault counter lifecycle:
- counters are cumulative only within the current UTC-hour window after first sync
- counters reset on first valid sender time sync and at each subsequent UTC hour boundary
Home Assistant discovery: Home Assistant discovery:
- enabled by `ENABLE_HA_DISCOVERY=true` - enabled by `ENABLE_HA_DISCOVERY=true`
- publishes to `homeassistant/sensor/<device_id>/<key>/config` - publishes to `homeassistant/sensor/<device_id>/<key>/config`
- `unique_id` format is `<device_id>_<key>` (example: `dd3-F19C_energy`)
- device metadata:
- `identifiers: ["<device_id>"]`
- `name: "<device_id>"`
- `model: "DD3-LoRa-Bridge"`
- `manufacturer: "AcidBurns"` (from `HA_MANUFACTURER` in `include/config.h`)
- single source of truth: change manufacturer only in `include/config.h`
## Web UI, Wi-Fi, SD ## Web UI, Wi-Fi, SD
- Wi-Fi/MQTT/NTP/web-auth config is stored in Preferences. - Wi-Fi/MQTT/NTP/web-auth config is stored in Preferences.
- AP fallback SSID prefix: `DD3-Bridge-`. - AP fallback SSID prefix: `DD3-Bridge-`.
- Default web credentials: `admin/admin`. - Default web credentials: `admin/admin`.
- AP auth requirement is controlled by `WEB_AUTH_REQUIRE_AP` (default `false`). - AP auth requirement is controlled by `WEB_AUTH_REQUIRE_AP` (default `true`).
- STA auth requirement is controlled by `WEB_AUTH_REQUIRE_STA` (default `true`). - STA auth requirement is controlled by `WEB_AUTH_REQUIRE_STA` (default `true`).
Web timestamp display: Web timestamp display:
- human-facing timestamps show `epoch (HH:MM:SS TZ)` in local configured timezone. - human-facing timestamps show `epoch (HH:MM:SS TZ)` in local configured timezone.
SD CSV logging (`src/sd_logger.cpp`): SD CSV logging (`src/sd_logger.cpp`):
- header: `ts_utc,ts_hms_utc,p_w,p1_w,p2_w,p3_w,e_kwh,bat_v,bat_pct,rssi,snr,err_m,err_d,err_tx,err_last` - header: `ts_utc,ts_hms_local,p_w,p1_w,p2_w,p3_w,e_kwh,bat_v,bat_pct,rssi,snr,err_m,err_d,err_tx,err_last`
- `ts_hms_utc` is UTC `HH:MM:SS` - `ts_hms_local` is local `HH:MM:SS` derived from `TIMEZONE_TZ`
- per-day file partition uses local date from `TIMEZONE_TZ`: `/dd3/<device_id>/YYYY-MM-DD.csv`
History parser (`src/web_server.cpp`): History parser (`src/web_server.cpp`):
- expects the current CSV layout above - accepts both:
- legacy CSV layouts are not parsed (no backward compatibility) - current layout (`ts_utc,ts_hms_local,p_w,...`)
- legacy layout (`ts_utc,p_w,...`)
- daily file lookup prefers local-date filenames and falls back to legacy UTC-date filenames for backward compatibility
- requires full numeric parse for `ts_utc` and `p_w` (rejects trailing junk)
OLED duplicate display: OLED duplicate display:
- receiver sender-pages show duplicate rate as `pct (absolute)` and last duplicate as `HH:MM`. - receiver sender-pages show duplicate rate as `pct (absolute)` and last duplicate as `HH:MM`.

View File

@@ -11,7 +11,31 @@ It is based on the current `lora-refactor` code state and captures:
Function names below are C++ references. Rust naming/layout may differ, but the behavior must remain equivalent. Function names below are C++ references. Rust naming/layout may differ, but the behavior must remain equivalent.
## 2. System-Level Requirements ## 2. Refactored Architecture Baseline
The `lora-refactor` branch split role-specific runtime from the previous large `main.cpp` into dedicated modules while keeping a single firmware image:
- `src/main.cpp` is a thin coordinator that:
- detects role and initializes shared platform subsystems,
- prepares role module configuration,
- calls `begin()` once,
- delegates runtime in `loop()`.
- sender runtime ownership:
- `src/sender_state_machine.h`
- `src/sender_state_machine.cpp`
- receiver runtime ownership:
- `src/receiver_pipeline.h`
- `src/receiver_pipeline.cpp`
- receiver shared mutable state used by setup wiring and runtime:
- `src/app_context.h` (`ReceiverSharedState`)
Sender state machine invariants must remain behavior-equivalent:
- single inflight batch at a time,
- ACK acceptance only for matching `batch_id`,
- retry bounded by `BATCH_MAX_RETRIES`,
- queue depth bounded by `BATCH_QUEUE_DEPTH`.
## 3. System-Level Requirements
- Role selection: - Role selection:
- `Sender` when `GPIO14` reads HIGH. - `Sender` when `GPIO14` reads HIGH.
@@ -28,23 +52,37 @@ Function names below are C++ references. Rust naming/layout may differ, but the
- Time bootstrap guardrail: - Time bootstrap guardrail:
- sender must not run normal sampling/transmit until valid ACK time received. - sender must not run normal sampling/transmit until valid ACK time received.
- accept ACK time only if `time_valid=1` and `epoch >= MIN_ACCEPTED_EPOCH_UTC`. - accept ACK time only if `time_valid=1` and `epoch >= MIN_ACCEPTED_EPOCH_UTC`.
- sender fault counters reset when first valid sync is accepted.
- after first sync, sender fault counters reset again at each UTC hour boundary.
- Sampling/transmit cadence: - Sampling/transmit cadence:
- sender sample cadence 1 Hz. - sender sample cadence 1 Hz.
- sender batch cadence 30 s. - sender batch cadence 30 s.
- when sender backlog exists (`batch_count > 1`) and no ACK is pending, sender performs immediate catch-up sends (still stop-and-wait with one inflight batch).
- sync-request cadence 15 s while unsynced. - sync-request cadence 15 s while unsynced.
- sender retransmits reuse cached encoded payload bytes for same inflight batch.
- sender ACK receive window is adaptive from airtime + observed ACK RTT, with expanded second window on miss.
- Receiver behavior: - Receiver behavior:
- decode/reconstruct sparse timestamps. - decode/reconstruct sparse timestamps.
- ACK each decoded batch promptly. - ACK accepted batches promptly.
- reject unknown/mismatched sender identities before ACK and before SD/MQTT/web updates.
- update MQTT, web status, SD logging. - update MQTT, web status, SD logging.
- Persistence: - Persistence:
- Wi-Fi/MQTT/NTP/web credentials in Preferences namespace `dd3cfg`. - Wi-Fi/MQTT/NTP/web credentials in Preferences namespace `dd3cfg`.
- Web auth defaults:
- `WEB_AUTH_REQUIRE_STA=true`
- `WEB_AUTH_REQUIRE_AP=true`
- Web and display time rendering: - Web and display time rendering:
- local timezone from `TIMEZONE_TZ`. - local timezone from `TIMEZONE_TZ`.
- Sender diagnostics:
- structured sender diagnostics are emitted to serial debug output only.
- diagnostics do not change LoRa payload schema or remap payload fields.
- SD logging: - SD logging:
- CSV columns include both `ts_utc` and `ts_hms_utc`. - CSV columns include both `ts_utc` and `ts_hms_local`.
- history parser expects this current layout. - per-day CSV file partitioning uses local date (`TIMEZONE_TZ`) under `/dd3/<device_id>/YYYY-MM-DD.csv`.
- history day-file resolution prefers local-date filenames and falls back to legacy UTC-date filenames.
- history parser supports both current (`ts_utc,ts_hms_local,p_w,...`) and legacy (`ts_utc,p_w,...`) layouts.
## 3. Protocol and Data Contracts ## 4. Protocol and Data Contracts
- `LoraMsgKind`: - `LoraMsgKind`:
- `BatchUp=0` - `BatchUp=0`
@@ -52,29 +90,42 @@ Function names below are C++ references. Rust naming/layout may differ, but the
- `AckDown` payload fixed length `7` bytes: - `AckDown` payload fixed length `7` bytes:
- `[flags:1][batch_id_be:2][epoch_utc_be:4]` - `[flags:1][batch_id_be:2][epoch_utc_be:4]`
- `flags bit0 = time_valid` - `flags bit0 = time_valid`
- sender acceptance window is implementation-adaptive; payload format stays unchanged.
- `BatchInput`: - `BatchInput`:
- fixed arrays length `30` (`energy_wh`, `p1_w`, `p2_w`, `p3_w`) - fixed arrays length `30` (`energy_wh`, `p1_w`, `p2_w`, `p3_w`)
- `present_mask` must satisfy: only low 30 bits used and `bit_count == n` - `present_mask` must satisfy: only low 30 bits used and `bit_count == n`
- Timestamp constraints: - Timestamp constraints:
- receiver rejects decoded data whose timestamps are below `MIN_ACCEPTED_EPOCH_UTC` - receiver rejects decoded data whose timestamps are below `MIN_ACCEPTED_EPOCH_UTC`
- CSV header (current required layout): - CSV header (current required layout):
- `ts_utc,ts_hms_utc,p_w,p1_w,p2_w,p3_w,e_kwh,bat_v,bat_pct,rssi,snr,err_m,err_d,err_tx,err_last` - `ts_utc,ts_hms_local,p_w,p1_w,p2_w,p3_w,e_kwh,bat_v,bat_pct,rssi,snr,err_m,err_d,err_tx,err_last`
- Home Assistant discovery contract:
- topic: `homeassistant/sensor/<device_id>/<key>/config`
- `unique_id`: `<device_id>_<key>`
- `device.identifiers`: `["<device_id>"]`
- `device.name`: `<device_id>`
- `device.model`: `DD3-LoRa-Bridge`
- `device.manufacturer`: `AcidBurns`
- drift guards:
- canonical value is `HA_MANUFACTURER` in `include/config.h`,
- compile-time lock via `static_assert` in `include/config.h`,
- script guard `test/check_ha_manufacturer.ps1`,
- smoke test guard `test/test_refactor_smoke/test_refactor_smoke.cpp`.
## 4. Module and Function Requirements ## 5. Module and Function Requirements
## `src/config.cpp` ## `src/config.cpp`
- `DeviceRole detect_role()` - `DeviceRole detect_role()`
- configure role pin input pulldown and map to sender/receiver role. - configure role pin input pulldown and map to sender/receiver role.
## `src/data_model.cpp` ## `lib/dd3_legacy_core/src/data_model.cpp`
- `void init_device_ids(uint16_t&, char*, size_t)` - `void init_device_ids(uint16_t&, char*, size_t)`
- read MAC, derive short ID, format canonical device ID. - read MAC, derive short ID, format canonical device ID.
- `const char *rx_reject_reason_text(RxRejectReason)` - `const char *rx_reject_reason_text(RxRejectReason)`
- stable mapping for diagnostics and payloads. - stable mapping for diagnostics and payloads.
## `src/html_util.cpp` ## `lib/dd3_legacy_core/src/html_util.cpp`
- `String html_escape(const String&)` - `String html_escape(const String&)`
- escape `& < > " '`. - escape `& < > " '`.
@@ -96,11 +147,15 @@ Function names below are C++ references. Rust naming/layout may differ, but the
- parse OBIS values and set meter data fields. - parse OBIS values and set meter data fields.
- `bool meter_read(MeterData&)` - `bool meter_read(MeterData&)`
- compatibility wrapper around poll+parse. - compatibility wrapper around poll+parse.
- `void meter_get_stats(MeterDriverStats&)`
- expose parser/UART counters for sender-local diagnostics.
- Internal parse helpers to preserve numeric behavior: - Internal parse helpers to preserve numeric behavior:
- `parse_obis_ascii_value` - `detect_obis_field`
- `parse_decimal_fixed`
- `parse_obis_ascii_payload_value`
- `parse_obis_ascii_unit_scale` - `parse_obis_ascii_unit_scale`
- `hex_nibble` - `hex_nibble`
- `parse_obis_hex_u32` - `parse_obis_hex_payload_u32`
- `meter_debug_log` - `meter_debug_log`
## `src/power_manager.cpp` ## `src/power_manager.cpp`
@@ -126,8 +181,9 @@ Function names below are C++ references. Rust naming/layout may differ, but the
- configure NTP servers and timezone env. - configure NTP servers and timezone env.
- `uint32_t time_get_utc()` - `uint32_t time_get_utc()`
- return epoch or `0` when not plausible. - return epoch or `0` when not plausible.
- updates "clock plausible" state independently from sync state.
- `bool time_is_synced()` - `bool time_is_synced()`
- sync status helper. - true only after explicit sync signals (NTP callback/status or trusted `time_set_utc`).
- `void time_set_utc(uint32_t)` - `void time_set_utc(uint32_t)`
- set system time and sync flags. - set system time and sync flags.
- `void time_get_local_hhmm(char*, size_t)` - `void time_get_local_hhmm(char*, size_t)`
@@ -136,6 +192,8 @@ Function names below are C++ references. Rust naming/layout may differ, but the
- `uint32_t time_get_last_sync_age_sec()` - `uint32_t time_get_last_sync_age_sec()`
- Internal behavior-critical helpers: - Internal behavior-critical helpers:
- `note_last_sync` - `note_last_sync`
- `mark_synced`
- `ntp_sync_notification_cb`
- `ensure_timezone_set` - `ensure_timezone_set`
## `src/lora_transport.cpp` ## `src/lora_transport.cpp`
@@ -158,9 +216,9 @@ Function names below are C++ references. Rust naming/layout may differ, but the
- compute packet airtime from SF/BW/CR/preamble. - compute packet airtime from SF/BW/CR/preamble.
- Internal behavior-critical helpers: - Internal behavior-critical helpers:
- `note_reject` - `note_reject`
- `crc16_ccitt` - `lora_build_frame`, `lora_parse_frame`, `lora_crc16_ccitt` (implemented in `lib/dd3_transport_logic/src/lora_frame_logic.cpp`)
## `src/payload_codec.cpp` ## `lib/dd3_legacy_core/src/payload_codec.cpp`
- `bool encode_batch(const BatchInput&, uint8_t*, size_t, size_t*)` - `bool encode_batch(const BatchInput&, uint8_t*, size_t, size_t*)`
- schema v3 encoder with metadata, sparse present mask, delta coding. - schema v3 encoder with metadata, sparse present mask, delta coding.
@@ -178,7 +236,7 @@ Function names below are C++ references. Rust naming/layout may differ, but the
- Optional self-test: - Optional self-test:
- `payload_codec_self_test` (when `PAYLOAD_CODEC_TEST`). - `payload_codec_self_test` (when `PAYLOAD_CODEC_TEST`).
## `src/json_codec.cpp` ## `lib/dd3_legacy_core/src/json_codec.cpp`
- `bool meterDataToJson(const MeterData&, String&)` - `bool meterDataToJson(const MeterData&, String&)`
- create MQTT state JSON with stable field semantics. - create MQTT state JSON with stable field semantics.
@@ -202,12 +260,14 @@ Function names below are C++ references. Rust naming/layout may differ, but the
- `fault_text` - `fault_text`
- `mqtt_connect` - `mqtt_connect`
- `publish_discovery_sensor` - `publish_discovery_sensor`
- discovery payload uses canonical device identity fields and `manufacturer=AcidBurns`
## `src/wifi_manager.cpp` ## `src/wifi_manager.cpp`
- `void wifi_manager_init()` - `void wifi_manager_init()`
- `bool wifi_load_config(WifiMqttConfig&)` - `bool wifi_load_config(WifiMqttConfig&)`
- `bool wifi_save_config(const WifiMqttConfig&)` - `bool wifi_save_config(const WifiMqttConfig&)`
- returns `false` when any Preferences write/verify fails.
- `bool wifi_connect_sta(const WifiMqttConfig&, uint32_t timeout_ms)` - `bool wifi_connect_sta(const WifiMqttConfig&, uint32_t timeout_ms)`
- `void wifi_start_ap(const char*, const char*)` - `void wifi_start_ap(const char*, const char*)`
- `bool wifi_is_connected()` - `bool wifi_is_connected()`
@@ -218,12 +278,12 @@ Function names below are C++ references. Rust naming/layout may differ, but the
- `void sd_logger_init()` - `void sd_logger_init()`
- `bool sd_logger_is_ready()` - `bool sd_logger_is_ready()`
- `void sd_logger_log_sample(const MeterData&, bool include_error_text)` - `void sd_logger_log_sample(const MeterData&, bool include_error_text)`
- append/create per-day CSV under `/dd3/<device_id>/YYYY-MM-DD.csv`. - append/create per-day CSV under `/dd3/<device_id>/YYYY-MM-DD.csv` using local calendar date from `TIMEZONE_TZ`.
- Internal behavior-critical helpers: - Internal behavior-critical helpers:
- `fault_text` - `fault_text`
- `ensure_dir` - `ensure_dir`
- `format_date_utc` - `format_date_local`
- `format_hms_utc` - `format_hms_local`
## `src/display_ui.cpp` ## `src/display_ui.cpp`
@@ -281,7 +341,8 @@ Internal route/state functions to preserve behavior:
- `sanitize_history_device_id` - `sanitize_history_device_id`
- `sanitize_download_filename` - `sanitize_download_filename`
- `history_reset` - `history_reset`
- `history_date_from_epoch` - `history_date_from_epoch_local`
- `history_date_from_epoch_utc` (legacy fallback mapping)
- `history_open_next_file` - `history_open_next_file`
- `history_parse_line` - `history_parse_line`
- `history_tick` - `history_tick`
@@ -303,15 +364,25 @@ Internal route/state functions to preserve behavior:
- `test_receiver_loop` - `test_receiver_loop`
- decode test JSON, update display test markers, publish MQTT test topic. - decode test JSON, update display test markers, publish MQTT test topic.
## `src/main.cpp` (Core Orchestration) ## `src/app_context.h`
These functions define end-to-end firmware behavior and must have equivalents: - `ReceiverSharedState`
- retains receiver-owned shared status/fault/discovery state used by setup wiring and runtime.
## `src/sender_state_machine.h/.cpp` (Sender Runtime)
Public API:
- `SenderStateMachineConfig`
- `SenderStats`
- `SenderStateMachine::begin(...)`
- `SenderStateMachine::loop()`
- `SenderStateMachine::stats()`
Behavior-critical internals (migrated from pre-refactor `main.cpp`) that must remain equivalent:
- Logging/utilities: - Logging/utilities:
- `serial_debug_printf` - `serial_debug_printf`
- `bit_count32` - `bit_count32`
- `abs_diff_u32` - `abs_diff_u32`
- Meter-time anchoring and ingest: - Meter-time anchoring and ingest:
- `meter_time_update_snapshot` - `meter_time_update_snapshot`
- `set_last_meter_sample` - `set_last_meter_sample`
@@ -320,31 +391,57 @@ These functions define end-to-end firmware behavior and must have equivalents:
- `meter_reader_task_entry` - `meter_reader_task_entry`
- `meter_reader_start` - `meter_reader_start`
- `meter_reader_pump` - `meter_reader_pump`
- Sender state/data handling:
- Sender/receiver state setup and shared state:
- `init_sender_statuses`
- `update_battery_cache` - `update_battery_cache`
- `battery_sample_due` - `battery_sample_due`
- Queue and sample batching:
- `batch_queue_drop_oldest` - `batch_queue_drop_oldest`
- `sender_note_rx_reject` - `sender_note_rx_reject`
- `sender_log_diagnostics`
- `batch_queue_peek` - `batch_queue_peek`
- `batch_queue_enqueue` - `batch_queue_enqueue`
- `reset_build_counters` - `reset_build_counters`
- `append_meter_sample` - `append_meter_sample`
- `last_sample_ts` - `last_sample_ts`
- Sender fault handling:
- Fault tracking/publish:
- `note_fault` - `note_fault`
- `clear_faults`
- `sender_reset_fault_stats`
- `sender_reset_fault_stats_on_first_sync`
- `sender_reset_fault_stats_on_hour_boundary`
- Sender-specific encoding/scheduling:
- `kwh_to_wh_from_float`
- `float_to_i16_w`
- `float_to_i16_w_clamped`
- `battery_mv_from_voltage`
- `compute_batch_ack_timeout_ms`
- `send_batch_payload`
- `invalidate_inflight_encode_cache`
- `prepare_inflight_from_queue`
- `send_inflight_batch`
- `send_meter_batch`
- `send_sync_request`
- `resend_inflight_batch`
- `finish_inflight_batch`
- `sender_loop`
## `src/receiver_pipeline.h/.cpp` (Receiver Runtime)
Public API:
- `ReceiverPipelineConfig`
- `ReceiverStats`
- `ReceiverPipeline::begin(...)`
- `ReceiverPipeline::loop()`
- `ReceiverPipeline::stats()`
Behavior-critical internals (migrated from pre-refactor `main.cpp`) that must remain equivalent:
- Receiver setup/state:
- `init_sender_statuses`
- Fault handling/publish:
- `note_fault`
- `clear_faults`
- `age_seconds` - `age_seconds`
- `counters_changed` - `counters_changed`
- `publish_faults_if_needed` - `publish_faults_if_needed`
- Watchdog:
- `watchdog_init`
- `watchdog_kick`
- Binary helpers and ID conversion: - Binary helpers and ID conversion:
- `write_u16_le` - `write_u16_le`
- `read_u16_le` - `read_u16_le`
@@ -354,59 +451,51 @@ These functions define end-to-end firmware behavior and must have equivalents:
- `read_u32_be` - `read_u32_be`
- `sender_id_from_short_id` - `sender_id_from_short_id`
- `short_id_from_sender_id` - `short_id_from_sender_id`
- LoRa RX/TX pipeline:
- Numeric normalization/sanitization:
- `kwh_to_wh_from_float`
- `float_to_i16_w`
- `float_to_i16_w_clamped`
- `battery_mv_from_voltage`
- Timeout and airtime-driven scheduling:
- `compute_batch_rx_timeout_ms` - `compute_batch_rx_timeout_ms`
- `compute_batch_ack_timeout_ms`
- LoRa TX pipeline:
- `send_batch_payload`
- `send_batch_ack` - `send_batch_ack`
- `prepare_inflight_from_queue`
- `send_inflight_batch`
- `send_meter_batch`
- `send_sync_request`
- `resend_inflight_batch`
- `finish_inflight_batch`
- LoRa RX reassembly/decode:
- `reset_batch_rx` - `reset_batch_rx`
- `process_batch_packet` - `process_batch_packet`
- Role loop orchestration:
- `setup`
- `sender_loop`
- `receiver_loop` - `receiver_loop`
- `loop`
## 5. Rust Porting Constraints and Recommendations ## `src/main.cpp` (Thin Coordinator)
Current core orchestration requirements:
- `setup`
- initialize shared subsystems once,
- force-link `dd3_legacy_core` before first legacy-core symbol use (`dd3_legacy_core_force_link()`),
- instantiate role config and call role `begin`,
- keep role-specific runtime out of this file.
- `loop`
- delegate to `SenderStateMachine::loop()` or `ReceiverPipeline::loop()` by role.
- Watchdog wrapper remains in coordinator:
- `watchdog_init`
- `watchdog_kick`
## 6. Rust Porting Constraints and Recommendations
- Preserve wire compatibility first: - Preserve wire compatibility first:
- LoRa frame byte layout, CRC16, ACK format, payload schema v3. - LoRa frame byte layout, CRC16, ACK format, payload schema v3.
- sender optimization changes must not alter payload field meanings.
- Preserve persistent storage keys: - Preserve persistent storage keys:
- Preferences keys (`ssid`, `pass`, `mqhost`, `mqport`, `mquser`, `mqpass`, `ntp1`, `ntp2`, `webuser`, `webpass`, `valid`). - Preferences keys (`ssid`, `pass`, `mqhost`, `mqport`, `mquser`, `mqpass`, `ntp1`, `ntp2`, `webuser`, `webpass`, `valid`).
- Preserve timing constants and acceptance thresholds: - Preserve timing constants and acceptance thresholds:
- bootstrap guardrail, retry counts, schedule intervals, min accepted epoch. - bootstrap guardrail, retry counts, schedule intervals, min accepted epoch.
- Preserve CSV output layout exactly: - Preserve CSV output layout exactly:
- consumers (history parser and external tooling) depend on it. - consumers (history parser and external tooling) depend on it.
- preserve reader compatibility for both current and legacy layouts.
- Preserve enum meanings: - Preserve enum meanings:
- `FaultType`, `RxRejectReason`, `LoraMsgKind`. - `FaultType`, `RxRejectReason`, `LoraMsgKind`.
Suggested Rust module split: Suggested Rust module split:
- `config`, `ids`, `meter`, `power`, `time`, `lora_transport`, `payload_codec`, `batch`, `mqtt`, `wifi_cfg`, `sd_log`, `web`, `display`, `runtime`. - `config`, `ids`, `meter`, `power`, `time`, `lora_transport`, `payload_codec`, `sender_state_machine`, `receiver_pipeline`, `app_context`, `mqtt`, `wifi_cfg`, `sd_log`, `web`, `display`, `runtime`.
Suggested Rust primitives: Suggested Rust primitives:
- async task for meter reader + bounded channel (drop-oldest behavior). - async task for meter reader + bounded channel (drop-oldest behavior).
- explicit state structs for sender/receiver loops. - explicit state structs for sender/receiver loops.
- serde-free/manual codec for wire compatibility where needed. - serde-free/manual codec for wire compatibility where needed.
## 6. Port Validation Checklist ## 7. Port Validation Checklist
- Sender unsynced boot sends only sync requests. - Sender unsynced boot sends only sync requests.
- ACK time bootstrap unlocks normal sender sampling. - ACK time bootstrap unlocks normal sender sampling.
@@ -415,5 +504,25 @@ Suggested Rust primitives:
- Duplicate batch handling updates counters and suppresses duplicate publish/log. - Duplicate batch handling updates counters and suppresses duplicate publish/log.
- Web UI shows `epoch (HH:MM:SS TZ)` local time. - Web UI shows `epoch (HH:MM:SS TZ)` local time.
- SD CSV header/fields match expected order. - SD CSV header/fields match expected order.
- History endpoint reads only current CSV layout successfully. - SD daily files roll over at local midnight (`TIMEZONE_TZ`), not UTC midnight.
- History endpoint reads current and legacy CSV layouts successfully.
- History endpoint can read both local-date and legacy UTC-date day filenames.
- MQTT state/fault payload fields match existing names and semantics. - MQTT state/fault payload fields match existing names and semantics.
## 8. Port Readiness Audit (2026-02-20)
Evidence checked on `lora-refactor`:
- build verification:
- `pio run -e lilygo-t3-v1-6-1`
- `pio run -e lilygo-t3-v1-6-1-test`
- drift guard verification:
- `powershell -ExecutionPolicy Bypass -File test/check_ha_manufacturer.ps1`
- refactor ownership verification:
- sender state machine state/API present in `src/sender_state_machine.h/.cpp`,
- receiver pipeline API present in `src/receiver_pipeline.h/.cpp`,
- coordinator remains thin in `src/main.cpp`.
Findings:
- Requirements are functionally met by current C++ baseline from static/code-build checks.
- The old requirement ownership under `src/main.cpp` was stale; this document now maps that behavior to `sender_state_machine` and `receiver_pipeline`.
- No wire/protocol or persistence contract drift found in this audit.

48
docs/TESTS.md Normal file
View File

@@ -0,0 +1,48 @@
# Legacy Unity Tests
This change intentionally keeps the existing PlatformIO legacy Unity harness unchanged.
No `platformio.ini`, CI, or test-runner configuration was modified.
## Compile-Only (Legacy Gate)
Use compile-only checks in environments that do not have a connected board:
```powershell
pio test -e lilygo-t3-v1-6-1-test --without-uploading --without-testing
pio test -e lilygo-t3-v1-6-1-868-test --without-uploading --without-testing
```
Suite-specific compile checks:
```powershell
pio test -e lilygo-t3-v1-6-1-test --without-uploading --without-testing -f test_html_escape
pio test -e lilygo-t3-v1-6-1-test --without-uploading --without-testing -f test_payload_codec
pio test -e lilygo-t3-v1-6-1-test --without-uploading --without-testing -f test_lora_transport
pio test -e lilygo-t3-v1-6-1-test --without-uploading --without-testing -f test_json_codec
pio test -e lilygo-t3-v1-6-1-test --without-uploading --without-testing -f test_refactor_smoke
```
## Full On-Device Unity Run
When hardware is connected, run full legacy Unity tests:
```powershell
pio test -e lilygo-t3-v1-6-1-test
pio test -e lilygo-t3-v1-6-1-868-test
```
## Suite Coverage
- `test_html_escape`: `html_escape`, `url_encode_component`, and `sanitize_device_id` edge/adversarial coverage.
- `test_payload_codec`: payload schema v3 roundtrip/reject paths and golden vectors.
- `test_lora_transport`: CRC16, frame encode/decode integrity, and chunk reassembly behavior.
- `test_json_codec`: state JSON key stability and Home Assistant discovery payload manufacturer/key stability.
- `test_refactor_smoke`: baseline include/type smoke and manufacturer constant guard, using stable public headers from `include/` (no `../../src` includes).
## Manufacturer Drift Guard
Run the static guard script to enforce Home Assistant manufacturer wiring:
```powershell
powershell -ExecutionPolicy Bypass -File test/check_ha_manufacturer.ps1
```

3
include/app_context.h Normal file
View File

@@ -0,0 +1,3 @@
#pragma once
#include "../src/app_context.h"

View File

@@ -83,9 +83,22 @@ constexpr const char *TIMEZONE_TZ = "CET-1CEST,M3.5.0/2,M10.5.0/3";
constexpr const char *AP_SSID_PREFIX = "DD3-Bridge-"; constexpr const char *AP_SSID_PREFIX = "DD3-Bridge-";
constexpr const char *AP_PASSWORD = "changeme123"; constexpr const char *AP_PASSWORD = "changeme123";
constexpr bool WEB_AUTH_REQUIRE_STA = true; constexpr bool WEB_AUTH_REQUIRE_STA = true;
constexpr bool WEB_AUTH_REQUIRE_AP = false; constexpr bool WEB_AUTH_REQUIRE_AP = true;
constexpr const char *WEB_AUTH_DEFAULT_USER = "admin"; constexpr const char *WEB_AUTH_DEFAULT_USER = "admin";
constexpr const char *WEB_AUTH_DEFAULT_PASS = "admin"; constexpr const char *WEB_AUTH_DEFAULT_PASS = "admin";
inline constexpr char HA_MANUFACTURER[] = "AcidBurns";
static_assert(
HA_MANUFACTURER[0] == 'A' &&
HA_MANUFACTURER[1] == 'c' &&
HA_MANUFACTURER[2] == 'i' &&
HA_MANUFACTURER[3] == 'd' &&
HA_MANUFACTURER[4] == 'B' &&
HA_MANUFACTURER[5] == 'u' &&
HA_MANUFACTURER[6] == 'r' &&
HA_MANUFACTURER[7] == 'n' &&
HA_MANUFACTURER[8] == 's' &&
HA_MANUFACTURER[9] == '\0',
"HA_MANUFACTURER must remain exactly \"AcidBurns\"");
constexpr uint8_t NUM_SENDERS = 1; constexpr uint8_t NUM_SENDERS = 1;
constexpr uint32_t MIN_ACCEPTED_EPOCH_UTC = 1769904000UL; // 2026-02-01 00:00:00 UTC constexpr uint32_t MIN_ACCEPTED_EPOCH_UTC = 1769904000UL; // 2026-02-01 00:00:00 UTC

View File

@@ -15,7 +15,8 @@ enum class RxRejectReason : uint8_t {
InvalidMsgKind = 2, InvalidMsgKind = 2,
LengthMismatch = 3, LengthMismatch = 3,
DeviceIdMismatch = 4, DeviceIdMismatch = 4,
BatchIdMismatch = 5 BatchIdMismatch = 5,
UnknownSender = 6
}; };
struct FaultCounters { struct FaultCounters {

View File

@@ -3,7 +3,18 @@
#include <Arduino.h> #include <Arduino.h>
#include "data_model.h" #include "data_model.h"
struct MeterDriverStats {
uint32_t frames_ok;
uint32_t frames_parse_fail;
uint32_t rx_overflow;
uint32_t rx_timeout;
uint32_t bytes_rx;
uint32_t last_rx_ms;
uint32_t last_good_frame_ms;
};
void meter_init(); void meter_init();
bool meter_read(MeterData &data); bool meter_read(MeterData &data);
bool meter_poll_frame(const char *&frame, size_t &len); bool meter_poll_frame(const char *&frame, size_t &len);
bool meter_parse_frame(const char *frame, size_t len, MeterData &data); bool meter_parse_frame(const char *frame, size_t len, MeterData &data);
void meter_get_stats(MeterDriverStats &out);

View File

@@ -0,0 +1,3 @@
#pragma once
#include "../src/receiver_pipeline.h"

View File

@@ -0,0 +1,3 @@
#pragma once
#include "../src/sender_state_machine.h"

View File

@@ -0,0 +1,3 @@
#pragma once
#include "../../../include/data_model.h"

View File

@@ -0,0 +1,4 @@
#pragma once
// Include this header in legacy Unity tests to force-link dd3_legacy_core.
void dd3_legacy_core_force_link();

View File

@@ -0,0 +1,3 @@
#pragma once
#include "../../../include/html_util.h"

View File

@@ -0,0 +1,3 @@
#pragma once
#include "../../../include/json_codec.h"

View File

@@ -0,0 +1,37 @@
#pragma once
#include <Arduino.h>
struct BatchInput {
uint16_t sender_id;
uint16_t batch_id;
uint32_t t_last;
uint32_t present_mask;
uint8_t n;
uint16_t battery_mV;
uint8_t err_m;
uint8_t err_d;
uint8_t err_tx;
uint8_t err_last;
uint8_t err_rx_reject;
uint32_t energy_wh[30];
int16_t p1_w[30];
int16_t p2_w[30];
int16_t p3_w[30];
};
bool encode_batch(const BatchInput &in, uint8_t *out, size_t out_cap, size_t *out_len);
bool decode_batch(const uint8_t *buf, size_t len, BatchInput *out);
size_t uleb128_encode(uint32_t v, uint8_t *out, size_t cap);
bool uleb128_decode(const uint8_t *in, size_t len, size_t *pos, uint32_t *v);
uint32_t zigzag32(int32_t x);
int32_t unzigzag32(uint32_t u);
size_t svarint_encode(int32_t x, uint8_t *out, size_t cap);
bool svarint_decode(const uint8_t *in, size_t len, size_t *pos, int32_t *x);
#ifdef PAYLOAD_CODEC_TEST
bool payload_codec_self_test();
#endif

View File

@@ -21,6 +21,8 @@ const char *rx_reject_reason_text(RxRejectReason reason) {
return "device_id_mismatch"; return "device_id_mismatch";
case RxRejectReason::BatchIdMismatch: case RxRejectReason::BatchIdMismatch:
return "batch_id_mismatch"; return "batch_id_mismatch";
case RxRejectReason::UnknownSender:
return "unknown_sender";
default: default:
return "none"; return "none";
} }

View File

@@ -0,0 +1,3 @@
#include "dd3_legacy_core.h"
void dd3_legacy_core_force_link() {}

View File

@@ -0,0 +1,28 @@
#pragma once
#include <Arduino.h>
struct BatchReassemblyState {
bool active;
uint16_t batch_id;
uint8_t next_index;
uint8_t expected_chunks;
uint16_t total_len;
uint16_t received_len;
uint32_t last_rx_ms;
uint32_t timeout_ms;
};
enum class BatchReassemblyStatus : uint8_t {
InProgress = 0,
Complete = 1,
ErrorReset = 2
};
void batch_reassembly_reset(BatchReassemblyState &state);
BatchReassemblyStatus batch_reassembly_push(BatchReassemblyState &state, uint16_t batch_id, uint8_t chunk_index,
uint8_t chunk_count, uint16_t total_len, const uint8_t *chunk_data,
size_t chunk_len, uint32_t now_ms, uint32_t timeout_ms_for_new_batch,
uint16_t max_total_len, uint8_t *buffer, size_t buffer_cap,
uint16_t &out_complete_len);

View File

@@ -0,0 +1,7 @@
#pragma once
#include <Arduino.h>
bool ha_build_discovery_sensor_payload(const char *device_id, const char *key, const char *name, const char *unit,
const char *device_class, const char *state_topic, const char *value_template,
const char *manufacturer, String &out_payload);

View File

@@ -0,0 +1,19 @@
#pragma once
#include <Arduino.h>
enum class LoraFrameDecodeStatus : uint8_t {
Ok = 0,
LengthMismatch = 1,
CrcFail = 2,
InvalidMsgKind = 3
};
uint16_t lora_crc16_ccitt(const uint8_t *data, size_t len);
bool lora_build_frame(uint8_t msg_kind, uint16_t device_id_short, const uint8_t *payload, size_t payload_len,
uint8_t *out_frame, size_t out_cap, size_t &out_len);
LoraFrameDecodeStatus lora_parse_frame(const uint8_t *frame, size_t frame_len, uint8_t max_msg_kind, uint8_t *out_msg_kind,
uint16_t *out_device_id_short, uint8_t *out_payload, size_t payload_cap,
size_t *out_payload_len);

View File

@@ -0,0 +1,75 @@
#include "batch_reassembly_logic.h"
#include <string.h>
void batch_reassembly_reset(BatchReassemblyState &state) {
state.active = false;
state.batch_id = 0;
state.next_index = 0;
state.expected_chunks = 0;
state.total_len = 0;
state.received_len = 0;
state.last_rx_ms = 0;
state.timeout_ms = 0;
}
BatchReassemblyStatus batch_reassembly_push(BatchReassemblyState &state, uint16_t batch_id, uint8_t chunk_index,
uint8_t chunk_count, uint16_t total_len, const uint8_t *chunk_data,
size_t chunk_len, uint32_t now_ms, uint32_t timeout_ms_for_new_batch,
uint16_t max_total_len, uint8_t *buffer, size_t buffer_cap,
uint16_t &out_complete_len) {
out_complete_len = 0;
if (!buffer || !chunk_data) {
batch_reassembly_reset(state);
return BatchReassemblyStatus::ErrorReset;
}
if (chunk_len > 0 && total_len == 0) {
batch_reassembly_reset(state);
return BatchReassemblyStatus::ErrorReset;
}
bool expired = state.timeout_ms > 0 && (now_ms - state.last_rx_ms > state.timeout_ms);
if (!state.active || batch_id != state.batch_id || expired) {
if (chunk_index != 0) {
batch_reassembly_reset(state);
return BatchReassemblyStatus::ErrorReset;
}
if (total_len == 0 || total_len > max_total_len || chunk_count == 0) {
batch_reassembly_reset(state);
return BatchReassemblyStatus::ErrorReset;
}
state.active = true;
state.batch_id = batch_id;
state.expected_chunks = chunk_count;
state.total_len = total_len;
state.received_len = 0;
state.next_index = 0;
state.last_rx_ms = now_ms;
state.timeout_ms = timeout_ms_for_new_batch;
}
if (!state.active || chunk_index != state.next_index || chunk_count != state.expected_chunks) {
batch_reassembly_reset(state);
return BatchReassemblyStatus::ErrorReset;
}
if (state.received_len + chunk_len > state.total_len ||
state.received_len + chunk_len > max_total_len ||
state.received_len + chunk_len > buffer_cap) {
batch_reassembly_reset(state);
return BatchReassemblyStatus::ErrorReset;
}
memcpy(&buffer[state.received_len], chunk_data, chunk_len);
state.received_len += static_cast<uint16_t>(chunk_len);
state.next_index++;
state.last_rx_ms = now_ms;
if (state.next_index == state.expected_chunks && state.received_len == state.total_len) {
out_complete_len = state.received_len;
batch_reassembly_reset(state);
return BatchReassemblyStatus::Complete;
}
return BatchReassemblyStatus::InProgress;
}

View File

@@ -0,0 +1,37 @@
#include "ha_discovery_json.h"
#include <ArduinoJson.h>
bool ha_build_discovery_sensor_payload(const char *device_id, const char *key, const char *name, const char *unit,
const char *device_class, const char *state_topic, const char *value_template,
const char *manufacturer, String &out_payload) {
if (!device_id || !key || !name || !state_topic || !value_template || !manufacturer) {
return false;
}
StaticJsonDocument<256> doc;
String unique_id = String(device_id) + "_" + key;
String sensor_name = String(device_id) + " " + name;
doc["name"] = sensor_name;
doc["state_topic"] = state_topic;
doc["unique_id"] = unique_id;
if (unit && unit[0] != '\0') {
doc["unit_of_measurement"] = unit;
}
if (device_class && device_class[0] != '\0') {
doc["device_class"] = device_class;
}
doc["value_template"] = value_template;
JsonObject device = doc.createNestedObject("device");
JsonArray identifiers = device.createNestedArray("identifiers");
identifiers.add(String(device_id));
device["name"] = String(device_id);
device["model"] = "DD3-LoRa-Bridge";
device["manufacturer"] = manufacturer;
out_payload = "";
size_t len = serializeJson(doc, out_payload);
return len > 0;
}

View File

@@ -0,0 +1,88 @@
#include "lora_frame_logic.h"
#include <string.h>
uint16_t lora_crc16_ccitt(const uint8_t *data, size_t len) {
if (!data && len > 0) {
return 0;
}
uint16_t crc = 0xFFFF;
for (size_t i = 0; i < len; ++i) {
crc ^= static_cast<uint16_t>(data[i]) << 8;
for (uint8_t b = 0; b < 8; ++b) {
if (crc & 0x8000) {
crc = (crc << 1) ^ 0x1021;
} else {
crc <<= 1;
}
}
}
return crc;
}
bool lora_build_frame(uint8_t msg_kind, uint16_t device_id_short, const uint8_t *payload, size_t payload_len,
uint8_t *out_frame, size_t out_cap, size_t &out_len) {
out_len = 0;
if (!out_frame) {
return false;
}
if (payload_len > 0 && !payload) {
return false;
}
if (payload_len > (SIZE_MAX - 5)) {
return false;
}
size_t needed = payload_len + 5;
if (needed > out_cap) {
return false;
}
size_t idx = 0;
out_frame[idx++] = msg_kind;
out_frame[idx++] = static_cast<uint8_t>(device_id_short >> 8);
out_frame[idx++] = static_cast<uint8_t>(device_id_short & 0xFF);
if (payload_len > 0) {
memcpy(&out_frame[idx], payload, payload_len);
idx += payload_len;
}
uint16_t crc = lora_crc16_ccitt(out_frame, idx);
out_frame[idx++] = static_cast<uint8_t>(crc >> 8);
out_frame[idx++] = static_cast<uint8_t>(crc & 0xFF);
out_len = idx;
return true;
}
LoraFrameDecodeStatus lora_parse_frame(const uint8_t *frame, size_t frame_len, uint8_t max_msg_kind, uint8_t *out_msg_kind,
uint16_t *out_device_id_short, uint8_t *out_payload, size_t payload_cap,
size_t *out_payload_len) {
if (!frame || !out_msg_kind || !out_device_id_short || !out_payload_len) {
return LoraFrameDecodeStatus::LengthMismatch;
}
if (frame_len < 5) {
return LoraFrameDecodeStatus::LengthMismatch;
}
size_t payload_len = frame_len - 5;
if (payload_len > payload_cap || (payload_len > 0 && !out_payload)) {
return LoraFrameDecodeStatus::LengthMismatch;
}
uint16_t crc_calc = lora_crc16_ccitt(frame, frame_len - 2);
uint16_t crc_rx = static_cast<uint16_t>(frame[frame_len - 2] << 8) | frame[frame_len - 1];
if (crc_calc != crc_rx) {
return LoraFrameDecodeStatus::CrcFail;
}
uint8_t msg_kind = frame[0];
if (msg_kind > max_msg_kind) {
return LoraFrameDecodeStatus::InvalidMsgKind;
}
*out_msg_kind = msg_kind;
*out_device_id_short = static_cast<uint16_t>(frame[1] << 8) | frame[2];
if (payload_len > 0) {
memcpy(out_payload, &frame[3], payload_len);
}
*out_payload_len = payload_len;
return LoraFrameDecodeStatus::Ok;
}

28
src/app_context.h Normal file
View File

@@ -0,0 +1,28 @@
#pragma once
#include <Arduino.h>
#include "config.h"
#include "data_model.h"
struct ReceiverSharedState {
SenderStatus sender_statuses[NUM_SENDERS];
FaultCounters sender_faults_remote[NUM_SENDERS];
FaultCounters sender_faults_remote_published[NUM_SENDERS];
FaultType sender_last_error_remote[NUM_SENDERS];
FaultType sender_last_error_remote_published[NUM_SENDERS];
uint32_t sender_last_error_remote_utc[NUM_SENDERS];
uint32_t sender_last_error_remote_ms[NUM_SENDERS];
bool sender_discovery_sent[NUM_SENDERS];
uint16_t last_batch_id_rx[NUM_SENDERS];
FaultCounters receiver_faults;
FaultCounters receiver_faults_published;
FaultType receiver_last_error;
FaultType receiver_last_error_published;
uint32_t receiver_last_error_utc;
uint32_t receiver_last_error_ms;
bool receiver_discovery_sent;
bool ap_mode;
};

View File

@@ -1,4 +1,5 @@
#include "lora_transport.h" #include "lora_transport.h"
#include "lora_frame_logic.h"
#include <LoRa.h> #include <LoRa.h>
#include <SPI.h> #include <SPI.h>
#include <math.h> #include <math.h>
@@ -35,21 +36,6 @@ bool lora_get_last_rx_signal(int16_t &rssi_dbm, float &snr_db) {
return true; return true;
} }
static uint16_t crc16_ccitt(const uint8_t *data, size_t len) {
uint16_t crc = 0xFFFF;
for (size_t i = 0; i < len; ++i) {
crc ^= static_cast<uint16_t>(data[i]) << 8;
for (uint8_t b = 0; b < 8; ++b) {
if (crc & 0x8000) {
crc = (crc << 1) ^ 0x1021;
} else {
crc <<= 1;
}
}
}
return crc;
}
void lora_init() { void lora_init() {
SPI.begin(PIN_LORA_SCK, PIN_LORA_MISO, PIN_LORA_MOSI, PIN_LORA_NSS); SPI.begin(PIN_LORA_SCK, PIN_LORA_MISO, PIN_LORA_MOSI, PIN_LORA_NSS);
LoRa.setPins(PIN_LORA_NSS, PIN_LORA_RST, PIN_LORA_DIO0); LoRa.setPins(PIN_LORA_NSS, PIN_LORA_RST, PIN_LORA_DIO0);
@@ -66,54 +52,35 @@ bool lora_send(const LoraPacket &pkt) {
return true; return true;
} }
uint32_t t0 = 0; uint32_t t0 = 0;
uint32_t t1 = 0;
uint32_t t2 = 0;
uint32_t t3 = 0;
uint32_t t4 = 0;
if (SERIAL_DEBUG_MODE) { if (SERIAL_DEBUG_MODE) {
t0 = millis(); t0 = millis();
} }
LoRa.idle(); LoRa.idle();
if (SERIAL_DEBUG_MODE) {
t1 = millis();
}
uint8_t buffer[1 + 2 + LORA_MAX_PAYLOAD + 2];
size_t idx = 0;
buffer[idx++] = static_cast<uint8_t>(pkt.msg_kind);
buffer[idx++] = static_cast<uint8_t>(pkt.device_id_short >> 8);
buffer[idx++] = static_cast<uint8_t>(pkt.device_id_short & 0xFF);
if (pkt.payload_len > LORA_MAX_PAYLOAD) { if (pkt.payload_len > LORA_MAX_PAYLOAD) {
return false; return false;
} }
memcpy(&buffer[idx], pkt.payload, pkt.payload_len); uint8_t buffer[1 + 2 + LORA_MAX_PAYLOAD + 2];
idx += pkt.payload_len; size_t frame_len = 0;
if (!lora_build_frame(static_cast<uint8_t>(pkt.msg_kind), pkt.device_id_short, pkt.payload, pkt.payload_len,
uint16_t crc = crc16_ccitt(buffer, idx); buffer, sizeof(buffer), frame_len)) {
buffer[idx++] = static_cast<uint8_t>(crc >> 8); return false;
buffer[idx++] = static_cast<uint8_t>(crc & 0xFF); }
LoRa.beginPacket(); LoRa.beginPacket();
if (SERIAL_DEBUG_MODE) { LoRa.write(buffer, frame_len);
t2 = millis();
}
LoRa.write(buffer, idx);
if (SERIAL_DEBUG_MODE) {
t3 = millis();
}
int result = LoRa.endPacket(false); int result = LoRa.endPacket(false);
bool ok = result == 1;
if (SERIAL_DEBUG_MODE) { if (SERIAL_DEBUG_MODE) {
t4 = millis(); uint32_t tx_ms = millis() - t0;
Serial.printf("lora_tx: idle=%lums begin=%lums write=%lums end=%lums total=%lums len=%u\n", if (!ok || tx_ms > 2000) {
static_cast<unsigned long>(t1 - t0), Serial.printf("lora_tx: len=%u total=%lums ok=%u\n",
static_cast<unsigned long>(t2 - t1), static_cast<unsigned>(frame_len),
static_cast<unsigned long>(t3 - t2), static_cast<unsigned long>(tx_ms),
static_cast<unsigned long>(t4 - t3), ok ? 1U : 0U);
static_cast<unsigned long>(t4 - t0),
static_cast<unsigned>(idx));
} }
return result == 1; }
return ok;
} }
bool lora_receive(LoraPacket &pkt, uint32_t timeout_ms) { bool lora_receive(LoraPacket &pkt, uint32_t timeout_ms) {
@@ -154,26 +121,33 @@ bool lora_receive(LoraPacket &pkt, uint32_t timeout_ms) {
return false; return false;
} }
uint16_t crc_calc = crc16_ccitt(buffer, len - 2); uint8_t msg_kind = 0;
uint16_t crc_rx = static_cast<uint16_t>(buffer[len - 2] << 8) | buffer[len - 1]; uint16_t device_id_short = 0;
if (crc_calc != crc_rx) { size_t payload_len = 0;
LoraFrameDecodeStatus status = lora_parse_frame(
buffer, len, static_cast<uint8_t>(LoraMsgKind::AckDown), &msg_kind, &device_id_short,
pkt.payload, sizeof(pkt.payload), &payload_len);
if (status == LoraFrameDecodeStatus::CrcFail) {
note_reject(RxRejectReason::CrcFail); note_reject(RxRejectReason::CrcFail);
return false; return false;
} }
uint8_t msg_kind = buffer[0]; if (status == LoraFrameDecodeStatus::InvalidMsgKind) {
if (msg_kind > static_cast<uint8_t>(LoraMsgKind::AckDown)) {
note_reject(RxRejectReason::InvalidMsgKind); note_reject(RxRejectReason::InvalidMsgKind);
return false; return false;
} }
if (status == LoraFrameDecodeStatus::LengthMismatch) {
note_reject(RxRejectReason::LengthMismatch);
return false;
}
pkt.msg_kind = static_cast<LoraMsgKind>(msg_kind); pkt.msg_kind = static_cast<LoraMsgKind>(msg_kind);
pkt.device_id_short = static_cast<uint16_t>(buffer[1] << 8) | buffer[2]; pkt.device_id_short = device_id_short;
pkt.payload_len = len - 5; pkt.payload_len = payload_len;
if (pkt.payload_len > LORA_MAX_PAYLOAD) { if (pkt.payload_len > LORA_MAX_PAYLOAD) {
note_reject(RxRejectReason::LengthMismatch); note_reject(RxRejectReason::LengthMismatch);
return false; return false;
} }
memcpy(pkt.payload, &buffer[3], pkt.payload_len);
pkt.rssi_dbm = g_last_rx_rssi_dbm; pkt.rssi_dbm = g_last_rx_rssi_dbm;
pkt.snr_db = g_last_rx_snr_db; pkt.snr_db = g_last_rx_snr_db;
return true; return true;

File diff suppressed because it is too large Load Diff

View File

@@ -24,6 +24,8 @@ static uint32_t g_frames_parse_fail = 0;
static uint32_t g_rx_overflow = 0; static uint32_t g_rx_overflow = 0;
static uint32_t g_rx_timeout = 0; static uint32_t g_rx_timeout = 0;
static uint32_t g_last_log_ms = 0; static uint32_t g_last_log_ms = 0;
static uint32_t g_last_good_frame_ms = 0;
static constexpr uint32_t METER_FIXED_FRAC_MAX_DIV = 10000;
void meter_init() { void meter_init() {
#ifdef ARDUINO_ARCH_ESP32 #ifdef ARDUINO_ARCH_ESP32
@@ -33,47 +35,138 @@ void meter_init() {
Serial2.begin(9600, SERIAL_7E1, PIN_METER_RX, -1); Serial2.begin(9600, SERIAL_7E1, PIN_METER_RX, -1);
} }
static bool parse_obis_ascii_value(const char *line, const char *obis, float &out_value) { enum class ObisField : uint8_t {
const char *p = strstr(line, obis); None = 0,
if (!p) { Energy = 1,
TotalPower = 2,
Phase1 = 3,
Phase2 = 4,
Phase3 = 5,
MeterSeconds = 6
};
static ObisField detect_obis_field(const char *line) {
if (!line) {
return ObisField::None;
}
const char *p = line;
while (*p == ' ' || *p == '\t') {
++p;
}
if (strncmp(p, "1-0:1.8.0", 9) == 0) {
return ObisField::Energy;
}
if (strncmp(p, "1-0:16.7.0", 10) == 0) {
return ObisField::TotalPower;
}
if (strncmp(p, "1-0:36.7.0", 10) == 0) {
return ObisField::Phase1;
}
if (strncmp(p, "1-0:56.7.0", 10) == 0) {
return ObisField::Phase2;
}
if (strncmp(p, "1-0:76.7.0", 10) == 0) {
return ObisField::Phase3;
}
if (strncmp(p, "0-0:96.8.0*255", 14) == 0) {
return ObisField::MeterSeconds;
}
return ObisField::None;
}
static bool parse_decimal_fixed(const char *start, const char *end, float &out_value) {
if (!start || !end || end <= start) {
return false; return false;
} }
const char *lparen = strchr(p, '(');
if (!lparen) { const char *cur = start;
return false; bool started = false;
} bool negative = false;
const char *cur = lparen + 1; bool in_fraction = false;
char num_buf[24]; bool saw_digit = false;
size_t n = 0; uint64_t int_part = 0;
while (*cur && *cur != ')' && *cur != '*') { uint32_t frac_part = 0;
uint32_t frac_div = 1;
while (cur < end) {
char c = *cur++; char c = *cur++;
if ((c >= '0' && c <= '9') || c == '-' || c == '+' || c == '.' || c == ',') { if (!started) {
if (c == ',') { if (c == '+' || c == '-') {
c = '.'; started = true;
} negative = (c == '-');
if (n + 1 < sizeof(num_buf)) {
num_buf[n++] = c;
}
} else if (n == 0) {
continue; continue;
} else { }
if (c >= '0' && c <= '9') {
started = true;
saw_digit = true;
int_part = static_cast<uint64_t>(c - '0');
continue;
}
if (c == '.' || c == ',') {
started = true;
in_fraction = true;
continue;
}
continue;
}
if (c >= '0' && c <= '9') {
saw_digit = true;
uint32_t digit = static_cast<uint32_t>(c - '0');
if (!in_fraction) {
if (int_part <= (UINT64_MAX - digit) / 10ULL) {
int_part = int_part * 10ULL + digit;
}
} else if (frac_div < METER_FIXED_FRAC_MAX_DIV) {
frac_part = frac_part * 10U + digit;
frac_div *= 10U;
}
continue;
}
if ((c == '.' || c == ',') && !in_fraction) {
in_fraction = true;
continue;
}
break; break;
} }
}
if (n == 0) { if (!saw_digit) {
return false; return false;
} }
num_buf[n] = '\0'; double value = static_cast<double>(int_part);
out_value = static_cast<float>(atof(num_buf)); if (frac_div > 1U) {
value += static_cast<double>(frac_part) / static_cast<double>(frac_div);
}
if (negative) {
value = -value;
}
out_value = static_cast<float>(value);
return true; return true;
} }
static bool parse_obis_ascii_unit_scale(const char *line, const char *obis, float &value) { static bool parse_obis_ascii_payload_value(const char *line, float &out_value) {
const char *p = strstr(line, obis); const char *lparen = strchr(line, '(');
if (!p) { if (!lparen) {
return false; return false;
} }
const char *asterisk = strchr(p, '*'); const char *end = lparen + 1;
while (*end && *end != ')' && *end != '*') {
++end;
}
if (end <= lparen + 1) {
return false;
}
return parse_decimal_fixed(lparen + 1, end, out_value);
}
static bool parse_obis_ascii_unit_scale(const char *line, float &value) {
const char *lparen = strchr(line, '(');
if (!lparen) {
return false;
}
const char *asterisk = strchr(lparen, '*');
if (!asterisk) { if (!asterisk) {
return false; return false;
} }
@@ -113,12 +206,8 @@ static int8_t hex_nibble(char c) {
return -1; return -1;
} }
static bool parse_obis_hex_u32(const char *line, const char *obis, uint32_t &out_value) { static bool parse_obis_hex_payload_u32(const char *line, uint32_t &out_value) {
const char *p = strstr(line, obis); const char *lparen = strchr(line, '(');
if (!p) {
return false;
}
const char *lparen = strchr(p, '(');
if (!lparen) { if (!lparen) {
return false; return false;
} }
@@ -163,6 +252,16 @@ static void meter_debug_log() {
static_cast<unsigned long>(g_bytes_rx)); static_cast<unsigned long>(g_bytes_rx));
} }
void meter_get_stats(MeterDriverStats &out) {
out.frames_ok = g_frames_ok;
out.frames_parse_fail = g_frames_parse_fail;
out.rx_overflow = g_rx_overflow;
out.rx_timeout = g_rx_timeout;
out.bytes_rx = g_bytes_rx;
out.last_rx_ms = g_last_rx_ms;
out.last_good_frame_ms = g_last_good_frame_ms;
}
bool meter_poll_frame(const char *&frame, size_t &len) { bool meter_poll_frame(const char *&frame, size_t &len) {
frame = nullptr; frame = nullptr;
len = 0; len = 0;
@@ -244,6 +343,7 @@ bool meter_parse_frame(const char *frame, size_t len, MeterData &data) {
data.valid = energy_ok || total_p_ok || p1_ok || p2_ok || p3_ok; data.valid = energy_ok || total_p_ok || p1_ok || p2_ok || p3_ok;
if (data.valid) { if (data.valid) {
g_frames_ok++; g_frames_ok++;
g_last_good_frame_ms = millis();
} else { } else {
g_frames_parse_fail++; g_frames_parse_fail++;
} }
@@ -255,44 +355,69 @@ bool meter_parse_frame(const char *frame, size_t len, MeterData &data) {
data.valid = energy_ok || total_p_ok || p1_ok || p2_ok || p3_ok; data.valid = energy_ok || total_p_ok || p1_ok || p2_ok || p3_ok;
if (data.valid) { if (data.valid) {
g_frames_ok++; g_frames_ok++;
g_last_good_frame_ms = millis();
} else { } else {
g_frames_parse_fail++; g_frames_parse_fail++;
} }
return data.valid; return data.valid;
} }
ObisField field = detect_obis_field(line);
float value = NAN; float value = NAN;
if (parse_obis_ascii_value(line, "1-0:1.8.0", value)) { uint32_t meter_seconds = 0;
parse_obis_ascii_unit_scale(line, "1-0:1.8.0", value); switch (field) {
case ObisField::Energy:
if (parse_obis_ascii_payload_value(line, value)) {
parse_obis_ascii_unit_scale(line, value);
data.energy_total_kwh = value; data.energy_total_kwh = value;
energy_ok = true; energy_ok = true;
got_any = true; got_any = true;
} }
if (parse_obis_ascii_value(line, "1-0:16.7.0", value)) { break;
case ObisField::TotalPower:
if (parse_obis_ascii_payload_value(line, value)) {
data.total_power_w = value; data.total_power_w = value;
total_p_ok = true; total_p_ok = true;
got_any = true; got_any = true;
} }
if (parse_obis_ascii_value(line, "1-0:36.7.0", value)) { break;
case ObisField::Phase1:
if (parse_obis_ascii_payload_value(line, value)) {
data.phase_power_w[0] = value; data.phase_power_w[0] = value;
p1_ok = true; p1_ok = true;
got_any = true; got_any = true;
} }
if (parse_obis_ascii_value(line, "1-0:56.7.0", value)) { break;
case ObisField::Phase2:
if (parse_obis_ascii_payload_value(line, value)) {
data.phase_power_w[1] = value; data.phase_power_w[1] = value;
p2_ok = true; p2_ok = true;
got_any = true; got_any = true;
} }
if (parse_obis_ascii_value(line, "1-0:76.7.0", value)) { break;
case ObisField::Phase3:
if (parse_obis_ascii_payload_value(line, value)) {
data.phase_power_w[2] = value; data.phase_power_w[2] = value;
p3_ok = true; p3_ok = true;
got_any = true; got_any = true;
} }
uint32_t meter_seconds = 0; break;
if (parse_obis_hex_u32(line, "0-0:96.8.0*255", meter_seconds)) { case ObisField::MeterSeconds:
if (parse_obis_hex_payload_u32(line, meter_seconds)) {
data.meter_seconds = meter_seconds; data.meter_seconds = meter_seconds;
data.meter_seconds_valid = true; data.meter_seconds_valid = true;
} }
break;
default:
break;
}
if (energy_ok && total_p_ok && p1_ok && p2_ok && p3_ok && data.meter_seconds_valid) {
data.valid = true;
g_frames_ok++;
g_last_good_frame_ms = millis();
return true;
}
line_len = 0; line_len = 0;
continue; continue;
@@ -305,6 +430,7 @@ bool meter_parse_frame(const char *frame, size_t len, MeterData &data) {
data.valid = got_any; data.valid = got_any;
if (data.valid) { if (data.valid) {
g_frames_ok++; g_frames_ok++;
g_last_good_frame_ms = millis();
} else { } else {
g_frames_parse_fail++; g_frames_parse_fail++;
} }

View File

@@ -2,6 +2,7 @@
#include <WiFi.h> #include <WiFi.h>
#include <PubSubClient.h> #include <PubSubClient.h>
#include <ArduinoJson.h> #include <ArduinoJson.h>
#include "ha_discovery_json.h"
#include "config.h" #include "config.h"
#include "json_codec.h" #include "json_codec.h"
@@ -10,6 +11,13 @@ static PubSubClient mqtt_client(wifi_client);
static WifiMqttConfig g_cfg; static WifiMqttConfig g_cfg;
static String g_client_id; static String g_client_id;
static const char *ha_manufacturer_anchor() {
StaticJsonDocument<32> doc;
JsonObject device = doc.createNestedObject("device");
device["manufacturer"] = HA_MANUFACTURER;
return HA_MANUFACTURER;
}
static const char *fault_text(FaultType fault) { static const char *fault_text(FaultType fault) {
switch (fault) { switch (fault) {
case FaultType::MeterRead: case FaultType::MeterRead:
@@ -94,31 +102,9 @@ bool mqtt_publish_faults(const char *device_id, const FaultCounters &counters, F
static bool publish_discovery_sensor(const char *device_id, const char *key, const char *name, const char *unit, const char *device_class, static bool publish_discovery_sensor(const char *device_id, const char *key, const char *name, const char *unit, const char *device_class,
const char *state_topic, const char *value_template) { const char *state_topic, const char *value_template) {
StaticJsonDocument<256> doc;
String unique_id = String("dd3_") + device_id + "_" + key;
String sensor_name = String(device_id) + " " + name;
doc["name"] = sensor_name;
doc["state_topic"] = state_topic;
doc["unique_id"] = unique_id;
if (unit && unit[0] != '\0') {
doc["unit_of_measurement"] = unit;
}
if (device_class && device_class[0] != '\0') {
doc["device_class"] = device_class;
}
doc["value_template"] = value_template;
JsonObject device = doc.createNestedObject("device");
JsonArray identifiers = device.createNestedArray("identifiers");
identifiers.add(String("dd3-") + device_id);
device["name"] = String("DD3 ") + device_id;
device["model"] = "DD3-LoRa-Bridge";
device["manufacturer"] = "DD3";
String payload; String payload;
size_t len = serializeJson(doc, payload); if (!ha_build_discovery_sensor_payload(device_id, key, name, unit, device_class, state_topic, value_template,
if (len == 0) { ha_manufacturer_anchor(), payload)) {
return false; return false;
} }

523
src/receiver_pipeline.cpp Normal file
View File

@@ -0,0 +1,523 @@
#include "receiver_pipeline.h"
#include <Arduino.h>
#include <math.h>
#include <stdarg.h>
#include "config.h"
#include "batch_reassembly_logic.h"
#include "display_ui.h"
#include "json_codec.h"
#include "lora_transport.h"
#include "mqtt_client.h"
#include "payload_codec.h"
#include "power_manager.h"
#include "sd_logger.h"
#include "time_manager.h"
#include "web_server.h"
#include "wifi_manager.h"
#ifdef ARDUINO_ARCH_ESP32
#include <esp_task_wdt.h>
#endif
namespace {
static uint16_t g_short_id = 0;
static char g_device_id[16] = "";
static ReceiverSharedState *g_shared = nullptr;
static RxRejectReason g_receiver_rx_reject_reason = RxRejectReason::None;
static uint32_t g_receiver_rx_reject_log_ms = 0;
#define g_sender_statuses (g_shared->sender_statuses)
#define g_sender_faults_remote (g_shared->sender_faults_remote)
#define g_sender_faults_remote_published (g_shared->sender_faults_remote_published)
#define g_sender_last_error_remote (g_shared->sender_last_error_remote)
#define g_sender_last_error_remote_published (g_shared->sender_last_error_remote_published)
#define g_sender_last_error_remote_utc (g_shared->sender_last_error_remote_utc)
#define g_sender_last_error_remote_ms (g_shared->sender_last_error_remote_ms)
#define g_sender_discovery_sent (g_shared->sender_discovery_sent)
#define g_last_batch_id_rx (g_shared->last_batch_id_rx)
#define g_receiver_faults (g_shared->receiver_faults)
#define g_receiver_faults_published (g_shared->receiver_faults_published)
#define g_receiver_last_error (g_shared->receiver_last_error)
#define g_receiver_last_error_published (g_shared->receiver_last_error_published)
#define g_receiver_last_error_utc (g_shared->receiver_last_error_utc)
#define g_receiver_last_error_ms (g_shared->receiver_last_error_ms)
#define g_receiver_discovery_sent (g_shared->receiver_discovery_sent)
#define g_ap_mode (g_shared->ap_mode)
static void watchdog_kick() {
#ifdef ARDUINO_ARCH_ESP32
esp_task_wdt_reset();
#endif
}
static constexpr size_t BATCH_HEADER_SIZE = 6;
static constexpr size_t BATCH_CHUNK_PAYLOAD = LORA_MAX_PAYLOAD - BATCH_HEADER_SIZE;
static constexpr size_t BATCH_MAX_COMPRESSED = 4096;
static constexpr uint32_t BATCH_RX_MARGIN_MS = 800;
static void serial_debug_printf(const char *fmt, ...) {
if (!SERIAL_DEBUG_MODE) {
return;
}
char buf[256];
va_list args;
va_start(args, fmt);
vsnprintf(buf, sizeof(buf), fmt, args);
va_end(args);
Serial.println(buf);
}
static uint8_t bit_count32(uint32_t value) {
uint8_t count = 0;
while (value != 0) {
value &= (value - 1);
count++;
}
return count;
}
static bool mqtt_publish_sample(const MeterData &data) {
#ifdef ENABLE_TEST_MODE
String payload;
if (!meterDataToJson(data, payload)) {
return false;
}
return mqtt_publish_test(data.device_id, payload);
#else
return mqtt_publish_state(data);
#endif
}
static BatchReassemblyState g_batch_rx = {};
static uint8_t g_batch_rx_buffer[BATCH_MAX_COMPRESSED] = {};
static void init_sender_statuses() {
for (uint8_t i = 0; i < NUM_SENDERS; ++i) {
g_sender_statuses[i] = {};
g_sender_statuses[i].has_data = false;
g_sender_statuses[i].last_update_ts_utc = 0;
g_sender_statuses[i].rx_batches_total = 0;
g_sender_statuses[i].rx_batches_duplicate = 0;
g_sender_statuses[i].rx_last_duplicate_ts_utc = 0;
g_sender_statuses[i].last_data.short_id = EXPECTED_SENDER_IDS[i];
snprintf(g_sender_statuses[i].last_data.device_id, sizeof(g_sender_statuses[i].last_data.device_id), "dd3-%04X", EXPECTED_SENDER_IDS[i]);
g_sender_faults_remote[i] = {};
g_sender_faults_remote_published[i] = {};
g_sender_last_error_remote[i] = FaultType::None;
g_sender_last_error_remote_published[i] = FaultType::None;
g_sender_last_error_remote_utc[i] = 0;
g_sender_last_error_remote_ms[i] = 0;
g_sender_discovery_sent[i] = false;
}
}
static void receiver_note_rx_reject(RxRejectReason reason, const char *context) {
if (reason == RxRejectReason::None) {
return;
}
g_receiver_rx_reject_reason = reason;
uint32_t now_ms = millis();
if (SERIAL_DEBUG_MODE && now_ms - g_receiver_rx_reject_log_ms >= 1000) {
g_receiver_rx_reject_log_ms = now_ms;
serial_debug_printf("rx_reject: %s reason=%s", context, rx_reject_reason_text(reason));
}
}
static void note_fault(FaultCounters &counters, FaultType &last_type, uint32_t &last_ts_utc, uint32_t &last_ts_ms, FaultType type) {
if (type == FaultType::MeterRead) {
counters.meter_read_fail++;
} else if (type == FaultType::Decode) {
counters.decode_fail++;
} else if (type == FaultType::LoraTx) {
counters.lora_tx_fail++;
}
last_type = type;
last_ts_utc = time_get_utc();
last_ts_ms = millis();
}
static void clear_faults(FaultCounters &counters, FaultType &last_type, uint32_t &last_ts_utc, uint32_t &last_ts_ms) {
counters = {};
last_type = FaultType::None;
last_ts_utc = 0;
last_ts_ms = 0;
}
static uint32_t age_seconds(uint32_t ts_utc, uint32_t ts_ms) {
if (time_is_synced() && ts_utc > 0) {
uint32_t now = time_get_utc();
return now > ts_utc ? now - ts_utc : 0;
}
return (millis() - ts_ms) / 1000;
}
static bool counters_changed(const FaultCounters &a, const FaultCounters &b) {
return a.meter_read_fail != b.meter_read_fail || a.decode_fail != b.decode_fail || a.lora_tx_fail != b.lora_tx_fail;
}
static void publish_faults_if_needed(const char *device_id, const FaultCounters &counters, FaultCounters &last_published,
FaultType last_error, FaultType &last_error_published, uint32_t last_error_utc, uint32_t last_error_ms) {
if (!mqtt_is_connected()) {
return;
}
if (!counters_changed(counters, last_published) && last_error == last_error_published) {
return;
}
uint32_t age = last_error != FaultType::None ? age_seconds(last_error_utc, last_error_ms) : 0;
if (mqtt_publish_faults(device_id, counters, last_error, age)) {
last_published = counters;
last_error_published = last_error;
}
}
static void write_u16_le(uint8_t *dst, uint16_t value) {
dst[0] = static_cast<uint8_t>(value & 0xFF);
dst[1] = static_cast<uint8_t>((value >> 8) & 0xFF);
}
static uint16_t read_u16_le(const uint8_t *src) {
return static_cast<uint16_t>(src[0]) | (static_cast<uint16_t>(src[1]) << 8);
}
static void write_u16_be(uint8_t *dst, uint16_t value) {
dst[0] = static_cast<uint8_t>((value >> 8) & 0xFF);
dst[1] = static_cast<uint8_t>(value & 0xFF);
}
static uint16_t read_u16_be(const uint8_t *src) {
return static_cast<uint16_t>(src[0] << 8) | static_cast<uint16_t>(src[1]);
}
static void write_u32_be(uint8_t *dst, uint32_t value) {
dst[0] = static_cast<uint8_t>((value >> 24) & 0xFF);
dst[1] = static_cast<uint8_t>((value >> 16) & 0xFF);
dst[2] = static_cast<uint8_t>((value >> 8) & 0xFF);
dst[3] = static_cast<uint8_t>(value & 0xFF);
}
static uint32_t read_u32_be(const uint8_t *src) {
return (static_cast<uint32_t>(src[0]) << 24) |
(static_cast<uint32_t>(src[1]) << 16) |
(static_cast<uint32_t>(src[2]) << 8) |
static_cast<uint32_t>(src[3]);
}
static uint16_t sender_id_from_short_id(uint16_t short_id) {
for (uint8_t i = 0; i < NUM_SENDERS; ++i) {
if (EXPECTED_SENDER_IDS[i] == short_id) {
return static_cast<uint16_t>(i + 1);
}
}
return 0;
}
static uint16_t short_id_from_sender_id(uint16_t sender_id) {
if (sender_id == 0 || sender_id > NUM_SENDERS) {
return 0;
}
return EXPECTED_SENDER_IDS[sender_id - 1];
}
static uint32_t compute_batch_rx_timeout_ms(uint16_t total_len, uint8_t chunk_count) {
if (total_len == 0 || chunk_count == 0) {
return 10000;
}
size_t max_chunk_payload = total_len > BATCH_CHUNK_PAYLOAD ? BATCH_CHUNK_PAYLOAD : total_len;
size_t payload_len = BATCH_HEADER_SIZE + max_chunk_payload;
size_t packet_len = 3 + payload_len + 2;
uint32_t per_chunk_toa_ms = lora_airtime_ms(packet_len);
uint32_t timeout_ms = static_cast<uint32_t>(chunk_count) * per_chunk_toa_ms + BATCH_RX_MARGIN_MS;
return timeout_ms < 10000 ? 10000 : timeout_ms;
}
static void send_batch_ack(uint16_t batch_id, uint8_t sample_count) {
uint32_t epoch = time_get_utc();
uint8_t time_valid = (time_is_synced() && epoch >= MIN_ACCEPTED_EPOCH_UTC) ? 1 : 0;
if (!time_valid) {
epoch = 0;
}
LoraPacket ack = {};
ack.msg_kind = LoraMsgKind::AckDown;
ack.device_id_short = g_short_id;
ack.payload_len = LORA_ACK_DOWN_PAYLOAD_LEN;
ack.payload[0] = time_valid;
write_u16_be(&ack.payload[1], batch_id);
write_u32_be(&ack.payload[3], epoch);
uint8_t repeats = ACK_REPEAT_COUNT == 0 ? 1 : ACK_REPEAT_COUNT;
for (uint8_t i = 0; i < repeats; ++i) {
lora_send(ack);
if (i + 1 < repeats && ACK_REPEAT_DELAY_MS > 0) {
delay(ACK_REPEAT_DELAY_MS);
}
}
serial_debug_printf("ack: tx batch_id=%u time_valid=%u epoch=%lu samples=%u",
batch_id,
static_cast<unsigned>(time_valid),
static_cast<unsigned long>(epoch),
static_cast<unsigned>(sample_count));
lora_receive_continuous();
}
static void reset_batch_rx() {
batch_reassembly_reset(g_batch_rx);
}
static bool process_batch_packet(const LoraPacket &pkt, BatchInput &out_batch, bool &decode_error, uint16_t &out_batch_id) {
decode_error = false;
if (pkt.payload_len < BATCH_HEADER_SIZE) {
return false;
}
uint16_t batch_id = read_u16_le(&pkt.payload[0]);
uint8_t chunk_index = pkt.payload[2];
uint8_t chunk_count = pkt.payload[3];
uint16_t total_len = read_u16_le(&pkt.payload[4]);
const uint8_t *chunk_data = &pkt.payload[BATCH_HEADER_SIZE];
size_t chunk_len = pkt.payload_len - BATCH_HEADER_SIZE;
uint32_t now_ms = millis();
uint16_t complete_len = 0;
BatchReassemblyStatus reassembly_status = batch_reassembly_push(
g_batch_rx, batch_id, chunk_index, chunk_count, total_len, chunk_data, chunk_len, now_ms,
compute_batch_rx_timeout_ms(total_len, chunk_count), BATCH_MAX_COMPRESSED, g_batch_rx_buffer,
sizeof(g_batch_rx_buffer), complete_len);
if (reassembly_status == BatchReassemblyStatus::ErrorReset) {
return false;
}
if (reassembly_status == BatchReassemblyStatus::InProgress) {
return false;
}
if (reassembly_status == BatchReassemblyStatus::Complete) {
if (!decode_batch(g_batch_rx_buffer, complete_len, &out_batch)) {
decode_error = true;
return false;
}
out_batch_id = batch_id;
return true;
}
return false;
}
static void receiver_loop() {
watchdog_kick();
LoraPacket pkt = {};
if (lora_receive(pkt, 0)) {
if (pkt.msg_kind == LoraMsgKind::BatchUp) {
BatchInput batch = {};
bool decode_error = false;
uint16_t batch_id = 0;
if (process_batch_packet(pkt, batch, decode_error, batch_id)) {
int8_t sender_idx = -1;
for (uint8_t i = 0; i < NUM_SENDERS; ++i) {
if (pkt.device_id_short == EXPECTED_SENDER_IDS[i]) {
sender_idx = static_cast<int8_t>(i);
break;
}
}
if (sender_idx < 0) {
receiver_note_rx_reject(RxRejectReason::UnknownSender, "batch");
note_fault(g_receiver_faults, g_receiver_last_error, g_receiver_last_error_utc, g_receiver_last_error_ms, FaultType::Decode);
display_set_last_error(g_receiver_last_error, g_receiver_last_error_utc, g_receiver_last_error_ms);
serial_debug_printf("batch: reject unknown_sender short_id=%04X sender_id=%u batch_id=%u",
pkt.device_id_short,
static_cast<unsigned>(batch.sender_id),
static_cast<unsigned>(batch_id));
goto receiver_loop_done;
}
uint16_t expected_sender_id = static_cast<uint16_t>(sender_idx + 1);
if (batch.sender_id != expected_sender_id) {
receiver_note_rx_reject(RxRejectReason::DeviceIdMismatch, "batch");
note_fault(g_receiver_faults, g_receiver_last_error, g_receiver_last_error_utc, g_receiver_last_error_ms, FaultType::Decode);
display_set_last_error(g_receiver_last_error, g_receiver_last_error_utc, g_receiver_last_error_ms);
serial_debug_printf("batch: reject device_id_mismatch short_id=%04X sender_id=%u expected=%u batch_id=%u",
pkt.device_id_short,
static_cast<unsigned>(batch.sender_id),
static_cast<unsigned>(expected_sender_id),
static_cast<unsigned>(batch_id));
goto receiver_loop_done;
}
bool duplicate = g_last_batch_id_rx[sender_idx] == batch_id;
SenderStatus &status = g_sender_statuses[sender_idx];
if (status.rx_batches_total < UINT32_MAX) {
status.rx_batches_total++;
}
if (duplicate) {
if (status.rx_batches_duplicate < UINT32_MAX) {
status.rx_batches_duplicate++;
}
uint32_t duplicate_ts = time_get_utc();
if (duplicate_ts == 0) {
duplicate_ts = batch.t_last;
}
status.rx_last_duplicate_ts_utc = duplicate_ts;
}
send_batch_ack(batch_id, batch.n);
if (duplicate) {
goto receiver_loop_done;
}
g_last_batch_id_rx[sender_idx] = batch_id;
if (batch.n == 0) {
goto receiver_loop_done;
}
if (batch.n > METER_BATCH_MAX_SAMPLES) {
note_fault(g_receiver_faults, g_receiver_last_error, g_receiver_last_error_utc, g_receiver_last_error_ms, FaultType::Decode);
display_set_last_error(g_receiver_last_error, g_receiver_last_error_utc, g_receiver_last_error_ms);
goto receiver_loop_done;
}
if (bit_count32(batch.present_mask) != batch.n) {
note_fault(g_receiver_faults, g_receiver_last_error, g_receiver_last_error_utc, g_receiver_last_error_ms, FaultType::Decode);
display_set_last_error(g_receiver_last_error, g_receiver_last_error_utc, g_receiver_last_error_ms);
goto receiver_loop_done;
}
size_t count = batch.n;
uint16_t short_id = pkt.device_id_short;
if (short_id == 0) {
short_id = short_id_from_sender_id(batch.sender_id);
}
if (batch.t_last < static_cast<uint32_t>(METER_BATCH_MAX_SAMPLES - 1) || batch.t_last < MIN_ACCEPTED_EPOCH_UTC) {
note_fault(g_receiver_faults, g_receiver_last_error, g_receiver_last_error_utc, g_receiver_last_error_ms, FaultType::Decode);
display_set_last_error(g_receiver_last_error, g_receiver_last_error_utc, g_receiver_last_error_ms);
goto receiver_loop_done;
}
const uint32_t window_start = batch.t_last - static_cast<uint32_t>(METER_BATCH_MAX_SAMPLES - 1);
MeterData samples[METER_BATCH_MAX_SAMPLES];
float bat_v = batch.battery_mV > 0 ? static_cast<float>(batch.battery_mV) / 1000.0f : NAN;
size_t s = 0;
for (uint8_t slot = 0; slot < METER_BATCH_MAX_SAMPLES; ++slot) {
if ((batch.present_mask & (1UL << slot)) == 0) {
continue;
}
if (s >= count) {
note_fault(g_receiver_faults, g_receiver_last_error, g_receiver_last_error_utc, g_receiver_last_error_ms, FaultType::Decode);
display_set_last_error(g_receiver_last_error, g_receiver_last_error_utc, g_receiver_last_error_ms);
goto receiver_loop_done;
}
MeterData &data = samples[s];
data = {};
data.short_id = short_id;
if (short_id != 0) {
snprintf(data.device_id, sizeof(data.device_id), "dd3-%04X", short_id);
} else {
snprintf(data.device_id, sizeof(data.device_id), "dd3-0000");
}
data.ts_utc = window_start + static_cast<uint32_t>(slot);
if (data.ts_utc < MIN_ACCEPTED_EPOCH_UTC) {
note_fault(g_receiver_faults, g_receiver_last_error, g_receiver_last_error_utc, g_receiver_last_error_ms, FaultType::Decode);
display_set_last_error(g_receiver_last_error, g_receiver_last_error_utc, g_receiver_last_error_ms);
goto receiver_loop_done;
}
data.energy_total_kwh = static_cast<float>(batch.energy_wh[s]) / 1000.0f;
data.phase_power_w[0] = static_cast<float>(batch.p1_w[s]);
data.phase_power_w[1] = static_cast<float>(batch.p2_w[s]);
data.phase_power_w[2] = static_cast<float>(batch.p3_w[s]);
data.total_power_w = data.phase_power_w[0] + data.phase_power_w[1] + data.phase_power_w[2];
data.battery_voltage_v = bat_v;
data.battery_percent = !isnan(bat_v) ? battery_percent_from_voltage(bat_v) : 0;
data.valid = true;
data.link_valid = true;
data.link_rssi_dbm = pkt.rssi_dbm;
data.link_snr_db = pkt.snr_db;
data.err_meter_read = batch.err_m;
data.err_decode = batch.err_d;
data.err_lora_tx = batch.err_tx;
data.last_error = static_cast<FaultType>(batch.err_last);
data.rx_reject_reason = batch.err_rx_reject;
sd_logger_log_sample(data, (s + 1 == count) && data.last_error != FaultType::None);
s++;
}
if (s != count) {
note_fault(g_receiver_faults, g_receiver_last_error, g_receiver_last_error_utc, g_receiver_last_error_ms, FaultType::Decode);
display_set_last_error(g_receiver_last_error, g_receiver_last_error_utc, g_receiver_last_error_ms);
goto receiver_loop_done;
}
web_server_set_last_batch(static_cast<uint8_t>(sender_idx), samples, count);
for (size_t s = 0; s < count; ++s) {
mqtt_publish_sample(samples[s]);
}
g_sender_statuses[sender_idx].last_data = samples[count - 1];
g_sender_statuses[sender_idx].last_update_ts_utc = samples[count - 1].ts_utc;
g_sender_statuses[sender_idx].has_data = true;
g_sender_faults_remote[sender_idx].meter_read_fail = samples[count - 1].err_meter_read;
g_sender_faults_remote[sender_idx].lora_tx_fail = samples[count - 1].err_lora_tx;
g_sender_last_error_remote[sender_idx] = samples[count - 1].last_error;
g_sender_last_error_remote_utc[sender_idx] = time_get_utc();
g_sender_last_error_remote_ms[sender_idx] = millis();
if (ENABLE_HA_DISCOVERY && !g_sender_discovery_sent[sender_idx]) {
g_sender_discovery_sent[sender_idx] = mqtt_publish_discovery(samples[count - 1].device_id);
}
publish_faults_if_needed(samples[count - 1].device_id, g_sender_faults_remote[sender_idx], g_sender_faults_remote_published[sender_idx],
g_sender_last_error_remote[sender_idx], g_sender_last_error_remote_published[sender_idx],
g_sender_last_error_remote_utc[sender_idx], g_sender_last_error_remote_ms[sender_idx]);
} else if (decode_error) {
note_fault(g_receiver_faults, g_receiver_last_error, g_receiver_last_error_utc, g_receiver_last_error_ms, FaultType::Decode);
display_set_last_error(g_receiver_last_error, g_receiver_last_error_utc, g_receiver_last_error_ms);
}
}
}
receiver_loop_done:
mqtt_loop();
web_server_loop();
if (ENABLE_HA_DISCOVERY && !g_receiver_discovery_sent) {
g_receiver_discovery_sent = mqtt_publish_discovery(g_device_id);
}
publish_faults_if_needed(g_device_id, g_receiver_faults, g_receiver_faults_published,
g_receiver_last_error, g_receiver_last_error_published, g_receiver_last_error_utc, g_receiver_last_error_ms);
display_set_receiver_status(g_ap_mode, wifi_is_connected() ? wifi_get_ssid().c_str() : "AP", mqtt_is_connected());
display_tick();
watchdog_kick();
}
} // namespace
bool ReceiverPipeline::begin(const ReceiverPipelineConfig &config) {
if (!config.shared) {
return false;
}
g_shared = config.shared;
*g_shared = {};
g_short_id = config.short_id;
if (config.device_id) {
strncpy(g_device_id, config.device_id, sizeof(g_device_id));
g_device_id[sizeof(g_device_id) - 1] = '\0';
} else {
g_device_id[0] = '\0';
}
init_sender_statuses();
reset_batch_rx();
g_receiver_rx_reject_reason = RxRejectReason::None;
g_receiver_rx_reject_log_ms = 0;
return true;
}
void ReceiverPipeline::loop() {
if (!g_shared) {
return;
}
receiver_loop();
}
ReceiverStats ReceiverPipeline::stats() const {
ReceiverStats stats = {};
if (!g_shared) {
return stats;
}
stats.receiver_decode_fail = g_receiver_faults.decode_fail;
stats.receiver_lora_tx_fail = g_receiver_faults.lora_tx_fail;
stats.last_rx_reject = g_receiver_rx_reject_reason;
stats.receiver_discovery_sent = g_receiver_discovery_sent;
return stats;
}

27
src/receiver_pipeline.h Normal file
View File

@@ -0,0 +1,27 @@
#pragma once
#include <Arduino.h>
#include "app_context.h"
#include "data_model.h"
struct ReceiverPipelineConfig {
uint16_t short_id;
const char *device_id;
ReceiverSharedState *shared;
};
struct ReceiverStats {
uint32_t receiver_decode_fail;
uint32_t receiver_lora_tx_fail;
RxRejectReason last_rx_reject;
bool receiver_discovery_sent;
};
class ReceiverPipeline {
public:
bool begin(const ReceiverPipelineConfig &config);
void loop();
ReceiverStats stats() const;
};

View File

@@ -27,30 +27,30 @@ static bool ensure_dir(const String &path) {
return SD.mkdir(path); return SD.mkdir(path);
} }
static String format_date_utc(uint32_t ts_utc) { static String format_date_local(uint32_t ts_utc) {
time_t t = static_cast<time_t>(ts_utc); time_t t = static_cast<time_t>(ts_utc);
struct tm tm_utc; struct tm tm_local;
gmtime_r(&t, &tm_utc); localtime_r(&t, &tm_local);
char buf[16]; char buf[16];
snprintf(buf, sizeof(buf), "%04d-%02d-%02d", snprintf(buf, sizeof(buf), "%04d-%02d-%02d",
tm_utc.tm_year + 1900, tm_local.tm_year + 1900,
tm_utc.tm_mon + 1, tm_local.tm_mon + 1,
tm_utc.tm_mday); tm_local.tm_mday);
return String(buf); return String(buf);
} }
static String format_hms_utc(uint32_t ts_utc) { static String format_hms_local(uint32_t ts_utc) {
if (ts_utc == 0) { if (ts_utc == 0) {
return ""; return "";
} }
time_t t = static_cast<time_t>(ts_utc); time_t t = static_cast<time_t>(ts_utc);
struct tm tm_utc; struct tm tm_local;
gmtime_r(&t, &tm_utc); localtime_r(&t, &tm_local);
char buf[16]; char buf[16];
snprintf(buf, sizeof(buf), "%02d:%02d:%02d", snprintf(buf, sizeof(buf), "%02d:%02d:%02d",
tm_utc.tm_hour, tm_local.tm_hour,
tm_utc.tm_min, tm_local.tm_min,
tm_utc.tm_sec); tm_local.tm_sec);
return String(buf); return String(buf);
} }
@@ -94,7 +94,7 @@ void sd_logger_log_sample(const MeterData &data, bool include_error_text) {
return; return;
} }
String filename = sender_dir + "/" + format_date_utc(data.ts_utc) + ".csv"; String filename = sender_dir + "/" + format_date_local(data.ts_utc) + ".csv";
bool new_file = !SD.exists(filename); bool new_file = !SD.exists(filename);
File f = SD.open(filename, FILE_APPEND); File f = SD.open(filename, FILE_APPEND);
if (!f) { if (!f) {
@@ -102,13 +102,13 @@ void sd_logger_log_sample(const MeterData &data, bool include_error_text) {
} }
if (new_file) { if (new_file) {
f.println("ts_utc,ts_hms_utc,p_w,p1_w,p2_w,p3_w,e_kwh,bat_v,bat_pct,rssi,snr,err_m,err_d,err_tx,err_last"); f.println("ts_utc,ts_hms_local,p_w,p1_w,p2_w,p3_w,e_kwh,bat_v,bat_pct,rssi,snr,err_m,err_d,err_tx,err_last");
} }
String ts_hms_utc = format_hms_utc(data.ts_utc); String ts_hms_local = format_hms_local(data.ts_utc);
f.print(data.ts_utc); f.print(data.ts_utc);
f.print(','); f.print(',');
f.print(ts_hms_utc); f.print(ts_hms_local);
f.print(','); f.print(',');
f.print(data.total_power_w, 1); f.print(data.total_power_w, 1);
f.print(','); f.print(',');

1604
src/sender_state_machine.cpp Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,44 @@
#pragma once
#include <Arduino.h>
struct SenderStateMachineConfig {
uint16_t short_id;
const char *device_id;
};
struct SenderStats {
uint8_t queue_depth;
uint8_t build_count;
uint16_t inflight_batch_id;
uint16_t last_sent_batch_id;
uint16_t last_acked_batch_id;
uint8_t retry_count;
bool ack_pending;
uint32_t ack_timeout_total;
uint32_t ack_retry_total;
uint32_t ack_miss_streak;
uint32_t rx_window_ms;
uint32_t sleep_ms;
};
class SenderStateMachine {
public:
bool begin(const SenderStateMachineConfig &config);
void loop();
SenderStats stats() const;
private:
enum class State : uint8_t {
Syncing = 0,
Normal = 1,
Catchup = 2,
WaitAck = 3
};
void handleMeterRead(uint32_t now_ms);
void maybeSendBatch(uint32_t now_ms);
void handleAckWindow(uint32_t now_ms);
bool applyTimeFromAck(uint8_t time_valid, uint32_t ack_epoch);
void validateInvariants();
};

View File

@@ -1,10 +1,15 @@
#include "time_manager.h" #include "time_manager.h"
#include "config.h" #include "config.h"
#include <time.h> #include <time.h>
#ifdef ARDUINO_ARCH_ESP32
#include <esp_sntp.h>
#endif
static bool g_time_synced = false; static bool g_time_synced = false;
static bool g_clock_plausible = false;
static bool g_tz_set = false; static bool g_tz_set = false;
static uint32_t g_last_sync_utc = 0; static uint32_t g_last_sync_utc = 0;
static constexpr uint32_t MIN_PLAUSIBLE_EPOCH_UTC = 1672531200UL; // 2023-01-01 00:00:00 UTC
static void note_last_sync(uint32_t epoch) { static void note_last_sync(uint32_t epoch) {
if (epoch == 0) { if (epoch == 0) {
@@ -13,6 +18,32 @@ static void note_last_sync(uint32_t epoch) {
g_last_sync_utc = epoch; g_last_sync_utc = epoch;
} }
static bool epoch_is_plausible(time_t epoch) {
return epoch >= static_cast<time_t>(MIN_PLAUSIBLE_EPOCH_UTC);
}
static void mark_synced(uint32_t epoch) {
if (epoch == 0) {
return;
}
g_time_synced = true;
g_clock_plausible = true;
note_last_sync(epoch);
}
#ifdef ARDUINO_ARCH_ESP32
static void ntp_sync_notification_cb(struct timeval *tv) {
time_t epoch = tv ? tv->tv_sec : time(nullptr);
if (!epoch_is_plausible(epoch)) {
return;
}
if (epoch > static_cast<time_t>(UINT32_MAX)) {
return;
}
mark_synced(static_cast<uint32_t>(epoch));
}
#endif
static void ensure_timezone_set() { static void ensure_timezone_set() {
if (g_tz_set) { if (g_tz_set) {
return; return;
@@ -25,24 +56,31 @@ static void ensure_timezone_set() {
void time_receiver_init(const char *ntp_server_1, const char *ntp_server_2) { void time_receiver_init(const char *ntp_server_1, const char *ntp_server_2) {
const char *server1 = (ntp_server_1 && ntp_server_1[0] != '\0') ? ntp_server_1 : "pool.ntp.org"; const char *server1 = (ntp_server_1 && ntp_server_1[0] != '\0') ? ntp_server_1 : "pool.ntp.org";
const char *server2 = (ntp_server_2 && ntp_server_2[0] != '\0') ? ntp_server_2 : "time.nist.gov"; const char *server2 = (ntp_server_2 && ntp_server_2[0] != '\0') ? ntp_server_2 : "time.nist.gov";
#ifdef ARDUINO_ARCH_ESP32
sntp_set_time_sync_notification_cb(ntp_sync_notification_cb);
#endif
configTime(0, 0, server1, server2); configTime(0, 0, server1, server2);
ensure_timezone_set(); ensure_timezone_set();
} }
uint32_t time_get_utc() { uint32_t time_get_utc() {
time_t now = time(nullptr); time_t now = time(nullptr);
if (now < 1672531200) { if (!epoch_is_plausible(now)) {
g_clock_plausible = false;
return 0; return 0;
} }
if (!g_time_synced) { g_clock_plausible = true;
g_time_synced = true; #ifdef ARDUINO_ARCH_ESP32
note_last_sync(static_cast<uint32_t>(now)); if (!g_time_synced && sntp_get_sync_status() == SNTP_SYNC_STATUS_COMPLETED) {
mark_synced(static_cast<uint32_t>(now));
} }
#endif
return static_cast<uint32_t>(now); return static_cast<uint32_t>(now);
} }
bool time_is_synced() { bool time_is_synced() {
return g_time_synced || time_get_utc() > 0; (void)time_get_utc();
return g_time_synced && g_clock_plausible;
} }
void time_set_utc(uint32_t epoch) { void time_set_utc(uint32_t epoch) {
@@ -51,8 +89,12 @@ void time_set_utc(uint32_t epoch) {
tv.tv_sec = epoch; tv.tv_sec = epoch;
tv.tv_usec = 0; tv.tv_usec = 0;
settimeofday(&tv, nullptr); settimeofday(&tv, nullptr);
g_time_synced = true; if (epoch_is_plausible(static_cast<time_t>(epoch))) {
note_last_sync(epoch); mark_synced(epoch);
} else {
g_clock_plausible = false;
g_time_synced = false;
}
} }
void time_get_local_hhmm(char *out, size_t out_len) { void time_get_local_hhmm(char *out, size_t out_len) {

View File

@@ -243,7 +243,16 @@ static void history_reset() {
g_history = {}; g_history = {};
} }
static String history_date_from_epoch(uint32_t ts_utc) { static String history_date_from_epoch_local(uint32_t ts_utc) {
time_t t = static_cast<time_t>(ts_utc);
struct tm tm_local;
localtime_r(&t, &tm_local);
char buf[16];
snprintf(buf, sizeof(buf), "%04d-%02d-%02d", tm_local.tm_year + 1900, tm_local.tm_mon + 1, tm_local.tm_mday);
return String(buf);
}
static String history_date_from_epoch_utc(uint32_t ts_utc) {
time_t t = static_cast<time_t>(ts_utc); time_t t = static_cast<time_t>(ts_utc);
struct tm tm_utc; struct tm tm_utc;
gmtime_r(&t, &tm_utc); gmtime_r(&t, &tm_utc);
@@ -252,6 +261,40 @@ static String history_date_from_epoch(uint32_t ts_utc) {
return String(buf); return String(buf);
} }
static bool history_parse_u32_field(const char *start, size_t len, uint32_t &out) {
if (!start || len == 0 || len >= 16) {
return false;
}
char buf[16];
memcpy(buf, start, len);
buf[len] = '\0';
char *end = nullptr;
unsigned long value = strtoul(buf, &end, 10);
if (end == buf || *end != '\0' || value > static_cast<unsigned long>(UINT32_MAX)) {
return false;
}
out = static_cast<uint32_t>(value);
return true;
}
static bool history_parse_float_field(const char *start, size_t len, float &out) {
if (!start || len == 0 || len >= 24) {
return false;
}
char buf[24];
memcpy(buf, start, len);
buf[len] = '\0';
char *end = nullptr;
float value = strtof(buf, &end);
if (end == buf || *end != '\0') {
return false;
}
out = value;
return true;
}
static bool history_open_next_file() { static bool history_open_next_file() {
if (!g_history.active || g_history.done || g_history.error) { if (!g_history.active || g_history.done || g_history.error) {
return false; return false;
@@ -264,8 +307,17 @@ static bool history_open_next_file() {
g_history.done = true; g_history.done = true;
return false; return false;
} }
String path = String("/dd3/") + g_history.device_id + "/" + history_date_from_epoch(day_ts) + ".csv"; String local_date = history_date_from_epoch_local(day_ts);
String path = String("/dd3/") + g_history.device_id + "/" + local_date + ".csv";
g_history.file = SD.open(path.c_str(), FILE_READ); g_history.file = SD.open(path.c_str(), FILE_READ);
if (!g_history.file) {
// Compatibility fallback for files written before local-date partitioning.
String utc_date = history_date_from_epoch_utc(day_ts);
if (utc_date != local_date) {
String legacy_path = String("/dd3/") + g_history.device_id + "/" + utc_date + ".csv";
g_history.file = SD.open(legacy_path.c_str(), FILE_READ);
}
}
g_history.day_index++; g_history.day_index++;
return true; return true;
} }
@@ -274,40 +326,32 @@ static bool history_parse_line(const char *line, uint32_t &ts_out, float &p_out)
if (!line || line[0] < '0' || line[0] > '9') { if (!line || line[0] < '0' || line[0] > '9') {
return false; return false;
} }
const char *comma1 = strchr(line, ','); const char *comma1 = strchr(line, ',');
if (!comma1) { if (!comma1) {
return false; return false;
} }
char ts_buf[16];
size_t ts_len = static_cast<size_t>(comma1 - line); uint32_t ts = 0;
if (ts_len >= sizeof(ts_buf)) { if (!history_parse_u32_field(line, static_cast<size_t>(comma1 - line), ts)) {
return false;
}
memcpy(ts_buf, line, ts_len);
ts_buf[ts_len] = '\0';
char *end = nullptr;
uint32_t ts = static_cast<uint32_t>(strtoul(ts_buf, &end, 10));
if (end == ts_buf) {
return false; return false;
} }
const char *comma2 = strchr(comma1 + 1, ','); const char *comma2 = strchr(comma1 + 1, ',');
if (!comma2) { if (!comma2) {
return false; return false;
} }
float p = 0.0f;
if (!history_parse_float_field(comma1 + 1, static_cast<size_t>(comma2 - (comma1 + 1)), p)) {
const char *p_start = comma2 + 1; const char *p_start = comma2 + 1;
const char *p_end = strchr(p_start, ','); const char *p_end = strchr(p_start, ',');
char p_buf[16];
size_t p_len = p_end ? static_cast<size_t>(p_end - p_start) : strlen(p_start); size_t p_len = p_end ? static_cast<size_t>(p_end - p_start) : strlen(p_start);
if (p_len == 0 || p_len >= sizeof(p_buf)) { if (!history_parse_float_field(p_start, p_len, p)) {
return false; return false;
} }
memcpy(p_buf, p_start, p_len);
p_buf[p_len] = '\0';
char *endp = nullptr;
float p = strtof(p_buf, &endp);
if (endp == p_buf) {
return false;
} }
ts_out = ts; ts_out = ts;
p_out = p; p_out = p;
return true; return true;
@@ -567,10 +611,21 @@ static void handle_wifi_post() {
cfg.ntp_server_2 = server.arg("ntp2"); cfg.ntp_server_2 = server.arg("ntp2");
} }
cfg.valid = true; cfg.valid = true;
if (!wifi_save_config(cfg)) {
if (SERIAL_DEBUG_MODE) {
Serial.println("wifi_cfg: save failed, reboot cancelled");
}
String html = html_header("WiFi/MQTT Config");
html += "<p style='color:#b00020;'>Save failed. Configuration was not persisted and reboot was cancelled.</p>";
html += "<p><a href='/wifi'>Back to config</a></p>";
html += html_footer();
server.send(500, "text/html", html);
return;
}
g_config = cfg; g_config = cfg;
g_web_user = cfg.web_user; g_web_user = cfg.web_user;
g_web_pass = cfg.web_pass; g_web_pass = cfg.web_pass;
wifi_save_config(cfg);
server.send(200, "text/html", "<html><body>Saved. Rebooting...</body></html>"); server.send(200, "text/html", "<html><body>Saved. Rebooting...</body></html>");
delay(1000); delay(1000);
ESP.restart(); ESP.restart();
@@ -638,10 +693,11 @@ static void handle_sender() {
html += "if(min===max){min=0;}"; html += "if(min===max){min=0;}";
html += "ctx.strokeStyle='#333';ctx.lineWidth=1;ctx.beginPath();"; html += "ctx.strokeStyle='#333';ctx.lineWidth=1;ctx.beginPath();";
html += "let first=true;"; html += "let first=true;";
html += "const xDen=series.length>1?(series.length-1):1;";
html += "for(let i=0;i<series.length;i++){"; html += "for(let i=0;i<series.length;i++){";
html += "const v=series[i][1];"; html += "const v=series[i][1];";
html += "if(v===null)continue;"; html += "if(v===null)continue;";
html += "const x=(i/(series.length-1))* (w-2) + 1;"; html += "const x=series.length>1?((i/xDen)*(w-2)+1):(w/2);";
html += "const y=h-2-((v-min)/(max-min))*(h-4);"; html += "const y=h-2-((v-min)/(max-min))*(h-4);";
html += "if(first){ctx.moveTo(x,y);first=false;} else {ctx.lineTo(x,y);} }"; html += "if(first){ctx.moveTo(x,y);first=false;} else {ctx.lineTo(x,y);} }";
html += "ctx.stroke();"; html += "ctx.stroke();";
@@ -698,7 +754,7 @@ static void handle_manual() {
html += "<li>RSSI/SNR: LoRa link quality from last packet.</li>"; html += "<li>RSSI/SNR: LoRa link quality from last packet.</li>";
html += "<li>err_tx: sender-side LoRa TX error counter.</li>"; html += "<li>err_tx: sender-side LoRa TX error counter.</li>";
html += "<li>err_last: last error code (0=None, 1=MeterRead, 2=Decode, 3=LoraTx).</li>"; html += "<li>err_last: last error code (0=None, 1=MeterRead, 2=Decode, 3=LoraTx).</li>";
html += "<li>rx_reject: last RX reject reason (0=None, 1=crc_fail, 2=invalid_msg_kind, 3=length_mismatch, 4=device_id_mismatch, 5=batch_id_mismatch).</li>"; html += "<li>rx_reject: last RX reject reason (0=None, 1=crc_fail, 2=invalid_msg_kind, 3=length_mismatch, 4=device_id_mismatch, 5=batch_id_mismatch, 6=unknown_sender).</li>";
html += "<li>faults m/d/tx: receiver-side counters (meter read fails, decode fails, LoRa TX fails).</li>"; html += "<li>faults m/d/tx: receiver-side counters (meter read fails, decode fails, LoRa TX fails).</li>";
html += "<li>faults last: last receiver-side error code (same mapping as err_last).</li>"; html += "<li>faults last: last receiver-side error code (same mapping as err_last).</li>";
html += "</ul>"; html += "</ul>";

View File

@@ -5,6 +5,59 @@
static Preferences prefs; static Preferences prefs;
static bool wifi_log_save_failure(const char *key, const char *reason) {
if (SERIAL_DEBUG_MODE) {
Serial.printf("wifi_cfg: save failed key=%s reason=%s\n", key, reason);
}
return false;
}
static bool wifi_write_string_pref(const char *key, const String &value) {
size_t written = prefs.putString(key, value);
if (written != value.length()) {
return wifi_log_save_failure(key, "write_short");
}
if (!prefs.isKey(key)) {
return wifi_log_save_failure(key, "missing_key");
}
String readback = prefs.getString(key, "");
if (readback != value) {
return wifi_log_save_failure(key, "verify_mismatch");
}
return true;
}
static bool wifi_write_bool_pref(const char *key, bool value) {
size_t written = prefs.putBool(key, value);
if (written != sizeof(uint8_t)) {
return wifi_log_save_failure(key, "write_short");
}
if (!prefs.isKey(key)) {
return wifi_log_save_failure(key, "missing_key");
}
bool readback = prefs.getBool(key, !value);
if (readback != value) {
return wifi_log_save_failure(key, "verify_mismatch");
}
return true;
}
static bool wifi_write_ushort_pref(const char *key, uint16_t value) {
size_t written = prefs.putUShort(key, value);
if (written != sizeof(uint16_t)) {
return wifi_log_save_failure(key, "write_short");
}
if (!prefs.isKey(key)) {
return wifi_log_save_failure(key, "missing_key");
}
uint16_t fallback = value == static_cast<uint16_t>(0xFFFF) ? 0 : static_cast<uint16_t>(0xFFFF);
uint16_t readback = prefs.getUShort(key, fallback);
if (readback != value) {
return wifi_log_save_failure(key, "verify_mismatch");
}
return true;
}
void wifi_manager_init() { void wifi_manager_init() {
prefs.begin("dd3cfg", false); prefs.begin("dd3cfg", false);
} }
@@ -28,17 +81,39 @@ bool wifi_load_config(WifiMqttConfig &config) {
} }
bool wifi_save_config(const WifiMqttConfig &config) { bool wifi_save_config(const WifiMqttConfig &config) {
prefs.putBool("valid", true); if (!wifi_write_bool_pref("valid", true)) {
prefs.putString("ssid", config.ssid); return false;
prefs.putString("pass", config.password); }
prefs.putString("mqhost", config.mqtt_host); if (!wifi_write_string_pref("ssid", config.ssid)) {
prefs.putUShort("mqport", config.mqtt_port); return false;
prefs.putString("mquser", config.mqtt_user); }
prefs.putString("mqpass", config.mqtt_pass); if (!wifi_write_string_pref("pass", config.password)) {
prefs.putString("ntp1", config.ntp_server_1); return false;
prefs.putString("ntp2", config.ntp_server_2); }
prefs.putString("webuser", config.web_user); if (!wifi_write_string_pref("mqhost", config.mqtt_host)) {
prefs.putString("webpass", config.web_pass); return false;
}
if (!wifi_write_ushort_pref("mqport", config.mqtt_port)) {
return false;
}
if (!wifi_write_string_pref("mquser", config.mqtt_user)) {
return false;
}
if (!wifi_write_string_pref("mqpass", config.mqtt_pass)) {
return false;
}
if (!wifi_write_string_pref("ntp1", config.ntp_server_1)) {
return false;
}
if (!wifi_write_string_pref("ntp2", config.ntp_server_2)) {
return false;
}
if (!wifi_write_string_pref("webuser", config.web_user)) {
return false;
}
if (!wifi_write_string_pref("webpass", config.web_pass)) {
return false;
}
return true; return true;
} }

View File

@@ -0,0 +1,37 @@
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).ProviderPath
$configPath = (Resolve-Path (Join-Path $repoRoot "include/config.h")).ProviderPath
$mqttPath = (Resolve-Path (Join-Path $repoRoot "src/mqtt_client.cpp")).ProviderPath
$configText = Get-Content -Raw -Path $configPath
if ($configText -notmatch 'HA_MANUFACTURER\[\]\s*=\s*"AcidBurns"\s*;') {
throw "include/config.h must define HA_MANUFACTURER as exactly ""AcidBurns""."
}
$mqttText = Get-Content -Raw -Path $mqttPath
if ($mqttText -notmatch 'device\["manufacturer"\]\s*=\s*HA_MANUFACTURER\s*;') {
throw "src/mqtt_client.cpp must assign device[""manufacturer""] from HA_MANUFACTURER."
}
if ($mqttText -match 'device\["manufacturer"\]\s*=\s*"[^"]+"\s*;') {
throw "src/mqtt_client.cpp must not hardcode manufacturer string literals."
}
$roots = @(
Join-Path $repoRoot "src"
Join-Path $repoRoot "include"
)
$literalHits = Get-ChildItem -Path $roots -Recurse -File -Include *.c,*.cc,*.cpp,*.h,*.hpp |
Select-String -Pattern '"AcidBurns"' |
Where-Object { (Resolve-Path $_.Path).ProviderPath -ne $configPath }
if ($literalHits) {
$details = $literalHits | ForEach-Object {
"$($_.Path):$($_.LineNumber)"
}
throw "Unexpected hardcoded ""AcidBurns"" literal(s) outside include/config.h:`n$($details -join "`n")"
}
Write-Host "HA manufacturer drift check passed."

View File

@@ -1,5 +1,6 @@
#include <Arduino.h> #include <Arduino.h>
#include <unity.h> #include <unity.h>
#include "dd3_legacy_core.h"
#include "html_util.h" #include "html_util.h"
static void test_html_escape_basic() { static void test_html_escape_basic() {
@@ -12,25 +13,122 @@ static void test_html_escape_basic() {
TEST_ASSERT_EQUAL_STRING("&amp;&lt;&gt;&quot;&#39;", html_escape("&<>\"'").c_str()); TEST_ASSERT_EQUAL_STRING("&amp;&lt;&gt;&quot;&#39;", html_escape("&<>\"'").c_str());
} }
static void test_sanitize_device_id() { static void test_html_escape_adversarial() {
TEST_ASSERT_EQUAL_STRING("&amp;amp;", html_escape("&amp;").c_str());
TEST_ASSERT_EQUAL_STRING("\n\r\t", html_escape("\n\r\t").c_str());
const String chunk = "<&>\"'abc\n\r\t";
const String escaped_chunk = "&lt;&amp;&gt;&quot;&#39;abc\n\r\t";
const size_t repeats = 300; // 3.3 KB input
String input;
String expected;
input.reserve(chunk.length() * repeats);
expected.reserve(escaped_chunk.length() * repeats);
for (size_t i = 0; i < repeats; ++i) {
input += chunk;
expected += escaped_chunk;
}
String out = html_escape(input);
TEST_ASSERT_EQUAL_UINT(expected.length(), out.length());
TEST_ASSERT_EQUAL_STRING(expected.c_str(), out.c_str());
TEST_ASSERT_TRUE(out.indexOf("&lt;&amp;&gt;&quot;&#39;abc") >= 0);
}
static void test_url_encode_component_table() {
struct Case {
const char *input;
const char *expected;
};
const Case cases[] = {
{"", ""},
{"abcABC012-_.~", "abcABC012-_.~"},
{"a b", "a%20b"},
{"/\\?&#%\"'", "%2F%5C%3F%26%23%25%22%27"},
{"line\nbreak", "line%0Abreak"},
};
for (size_t i = 0; i < (sizeof(cases) / sizeof(cases[0])); ++i) {
String out = url_encode_component(cases[i].input);
TEST_ASSERT_EQUAL_STRING(cases[i].expected, out.c_str());
}
String control;
control += static_cast<char>(0x01);
control += static_cast<char>(0x1F);
control += static_cast<char>(0x7F);
TEST_ASSERT_EQUAL_STRING("%01%1F%7F", url_encode_component(control).c_str());
const String long_chunk = "AZaz09-_.~ /%?";
const String long_expected_chunk = "AZaz09-_.~%20%2F%25%3F";
String long_input;
String long_expected;
for (size_t i = 0; i < 40; ++i) { // 520 chars
long_input += long_chunk;
long_expected += long_expected_chunk;
}
String long_out_1 = url_encode_component(long_input);
String long_out_2 = url_encode_component(long_input);
TEST_ASSERT_EQUAL_STRING(long_expected.c_str(), long_out_1.c_str());
TEST_ASSERT_EQUAL_STRING(long_out_1.c_str(), long_out_2.c_str());
}
static void test_sanitize_device_id_accepts_and_normalizes() {
String out; String out;
TEST_ASSERT_TRUE(sanitize_device_id("F19C", out)); const char *accept_cases[] = {
"F19C",
"f19c",
" f19c ",
"dd3-f19c",
"dd3-F19C",
"dd3-a0b1",
};
for (size_t i = 0; i < (sizeof(accept_cases) / sizeof(accept_cases[0])); ++i) {
TEST_ASSERT_TRUE(sanitize_device_id(accept_cases[i], out));
if (String(accept_cases[i]).indexOf("a0b1") >= 0) {
TEST_ASSERT_EQUAL_STRING("dd3-A0B1", out.c_str());
} else {
TEST_ASSERT_EQUAL_STRING("dd3-F19C", out.c_str()); TEST_ASSERT_EQUAL_STRING("dd3-F19C", out.c_str());
TEST_ASSERT_TRUE(sanitize_device_id("dd3-f19c", out)); }
TEST_ASSERT_EQUAL_STRING("dd3-F19C", out.c_str()); }
TEST_ASSERT_FALSE(sanitize_device_id("F19G", out)); }
TEST_ASSERT_FALSE(sanitize_device_id("dd3-12", out));
TEST_ASSERT_FALSE(sanitize_device_id("dd3-12345", out)); static void test_sanitize_device_id_rejects_invalid() {
TEST_ASSERT_FALSE(sanitize_device_id("../F19C", out)); String out = "dd3-KEEP";
TEST_ASSERT_FALSE(sanitize_device_id("dd3-%2f", out)); const char *reject_cases[] = {
TEST_ASSERT_FALSE(sanitize_device_id("dd3-12/3", out)); "",
TEST_ASSERT_FALSE(sanitize_device_id("dd3-12\\3", out)); "F",
"FFF",
"FFFFF",
"dd3-12",
"dd3-12345",
"F1 9C",
"dd3-F1\t9C",
"dd3-F19C%00",
"%F19C",
"../F19C",
"dd3-..1A",
"dd3-12/3",
"dd3-12\\3",
"F19G",
"dd3-zzzz",
};
for (size_t i = 0; i < (sizeof(reject_cases) / sizeof(reject_cases[0])); ++i) {
TEST_ASSERT_FALSE(sanitize_device_id(reject_cases[i], out));
}
TEST_ASSERT_EQUAL_STRING("dd3-KEEP", out.c_str());
} }
void setup() { void setup() {
dd3_legacy_core_force_link();
UNITY_BEGIN(); UNITY_BEGIN();
RUN_TEST(test_html_escape_basic); RUN_TEST(test_html_escape_basic);
RUN_TEST(test_sanitize_device_id); RUN_TEST(test_html_escape_adversarial);
RUN_TEST(test_url_encode_component_table);
RUN_TEST(test_sanitize_device_id_accepts_and_normalizes);
RUN_TEST(test_sanitize_device_id_rejects_invalid);
UNITY_END(); UNITY_END();
} }

View File

@@ -0,0 +1,129 @@
#include <Arduino.h>
#include <unity.h>
#include <ArduinoJson.h>
#include "config.h"
#include "data_model.h"
#include "dd3_legacy_core.h"
#include "ha_discovery_json.h"
#include "json_codec.h"
static void fill_state_sample(MeterData &data) {
data = {};
data.ts_utc = 1769905000;
data.short_id = 0xF19C;
strncpy(data.device_id, "dd3-F19C", sizeof(data.device_id));
data.energy_total_kwh = 1234.5678f;
data.total_power_w = 321.6f;
data.phase_power_w[0] = 100.4f;
data.phase_power_w[1] = 110.4f;
data.phase_power_w[2] = 110.8f;
data.battery_voltage_v = 3.876f;
data.battery_percent = 77;
data.link_valid = true;
data.link_rssi_dbm = -71;
data.link_snr_db = 7.25f;
data.err_meter_read = 1;
data.err_decode = 2;
data.err_lora_tx = 3;
data.last_error = FaultType::Decode;
data.rx_reject_reason = static_cast<uint8_t>(RxRejectReason::CrcFail);
}
static void test_state_json_required_keys_and_stability() {
MeterData data = {};
fill_state_sample(data);
String out_json;
TEST_ASSERT_TRUE(meterDataToJson(data, out_json));
StaticJsonDocument<512> doc;
DeserializationError err = deserializeJson(doc, out_json);
TEST_ASSERT_TRUE(err == DeserializationError::Ok);
const char *required_keys[] = {
"id", "ts", "e_kwh", "p_w", "p1_w", "p2_w", "p3_w",
"bat_v", "bat_pct", "rssi", "snr", "err_m", "err_d",
"err_tx", "err_last", "rx_reject", "rx_reject_text"};
for (size_t i = 0; i < (sizeof(required_keys) / sizeof(required_keys[0])); ++i) {
TEST_ASSERT_TRUE_MESSAGE(doc.containsKey(required_keys[i]), required_keys[i]);
}
TEST_ASSERT_EQUAL_STRING("F19C", doc["id"] | "");
TEST_ASSERT_EQUAL_UINT32(data.ts_utc, doc["ts"] | 0U);
TEST_ASSERT_EQUAL_UINT8(static_cast<uint8_t>(FaultType::Decode), doc["err_last"] | 0U);
TEST_ASSERT_EQUAL_UINT8(static_cast<uint8_t>(RxRejectReason::CrcFail), doc["rx_reject"] | 0U);
TEST_ASSERT_EQUAL_STRING("crc_fail", doc["rx_reject_text"] | "");
TEST_ASSERT_FALSE(doc.containsKey("energy_total_kwh"));
TEST_ASSERT_FALSE(doc.containsKey("power_w"));
TEST_ASSERT_FALSE(doc.containsKey("battery_voltage"));
}
static void test_state_json_optional_keys_when_not_available() {
MeterData data = {};
fill_state_sample(data);
data.link_valid = false;
data.err_meter_read = 0;
data.err_decode = 0;
data.err_lora_tx = 0;
data.rx_reject_reason = static_cast<uint8_t>(RxRejectReason::None);
String out_json;
TEST_ASSERT_TRUE(meterDataToJson(data, out_json));
StaticJsonDocument<512> doc;
DeserializationError err = deserializeJson(doc, out_json);
TEST_ASSERT_TRUE(err == DeserializationError::Ok);
TEST_ASSERT_FALSE(doc.containsKey("rssi"));
TEST_ASSERT_FALSE(doc.containsKey("snr"));
TEST_ASSERT_FALSE(doc.containsKey("err_m"));
TEST_ASSERT_FALSE(doc.containsKey("err_d"));
TEST_ASSERT_FALSE(doc.containsKey("err_tx"));
TEST_ASSERT_EQUAL_STRING("none", doc["rx_reject_text"] | "");
}
static void test_ha_discovery_manufacturer_and_key_stability() {
String payload;
TEST_ASSERT_TRUE(ha_build_discovery_sensor_payload(
"dd3-F19C", "energy", "Energy", "kWh", "energy",
"smartmeter/dd3-F19C/state", "{{ value_json.e_kwh }}",
HA_MANUFACTURER, payload));
StaticJsonDocument<384> doc;
DeserializationError err = deserializeJson(doc, payload);
TEST_ASSERT_TRUE(err == DeserializationError::Ok);
TEST_ASSERT_TRUE(doc.containsKey("name"));
TEST_ASSERT_TRUE(doc.containsKey("state_topic"));
TEST_ASSERT_TRUE(doc.containsKey("unique_id"));
TEST_ASSERT_TRUE(doc.containsKey("value_template"));
TEST_ASSERT_TRUE(doc.containsKey("device"));
TEST_ASSERT_EQUAL_STRING("dd3-F19C_energy", doc["unique_id"] | "");
TEST_ASSERT_EQUAL_STRING("smartmeter/dd3-F19C/state", doc["state_topic"] | "");
TEST_ASSERT_EQUAL_STRING("{{ value_json.e_kwh }}", doc["value_template"] | "");
JsonObject device = doc["device"].as<JsonObject>();
TEST_ASSERT_TRUE(device.containsKey("identifiers"));
TEST_ASSERT_TRUE(device.containsKey("name"));
TEST_ASSERT_TRUE(device.containsKey("model"));
TEST_ASSERT_TRUE(device.containsKey("manufacturer"));
TEST_ASSERT_EQUAL_STRING("DD3-LoRa-Bridge", device["model"] | "");
TEST_ASSERT_EQUAL_STRING("AcidBurns", device["manufacturer"] | "");
TEST_ASSERT_EQUAL_STRING("dd3-F19C", device["name"] | "");
TEST_ASSERT_EQUAL_STRING("dd3-F19C", device["identifiers"][0] | "");
}
void setup() {
dd3_legacy_core_force_link();
UNITY_BEGIN();
RUN_TEST(test_state_json_required_keys_and_stability);
RUN_TEST(test_state_json_optional_keys_when_not_available);
RUN_TEST(test_ha_discovery_manufacturer_and_key_stability);
UNITY_END();
}
void loop() {}

View File

@@ -0,0 +1,131 @@
#include <Arduino.h>
#include <unity.h>
#include "batch_reassembly_logic.h"
#include "lora_frame_logic.h"
static void test_crc16_known_vectors() {
const uint8_t canonical[] = {'1', '2', '3', '4', '5', '6', '7', '8', '9'};
TEST_ASSERT_EQUAL_HEX16(0x29B1, lora_crc16_ccitt(canonical, sizeof(canonical)));
const uint8_t binary[] = {0x00, 0x01, 0x02, 0x03, 0x04};
TEST_ASSERT_EQUAL_HEX16(0x1C0F, lora_crc16_ccitt(binary, sizeof(binary)));
}
static void test_frame_encode_decode_and_crc_reject() {
const uint8_t payload[] = {0x01, 0x02, 0xA5};
uint8_t frame[64] = {};
size_t frame_len = 0;
TEST_ASSERT_TRUE(lora_build_frame(0, 0xF19C, payload, sizeof(payload), frame, sizeof(frame), frame_len));
TEST_ASSERT_EQUAL_UINT(8, frame_len);
uint8_t out_kind = 0xFF;
uint16_t out_device_id = 0;
uint8_t out_payload[16] = {};
size_t out_payload_len = 0;
LoraFrameDecodeStatus ok = lora_parse_frame(frame, frame_len, 1, &out_kind, &out_device_id, out_payload,
sizeof(out_payload), &out_payload_len);
TEST_ASSERT_EQUAL_UINT8(static_cast<uint8_t>(LoraFrameDecodeStatus::Ok), static_cast<uint8_t>(ok));
TEST_ASSERT_EQUAL_UINT8(0, out_kind);
TEST_ASSERT_EQUAL_UINT16(0xF19C, out_device_id);
TEST_ASSERT_EQUAL_UINT(sizeof(payload), out_payload_len);
TEST_ASSERT_EQUAL_UINT8_ARRAY(payload, out_payload, sizeof(payload));
frame[frame_len - 1] ^= 0x01;
LoraFrameDecodeStatus bad_crc = lora_parse_frame(frame, frame_len, 1, &out_kind, &out_device_id, out_payload,
sizeof(out_payload), &out_payload_len);
TEST_ASSERT_EQUAL_UINT8(static_cast<uint8_t>(LoraFrameDecodeStatus::CrcFail), static_cast<uint8_t>(bad_crc));
}
static void test_frame_rejects_invalid_msg_kind_and_short_length() {
const uint8_t payload[] = {0x42};
uint8_t frame[32] = {};
size_t frame_len = 0;
TEST_ASSERT_TRUE(lora_build_frame(2, 0xF19C, payload, sizeof(payload), frame, sizeof(frame), frame_len));
uint8_t out_kind = 0;
uint16_t out_device_id = 0;
uint8_t out_payload[8] = {};
size_t out_payload_len = 0;
LoraFrameDecodeStatus invalid_msg = lora_parse_frame(frame, frame_len, 1, &out_kind, &out_device_id, out_payload,
sizeof(out_payload), &out_payload_len);
TEST_ASSERT_EQUAL_UINT8(static_cast<uint8_t>(LoraFrameDecodeStatus::InvalidMsgKind), static_cast<uint8_t>(invalid_msg));
LoraFrameDecodeStatus short_len = lora_parse_frame(frame, 4, 1, &out_kind, &out_device_id, out_payload,
sizeof(out_payload), &out_payload_len);
TEST_ASSERT_EQUAL_UINT8(static_cast<uint8_t>(LoraFrameDecodeStatus::LengthMismatch), static_cast<uint8_t>(short_len));
}
static void test_chunk_reassembly_in_order_success() {
BatchReassemblyState state = {};
batch_reassembly_reset(state);
const uint8_t payload[] = {1, 2, 3, 4, 5, 6, 7};
uint8_t buffer[32] = {};
uint16_t complete_len = 0;
TEST_ASSERT_EQUAL_UINT8(
static_cast<uint8_t>(BatchReassemblyStatus::InProgress),
static_cast<uint8_t>(batch_reassembly_push(state, 77, 0, 3, 7, &payload[0], 3, 1000, 5000, 32, buffer, sizeof(buffer), complete_len)));
TEST_ASSERT_EQUAL_UINT8(
static_cast<uint8_t>(BatchReassemblyStatus::InProgress),
static_cast<uint8_t>(batch_reassembly_push(state, 77, 1, 3, 7, &payload[3], 2, 1100, 5000, 32, buffer, sizeof(buffer), complete_len)));
TEST_ASSERT_EQUAL_UINT8(
static_cast<uint8_t>(BatchReassemblyStatus::Complete),
static_cast<uint8_t>(batch_reassembly_push(state, 77, 2, 3, 7, &payload[5], 2, 1200, 5000, 32, buffer, sizeof(buffer), complete_len)));
TEST_ASSERT_EQUAL_UINT16(7, complete_len);
TEST_ASSERT_FALSE(state.active);
TEST_ASSERT_EQUAL_UINT8_ARRAY(payload, buffer, sizeof(payload));
}
static void test_chunk_reassembly_missing_or_out_of_order_fails_deterministically() {
BatchReassemblyState state = {};
batch_reassembly_reset(state);
const uint8_t payload[] = {9, 8, 7, 6, 5, 4};
uint8_t buffer[32] = {};
uint16_t complete_len = 0;
TEST_ASSERT_EQUAL_UINT8(
static_cast<uint8_t>(BatchReassemblyStatus::InProgress),
static_cast<uint8_t>(batch_reassembly_push(state, 10, 0, 3, 6, &payload[0], 2, 1000, 5000, 32, buffer, sizeof(buffer), complete_len)));
TEST_ASSERT_EQUAL_UINT8(
static_cast<uint8_t>(BatchReassemblyStatus::ErrorReset),
static_cast<uint8_t>(batch_reassembly_push(state, 10, 2, 3, 6, &payload[4], 2, 1100, 5000, 32, buffer, sizeof(buffer), complete_len)));
TEST_ASSERT_FALSE(state.active);
TEST_ASSERT_EQUAL_UINT8(
static_cast<uint8_t>(BatchReassemblyStatus::ErrorReset),
static_cast<uint8_t>(batch_reassembly_push(state, 11, 1, 3, 6, &payload[2], 2, 1200, 5000, 32, buffer, sizeof(buffer), complete_len)));
}
static void test_chunk_reassembly_wrong_total_length_fails() {
BatchReassemblyState state = {};
batch_reassembly_reset(state);
const uint8_t payload[] = {1, 2, 3, 4, 5, 6};
uint8_t buffer[8] = {};
uint16_t complete_len = 0;
TEST_ASSERT_EQUAL_UINT8(
static_cast<uint8_t>(BatchReassemblyStatus::InProgress),
static_cast<uint8_t>(batch_reassembly_push(state, 55, 0, 2, 5, &payload[0], 3, 1000, 5000, 8, buffer, sizeof(buffer), complete_len)));
TEST_ASSERT_EQUAL_UINT8(
static_cast<uint8_t>(BatchReassemblyStatus::ErrorReset),
static_cast<uint8_t>(batch_reassembly_push(state, 55, 1, 2, 5, &payload[3], 3, 1100, 5000, 8, buffer, sizeof(buffer), complete_len)));
TEST_ASSERT_FALSE(state.active);
}
void setup() {
UNITY_BEGIN();
RUN_TEST(test_crc16_known_vectors);
RUN_TEST(test_frame_encode_decode_and_crc_reject);
RUN_TEST(test_frame_rejects_invalid_msg_kind_and_short_length);
RUN_TEST(test_chunk_reassembly_in_order_success);
RUN_TEST(test_chunk_reassembly_missing_or_out_of_order_fails_deterministically);
RUN_TEST(test_chunk_reassembly_wrong_total_length_fails);
UNITY_END();
}
void loop() {}

View File

@@ -0,0 +1,279 @@
#include <Arduino.h>
#include <unity.h>
#include "dd3_legacy_core.h"
#include "payload_codec.h"
static constexpr uint8_t kMaxSamples = 30;
static void fill_sparse_batch(BatchInput &in) {
memset(&in, 0, sizeof(in));
in.sender_id = 1;
in.batch_id = 42;
in.t_last = 1700000000;
in.present_mask = (1UL << 0) | (1UL << 2) | (1UL << 3) | (1UL << 10) | (1UL << 29);
in.n = 5;
in.battery_mV = 3750;
in.err_m = 2;
in.err_d = 1;
in.err_tx = 3;
in.err_last = 2;
in.err_rx_reject = 1;
in.energy_wh[0] = 100000;
in.energy_wh[1] = 100001;
in.energy_wh[2] = 100050;
in.energy_wh[3] = 100050;
in.energy_wh[4] = 100200;
in.p1_w[0] = -120;
in.p1_w[1] = -90;
in.p1_w[2] = 1910;
in.p1_w[3] = -90;
in.p1_w[4] = 500;
in.p2_w[0] = 50;
in.p2_w[1] = -1950;
in.p2_w[2] = 60;
in.p2_w[3] = 2060;
in.p2_w[4] = -10;
in.p3_w[0] = 0;
in.p3_w[1] = 10;
in.p3_w[2] = -1990;
in.p3_w[3] = 10;
in.p3_w[4] = 20;
}
static void fill_full_batch(BatchInput &in) {
memset(&in, 0, sizeof(in));
in.sender_id = 1;
in.batch_id = 0xBEEF;
in.t_last = 1769904999;
in.present_mask = 0x3FFFFFFFUL;
in.n = kMaxSamples;
in.battery_mV = 4095;
in.err_m = 10;
in.err_d = 20;
in.err_tx = 30;
in.err_last = 3;
in.err_rx_reject = 6;
for (uint8_t i = 0; i < kMaxSamples; ++i) {
in.energy_wh[i] = 500000UL + static_cast<uint32_t>(i) * static_cast<uint32_t>(i) * 3UL;
in.p1_w[i] = static_cast<int16_t>(-1000 + static_cast<int16_t>(i) * 25);
in.p2_w[i] = static_cast<int16_t>(500 - static_cast<int16_t>(i) * 30);
in.p3_w[i] = static_cast<int16_t>(((i % 2) == 0 ? 100 : -100) + static_cast<int16_t>(i) * 5);
}
}
static void assert_batch_equals(const BatchInput &expected, const BatchInput &actual) {
TEST_ASSERT_EQUAL_UINT16(expected.sender_id, actual.sender_id);
TEST_ASSERT_EQUAL_UINT16(expected.batch_id, actual.batch_id);
TEST_ASSERT_EQUAL_UINT32(expected.t_last, actual.t_last);
TEST_ASSERT_EQUAL_UINT32(expected.present_mask, actual.present_mask);
TEST_ASSERT_EQUAL_UINT8(expected.n, actual.n);
TEST_ASSERT_EQUAL_UINT16(expected.battery_mV, actual.battery_mV);
TEST_ASSERT_EQUAL_UINT8(expected.err_m, actual.err_m);
TEST_ASSERT_EQUAL_UINT8(expected.err_d, actual.err_d);
TEST_ASSERT_EQUAL_UINT8(expected.err_tx, actual.err_tx);
TEST_ASSERT_EQUAL_UINT8(expected.err_last, actual.err_last);
TEST_ASSERT_EQUAL_UINT8(expected.err_rx_reject, actual.err_rx_reject);
for (uint8_t i = 0; i < expected.n; ++i) {
TEST_ASSERT_EQUAL_UINT32(expected.energy_wh[i], actual.energy_wh[i]);
TEST_ASSERT_EQUAL_INT16(expected.p1_w[i], actual.p1_w[i]);
TEST_ASSERT_EQUAL_INT16(expected.p2_w[i], actual.p2_w[i]);
TEST_ASSERT_EQUAL_INT16(expected.p3_w[i], actual.p3_w[i]);
}
for (uint8_t i = expected.n; i < kMaxSamples; ++i) {
TEST_ASSERT_EQUAL_UINT32(0, actual.energy_wh[i]);
TEST_ASSERT_EQUAL_INT16(0, actual.p1_w[i]);
TEST_ASSERT_EQUAL_INT16(0, actual.p2_w[i]);
TEST_ASSERT_EQUAL_INT16(0, actual.p3_w[i]);
}
}
static void test_encode_decode_roundtrip_schema_v3() {
BatchInput in = {};
fill_sparse_batch(in);
uint8_t encoded[256] = {};
size_t encoded_len = 0;
TEST_ASSERT_TRUE(encode_batch(in, encoded, sizeof(encoded), &encoded_len));
TEST_ASSERT_TRUE(encoded_len > 24);
BatchInput out = {};
TEST_ASSERT_TRUE(decode_batch(encoded, encoded_len, &out));
assert_batch_equals(in, out);
}
static void test_decode_rejects_bad_magic_schema_flags() {
BatchInput in = {};
fill_sparse_batch(in);
uint8_t encoded[256] = {};
size_t encoded_len = 0;
TEST_ASSERT_TRUE(encode_batch(in, encoded, sizeof(encoded), &encoded_len));
BatchInput out = {};
uint8_t bad_magic[256] = {};
memcpy(bad_magic, encoded, encoded_len);
bad_magic[0] = 0x00;
TEST_ASSERT_FALSE(decode_batch(bad_magic, encoded_len, &out));
uint8_t bad_schema[256] = {};
memcpy(bad_schema, encoded, encoded_len);
bad_schema[2] = 0x02;
TEST_ASSERT_FALSE(decode_batch(bad_schema, encoded_len, &out));
uint8_t bad_flags[256] = {};
memcpy(bad_flags, encoded, encoded_len);
bad_flags[3] = 0x00;
TEST_ASSERT_FALSE(decode_batch(bad_flags, encoded_len, &out));
}
static void test_decode_rejects_truncated_and_length_mismatch() {
BatchInput in = {};
fill_sparse_batch(in);
uint8_t encoded[256] = {};
size_t encoded_len = 0;
TEST_ASSERT_TRUE(encode_batch(in, encoded, sizeof(encoded), &encoded_len));
BatchInput out = {};
TEST_ASSERT_FALSE(decode_batch(encoded, encoded_len - 1, &out));
TEST_ASSERT_FALSE(decode_batch(encoded, 12, &out));
uint8_t with_tail[257] = {};
memcpy(with_tail, encoded, encoded_len);
with_tail[encoded_len] = 0xAA;
TEST_ASSERT_FALSE(decode_batch(with_tail, encoded_len + 1, &out));
}
static void test_encode_and_decode_reject_invalid_present_mask() {
BatchInput in = {};
fill_sparse_batch(in);
uint8_t encoded[256] = {};
size_t encoded_len = 0;
in.present_mask = 0x40000000UL;
TEST_ASSERT_FALSE(encode_batch(in, encoded, sizeof(encoded), &encoded_len));
fill_sparse_batch(in);
TEST_ASSERT_TRUE(encode_batch(in, encoded, sizeof(encoded), &encoded_len));
BatchInput out = {};
uint8_t invalid_bits[256] = {};
memcpy(invalid_bits, encoded, encoded_len);
invalid_bits[15] |= 0x40;
TEST_ASSERT_FALSE(decode_batch(invalid_bits, encoded_len, &out));
uint8_t bitcount_mismatch[256] = {};
memcpy(bitcount_mismatch, encoded, encoded_len);
bitcount_mismatch[16] = 0x01; // n=1 while mask has 5 bits set
TEST_ASSERT_FALSE(decode_batch(bitcount_mismatch, encoded_len, &out));
}
static void test_encode_rejects_invalid_n_and_regression_cases() {
BatchInput in = {};
fill_sparse_batch(in);
uint8_t encoded[256] = {};
size_t encoded_len = 0;
in.n = 31;
TEST_ASSERT_FALSE(encode_batch(in, encoded, sizeof(encoded), &encoded_len));
fill_sparse_batch(in);
in.n = 0;
in.present_mask = 1;
TEST_ASSERT_FALSE(encode_batch(in, encoded, sizeof(encoded), &encoded_len));
fill_sparse_batch(in);
in.n = 2;
in.present_mask = 0x00000003UL;
in.energy_wh[1] = in.energy_wh[0] - 1;
TEST_ASSERT_FALSE(encode_batch(in, encoded, sizeof(encoded), &encoded_len));
fill_sparse_batch(in);
TEST_ASSERT_FALSE(encode_batch(in, encoded, 10, &encoded_len));
}
static const uint8_t VECTOR_SYNC_EMPTY[] = {
0xB3, 0xDD, 0x03, 0x01, 0x01, 0x00, 0x34, 0x12, 0xE4, 0x97, 0x7E, 0x69, 0x00, 0x00, 0x00, 0x00, 0x00, 0xA6, 0x0E,
0x00, 0x00, 0x00, 0x00, 0x00};
static const uint8_t VECTOR_SPARSE_5[] = {
0xB3, 0xDD, 0x03, 0x01, 0x01, 0x00, 0x2A, 0x00, 0x00, 0xF1, 0x53, 0x65, 0x0D, 0x04, 0x00, 0x20, 0x05, 0xA6, 0x0E,
0x02, 0x01, 0x03, 0x02, 0x01, 0xA0, 0x86, 0x01, 0x00, 0x01, 0x31, 0x00, 0x96, 0x01, 0x88, 0xFF, 0x3C, 0xA0, 0x1F,
0x9F, 0x1F, 0x9C, 0x09, 0x32, 0x00, 0x9F, 0x1F, 0xB4, 0x1F, 0xA0, 0x1F, 0xAB, 0x20, 0x00, 0x00, 0x14, 0x9F, 0x1F,
0xA0, 0x1F, 0x14};
static const uint8_t VECTOR_FULL_30[] = {
0xB3, 0xDD, 0x03, 0x01, 0x01, 0x00, 0xEF, 0xBE, 0x67, 0x9B, 0x7E, 0x69, 0xFF, 0xFF, 0xFF, 0x3F, 0x1E, 0xFF, 0x0F,
0x0A, 0x14, 0x1E, 0x03, 0x06, 0x20, 0xA1, 0x07, 0x00, 0x03, 0x09, 0x0F, 0x15, 0x1B, 0x21, 0x27, 0x2D, 0x33, 0x39,
0x3F, 0x45, 0x4B, 0x51, 0x57, 0x5D, 0x63, 0x69, 0x6F, 0x75, 0x7B, 0x81, 0x01, 0x87, 0x01, 0x8D, 0x01, 0x93, 0x01,
0x99, 0x01, 0x9F, 0x01, 0xA5, 0x01, 0xAB, 0x01, 0x18, 0xFC, 0x32, 0x32, 0x32, 0x32, 0x32, 0x32, 0x32, 0x32, 0x32,
0x32, 0x32, 0x32, 0x32, 0x32, 0x32, 0x32, 0x32, 0x32, 0x32, 0x32, 0x32, 0x32, 0x32, 0x32, 0x32, 0x32, 0x32, 0x32,
0x32, 0xF4, 0x01, 0x3B, 0x3B, 0x3B, 0x3B, 0x3B, 0x3B, 0x3B, 0x3B, 0x3B, 0x3B, 0x3B, 0x3B, 0x3B, 0x3B, 0x3B, 0x3B,
0x3B, 0x3B, 0x3B, 0x3B, 0x3B, 0x3B, 0x3B, 0x3B, 0x3B, 0x3B, 0x3B, 0x3B, 0x3B, 0x64, 0x00, 0x85, 0x03, 0x9A, 0x03,
0x85, 0x03, 0x9A, 0x03, 0x85, 0x03, 0x9A, 0x03, 0x85, 0x03, 0x9A, 0x03, 0x85, 0x03, 0x9A, 0x03, 0x85, 0x03, 0x9A,
0x03, 0x85, 0x03, 0x9A, 0x03, 0x85, 0x03, 0x9A, 0x03, 0x85, 0x03, 0x9A, 0x03, 0x85, 0x03, 0x9A, 0x03, 0x85, 0x03,
0x9A, 0x03, 0x85, 0x03, 0x9A, 0x03, 0x85, 0x03, 0x9A, 0x03, 0x85, 0x03};
static void test_payload_golden_vectors() {
BatchInput expected_sync = {};
expected_sync.sender_id = 1;
expected_sync.batch_id = 0x1234;
expected_sync.t_last = 1769904100;
expected_sync.present_mask = 0;
expected_sync.n = 0;
expected_sync.battery_mV = 3750;
expected_sync.err_m = 0;
expected_sync.err_d = 0;
expected_sync.err_tx = 0;
expected_sync.err_last = 0;
expected_sync.err_rx_reject = 0;
BatchInput expected_sparse = {};
fill_sparse_batch(expected_sparse);
BatchInput expected_full = {};
fill_full_batch(expected_full);
struct VectorCase {
const char *name;
const uint8_t *bytes;
size_t len;
const BatchInput *expected;
} cases[] = {
{"sync_empty", VECTOR_SYNC_EMPTY, sizeof(VECTOR_SYNC_EMPTY), &expected_sync},
{"sparse_5", VECTOR_SPARSE_5, sizeof(VECTOR_SPARSE_5), &expected_sparse},
{"full_30", VECTOR_FULL_30, sizeof(VECTOR_FULL_30), &expected_full},
};
for (size_t i = 0; i < (sizeof(cases) / sizeof(cases[0])); ++i) {
BatchInput decoded = {};
TEST_ASSERT_TRUE_MESSAGE(decode_batch(cases[i].bytes, cases[i].len, &decoded), cases[i].name);
assert_batch_equals(*cases[i].expected, decoded);
uint8_t reencoded[512] = {};
size_t reencoded_len = 0;
TEST_ASSERT_TRUE_MESSAGE(encode_batch(*cases[i].expected, reencoded, sizeof(reencoded), &reencoded_len), cases[i].name);
TEST_ASSERT_EQUAL_UINT_MESSAGE(cases[i].len, reencoded_len, cases[i].name);
TEST_ASSERT_EQUAL_UINT8_ARRAY_MESSAGE(cases[i].bytes, reencoded, cases[i].len, cases[i].name);
}
}
void setup() {
dd3_legacy_core_force_link();
UNITY_BEGIN();
RUN_TEST(test_encode_decode_roundtrip_schema_v3);
RUN_TEST(test_decode_rejects_bad_magic_schema_flags);
RUN_TEST(test_decode_rejects_truncated_and_length_mismatch);
RUN_TEST(test_encode_and_decode_reject_invalid_present_mask);
RUN_TEST(test_encode_rejects_invalid_n_and_regression_cases);
RUN_TEST(test_payload_golden_vectors);
UNITY_END();
}
void loop() {}

View File

@@ -0,0 +1,41 @@
#include <Arduino.h>
#include <unity.h>
#include "app_context.h"
#include "receiver_pipeline.h"
#include "sender_state_machine.h"
#include "config.h"
static void test_refactor_headers_and_types() {
SenderStateMachineConfig sender_cfg = {};
sender_cfg.short_id = 0xF19C;
sender_cfg.device_id = "dd3-F19C";
ReceiverSharedState shared = {};
ReceiverPipelineConfig receiver_cfg = {};
receiver_cfg.short_id = 0xF19C;
receiver_cfg.device_id = "dd3-F19C";
receiver_cfg.shared = &shared;
SenderStateMachine sender_sm;
ReceiverPipeline receiver_pipe;
TEST_ASSERT_EQUAL_UINT16(0xF19C, sender_cfg.short_id);
TEST_ASSERT_NOT_NULL(receiver_cfg.shared);
(void)sender_sm;
(void)receiver_pipe;
}
static void test_ha_manufacturer_constant() {
TEST_ASSERT_EQUAL_STRING("AcidBurns", HA_MANUFACTURER);
}
void setup() {
UNITY_BEGIN();
RUN_TEST(test_refactor_headers_and_types);
RUN_TEST(test_ha_manufacturer_constant);
UNITY_END();
}
void loop() {}