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:
65
README.md
65
README.md
@@ -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`.
|
||||||
|
|||||||
235
Requirements.md
235
Requirements.md
@@ -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
48
docs/TESTS.md
Normal 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
3
include/app_context.h
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "../src/app_context.h"
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
3
include/receiver_pipeline.h
Normal file
3
include/receiver_pipeline.h
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "../src/receiver_pipeline.h"
|
||||||
3
include/sender_state_machine.h
Normal file
3
include/sender_state_machine.h
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "../src/sender_state_machine.h"
|
||||||
3
lib/dd3_legacy_core/include/data_model.h
Normal file
3
lib/dd3_legacy_core/include/data_model.h
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "../../../include/data_model.h"
|
||||||
4
lib/dd3_legacy_core/include/dd3_legacy_core.h
Normal file
4
lib/dd3_legacy_core/include/dd3_legacy_core.h
Normal 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();
|
||||||
3
lib/dd3_legacy_core/include/html_util.h
Normal file
3
lib/dd3_legacy_core/include/html_util.h
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "../../../include/html_util.h"
|
||||||
3
lib/dd3_legacy_core/include/json_codec.h
Normal file
3
lib/dd3_legacy_core/include/json_codec.h
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "../../../include/json_codec.h"
|
||||||
37
lib/dd3_legacy_core/include/payload_codec.h
Normal file
37
lib/dd3_legacy_core/include/payload_codec.h
Normal 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
|
||||||
@@ -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";
|
||||||
}
|
}
|
||||||
3
lib/dd3_legacy_core/src/dd3_legacy_core.cpp
Normal file
3
lib/dd3_legacy_core/src/dd3_legacy_core.cpp
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
#include "dd3_legacy_core.h"
|
||||||
|
|
||||||
|
void dd3_legacy_core_force_link() {}
|
||||||
28
lib/dd3_transport_logic/include/batch_reassembly_logic.h
Normal file
28
lib/dd3_transport_logic/include/batch_reassembly_logic.h
Normal 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);
|
||||||
7
lib/dd3_transport_logic/include/ha_discovery_json.h
Normal file
7
lib/dd3_transport_logic/include/ha_discovery_json.h
Normal 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);
|
||||||
19
lib/dd3_transport_logic/include/lora_frame_logic.h
Normal file
19
lib/dd3_transport_logic/include/lora_frame_logic.h
Normal 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);
|
||||||
75
lib/dd3_transport_logic/src/batch_reassembly_logic.cpp
Normal file
75
lib/dd3_transport_logic/src/batch_reassembly_logic.cpp
Normal 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;
|
||||||
|
}
|
||||||
37
lib/dd3_transport_logic/src/ha_discovery_json.cpp
Normal file
37
lib/dd3_transport_logic/src/ha_discovery_json.cpp
Normal 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;
|
||||||
|
}
|
||||||
88
lib/dd3_transport_logic/src/lora_frame_logic.cpp
Normal file
88
lib/dd3_transport_logic/src/lora_frame_logic.cpp
Normal 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
28
src/app_context.h
Normal 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;
|
||||||
|
};
|
||||||
|
|
||||||
@@ -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;
|
||||||
|
|||||||
1553
src/main.cpp
1553
src/main.cpp
File diff suppressed because it is too large
Load Diff
@@ -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++;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
523
src/receiver_pipeline.cpp
Normal 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
27
src/receiver_pipeline.h
Normal 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;
|
||||||
|
};
|
||||||
|
|
||||||
@@ -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
1604
src/sender_state_machine.cpp
Normal file
File diff suppressed because it is too large
Load Diff
44
src/sender_state_machine.h
Normal file
44
src/sender_state_machine.h
Normal 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();
|
||||||
|
};
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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>";
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
37
test/check_ha_manufacturer.ps1
Normal file
37
test/check_ha_manufacturer.ps1
Normal 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."
|
||||||
@@ -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("&<>"'", html_escape("&<>\"'").c_str());
|
TEST_ASSERT_EQUAL_STRING("&<>"'", html_escape("&<>\"'").c_str());
|
||||||
}
|
}
|
||||||
|
|
||||||
static void test_sanitize_device_id() {
|
static void test_html_escape_adversarial() {
|
||||||
|
TEST_ASSERT_EQUAL_STRING("&amp;", html_escape("&").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 = "<&>"'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("<&>"'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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
129
test/test_json_codec/test_json_codec.cpp
Normal file
129
test/test_json_codec/test_json_codec.cpp
Normal 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() {}
|
||||||
131
test/test_lora_transport/test_lora_transport.cpp
Normal file
131
test/test_lora_transport/test_lora_transport.cpp
Normal 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() {}
|
||||||
279
test/test_payload_codec/test_payload_codec.cpp
Normal file
279
test/test_payload_codec/test_payload_codec.cpp
Normal 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() {}
|
||||||
41
test/test_refactor_smoke/test_refactor_smoke.cpp
Normal file
41
test/test_refactor_smoke/test_refactor_smoke.cpp
Normal 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() {}
|
||||||
|
|
||||||
Reference in New Issue
Block a user