diff --git a/README.md b/README.md index c9b5b8c..5a1e810 100644 --- a/README.md +++ b/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`). - 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`). -- Batch payload codec is schema `v3` with a 30-bit `present_mask` over `[t_last-29, t_last]` (`src/payload_codec.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]` (`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 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. ## LoRa Protocol @@ -44,6 +48,7 @@ Payload codec (`schema=3`, magic `0xDDB3`) carries: - `flags bit0`: `time_valid` - 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 ACK wait windows are adaptive (short first window, expanded second window on miss) ## Time Bootstrap and Timezone @@ -55,8 +60,12 @@ Sender boot starts in sync-only mode: After valid ACK time: - `time_set_utc()` is called - `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 +After initial sync: +- sender fault counters are reset again once per UTC hour when the hour index changes (`HH:00 UTC` boundary) + Timezone: - `TIMEZONE_TZ` from `include/config.h` is applied in `time_manager`. - Web/OLED local-time rendering uses this timezone. @@ -64,10 +73,12 @@ Timezone: ## 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` - ESP32 RX buffer enlarged to `8192` - 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: - `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) @@ -80,16 +91,27 @@ Timestamp derivation: - jump checks: rollback, wall-time delta mismatch, anchor drift 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 For decoded `BatchUp`: 1. Reassemble and decode. -2. Send `AckDown` immediately. -3. Track duplicates per configured sender (`EXPECTED_SENDER_IDS`). -4. If duplicate: update duplicate counters/time, skip data write/publish. -5. If `n==0`: sync request path only. -6. Else reconstruct each sample timestamp from `t_last + present_mask`, then: +2. Validate sender identity (`EXPECTED_SENDER_IDS` and payload sender ID mapping). +3. Reject unknown/mismatched senders before ACK and before SD/MQTT/web updates. +4. Send `AckDown` promptly for accepted senders. +5. Track duplicates per configured sender. +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 - publish MQTT state - update web status and last batch table @@ -102,7 +124,7 @@ State topic: Fault topic (retained): - `smartmeter//faults` -State JSON (`src/json_codec.cpp`) includes: +State JSON (`lib/dd3_legacy_core/src/json_codec.cpp`) includes: - `id`, `ts`, `e_kwh` - `p_w`, `p1_w`, `p2_w`, `p3_w` - `bat_v`, `bat_pct` @@ -110,28 +132,43 @@ State JSON (`src/json_codec.cpp`) includes: - `err_last`, `rx_reject`, `rx_reject_text` - 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: - enabled by `ENABLE_HA_DISCOVERY=true` - publishes to `homeassistant/sensor///config` +- `unique_id` format is `_` (example: `dd3-F19C_energy`) +- device metadata: + - `identifiers: [""]` + - `name: ""` + - `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 - Wi-Fi/MQTT/NTP/web-auth config is stored in Preferences. - AP fallback SSID prefix: `DD3-Bridge-`. - 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`). Web timestamp display: - human-facing timestamps show `epoch (HH:MM:SS TZ)` in local configured timezone. 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` -- `ts_hms_utc` is UTC `HH:MM:SS` +- 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_local` is local `HH:MM:SS` derived from `TIMEZONE_TZ` +- per-day file partition uses local date from `TIMEZONE_TZ`: `/dd3//YYYY-MM-DD.csv` History parser (`src/web_server.cpp`): -- expects the current CSV layout above -- legacy CSV layouts are not parsed (no backward compatibility) +- accepts both: + - 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: - receiver sender-pages show duplicate rate as `pct (absolute)` and last duplicate as `HH:MM`. diff --git a/Requirements.md b/Requirements.md index 221cabb..99d73cb 100644 --- a/Requirements.md +++ b/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. -## 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: - `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: - 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`. + - 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: - sender sample cadence 1 Hz. - 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. + - 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: - 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. - Persistence: - 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: - 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: - - CSV columns include both `ts_utc` and `ts_hms_utc`. - - history parser expects this current layout. + - CSV columns include both `ts_utc` and `ts_hms_local`. + - per-day CSV file partitioning uses local date (`TIMEZONE_TZ`) under `/dd3//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`: - `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: - `[flags:1][batch_id_be:2][epoch_utc_be:4]` - `flags bit0 = time_valid` + - sender acceptance window is implementation-adaptive; payload format stays unchanged. - `BatchInput`: - 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` - Timestamp constraints: - receiver rejects decoded data whose timestamps are below `MIN_ACCEPTED_EPOCH_UTC` - 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///config` + - `unique_id`: `_` + - `device.identifiers`: `[""]` + - `device.name`: `` + - `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` - `DeviceRole detect_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)` - read MAC, derive short ID, format canonical device ID. - `const char *rx_reject_reason_text(RxRejectReason)` - stable mapping for diagnostics and payloads. -## `src/html_util.cpp` +## `lib/dd3_legacy_core/src/html_util.cpp` - `String html_escape(const String&)` - 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. - `bool meter_read(MeterData&)` - 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: - - `parse_obis_ascii_value` + - `detect_obis_field` + - `parse_decimal_fixed` + - `parse_obis_ascii_payload_value` - `parse_obis_ascii_unit_scale` - `hex_nibble` - - `parse_obis_hex_u32` + - `parse_obis_hex_payload_u32` - `meter_debug_log` ## `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. - `uint32_t time_get_utc()` - return epoch or `0` when not plausible. + - updates "clock plausible" state independently from sync state. - `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)` - set system time and sync flags. - `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()` - Internal behavior-critical helpers: - `note_last_sync` + - `mark_synced` + - `ntp_sync_notification_cb` - `ensure_timezone_set` ## `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. - Internal behavior-critical helpers: - `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*)` - 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: - `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&)` - 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` - `mqtt_connect` - `publish_discovery_sensor` + - discovery payload uses canonical device identity fields and `manufacturer=AcidBurns` ## `src/wifi_manager.cpp` - `void wifi_manager_init()` - `bool wifi_load_config(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)` - `void wifi_start_ap(const char*, const char*)` - `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()` - `bool sd_logger_is_ready()` - `void sd_logger_log_sample(const MeterData&, bool include_error_text)` - - append/create per-day CSV under `/dd3//YYYY-MM-DD.csv`. + - append/create per-day CSV under `/dd3//YYYY-MM-DD.csv` using local calendar date from `TIMEZONE_TZ`. - Internal behavior-critical helpers: - `fault_text` - `ensure_dir` - - `format_date_utc` - - `format_hms_utc` + - `format_date_local` + - `format_hms_local` ## `src/display_ui.cpp` @@ -281,7 +341,8 @@ Internal route/state functions to preserve behavior: - `sanitize_history_device_id` - `sanitize_download_filename` - `history_reset` -- `history_date_from_epoch` +- `history_date_from_epoch_local` +- `history_date_from_epoch_utc` (legacy fallback mapping) - `history_open_next_file` - `history_parse_line` - `history_tick` @@ -303,15 +364,25 @@ Internal route/state functions to preserve behavior: - `test_receiver_loop` - 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: - `serial_debug_printf` - `bit_count32` - `abs_diff_u32` - - Meter-time anchoring and ingest: - `meter_time_update_snapshot` - `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_start` - `meter_reader_pump` - -- Sender/receiver state setup and shared state: - - `init_sender_statuses` +- Sender state/data handling: - `update_battery_cache` - `battery_sample_due` - -- Queue and sample batching: - `batch_queue_drop_oldest` - `sender_note_rx_reject` + - `sender_log_diagnostics` - `batch_queue_peek` - `batch_queue_enqueue` - `reset_build_counters` - `append_meter_sample` - `last_sample_ts` - -- Fault tracking/publish: +- Sender fault handling: - `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` - `counters_changed` - `publish_faults_if_needed` - -- Watchdog: - - `watchdog_init` - - `watchdog_kick` - - Binary helpers and ID conversion: - `write_u16_le` - `read_u16_le` @@ -354,59 +451,51 @@ These functions define end-to-end firmware behavior and must have equivalents: - `read_u32_be` - `sender_id_from_short_id` - `short_id_from_sender_id` - -- 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: +- LoRa RX/TX pipeline: - `compute_batch_rx_timeout_ms` - - `compute_batch_ack_timeout_ms` - -- LoRa TX pipeline: - - `send_batch_payload` - `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` - `process_batch_packet` - -- Role loop orchestration: - - `setup` - - `sender_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: - LoRa frame byte layout, CRC16, ACK format, payload schema v3. + - sender optimization changes must not alter payload field meanings. - Preserve persistent storage keys: - Preferences keys (`ssid`, `pass`, `mqhost`, `mqport`, `mquser`, `mqpass`, `ntp1`, `ntp2`, `webuser`, `webpass`, `valid`). - Preserve timing constants and acceptance thresholds: - bootstrap guardrail, retry counts, schedule intervals, min accepted epoch. - Preserve CSV output layout exactly: - consumers (history parser and external tooling) depend on it. + - preserve reader compatibility for both current and legacy layouts. - Preserve enum meanings: - `FaultType`, `RxRejectReason`, `LoraMsgKind`. 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: - async task for meter reader + bounded channel (drop-oldest behavior). - explicit state structs for sender/receiver loops. - serde-free/manual codec for wire compatibility where needed. -## 6. Port Validation Checklist +## 7. Port Validation Checklist - Sender unsynced boot sends only sync requests. - ACK time bootstrap unlocks normal sender sampling. @@ -415,5 +504,25 @@ Suggested Rust primitives: - Duplicate batch handling updates counters and suppresses duplicate publish/log. - Web UI shows `epoch (HH:MM:SS TZ)` local time. - 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. + +## 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. diff --git a/docs/TESTS.md b/docs/TESTS.md new file mode 100644 index 0000000..145b965 --- /dev/null +++ b/docs/TESTS.md @@ -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 +``` diff --git a/include/app_context.h b/include/app_context.h new file mode 100644 index 0000000..e647134 --- /dev/null +++ b/include/app_context.h @@ -0,0 +1,3 @@ +#pragma once + +#include "../src/app_context.h" diff --git a/include/config.h b/include/config.h index 22fcb51..c4b1533 100644 --- a/include/config.h +++ b/include/config.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_PASSWORD = "changeme123"; 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_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 uint32_t MIN_ACCEPTED_EPOCH_UTC = 1769904000UL; // 2026-02-01 00:00:00 UTC diff --git a/include/data_model.h b/include/data_model.h index 5b825f8..eadf2d3 100644 --- a/include/data_model.h +++ b/include/data_model.h @@ -15,7 +15,8 @@ enum class RxRejectReason : uint8_t { InvalidMsgKind = 2, LengthMismatch = 3, DeviceIdMismatch = 4, - BatchIdMismatch = 5 + BatchIdMismatch = 5, + UnknownSender = 6 }; struct FaultCounters { diff --git a/include/meter_driver.h b/include/meter_driver.h index a38cf73..de0b025 100644 --- a/include/meter_driver.h +++ b/include/meter_driver.h @@ -3,7 +3,18 @@ #include #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(); bool meter_read(MeterData &data); bool meter_poll_frame(const char *&frame, size_t &len); bool meter_parse_frame(const char *frame, size_t len, MeterData &data); +void meter_get_stats(MeterDriverStats &out); diff --git a/include/receiver_pipeline.h b/include/receiver_pipeline.h new file mode 100644 index 0000000..78e2aeb --- /dev/null +++ b/include/receiver_pipeline.h @@ -0,0 +1,3 @@ +#pragma once + +#include "../src/receiver_pipeline.h" diff --git a/include/sender_state_machine.h b/include/sender_state_machine.h new file mode 100644 index 0000000..0262466 --- /dev/null +++ b/include/sender_state_machine.h @@ -0,0 +1,3 @@ +#pragma once + +#include "../src/sender_state_machine.h" diff --git a/lib/dd3_legacy_core/include/data_model.h b/lib/dd3_legacy_core/include/data_model.h new file mode 100644 index 0000000..92e1b3b --- /dev/null +++ b/lib/dd3_legacy_core/include/data_model.h @@ -0,0 +1,3 @@ +#pragma once + +#include "../../../include/data_model.h" diff --git a/lib/dd3_legacy_core/include/dd3_legacy_core.h b/lib/dd3_legacy_core/include/dd3_legacy_core.h new file mode 100644 index 0000000..763d951 --- /dev/null +++ b/lib/dd3_legacy_core/include/dd3_legacy_core.h @@ -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(); diff --git a/lib/dd3_legacy_core/include/html_util.h b/lib/dd3_legacy_core/include/html_util.h new file mode 100644 index 0000000..845f04b --- /dev/null +++ b/lib/dd3_legacy_core/include/html_util.h @@ -0,0 +1,3 @@ +#pragma once + +#include "../../../include/html_util.h" diff --git a/lib/dd3_legacy_core/include/json_codec.h b/lib/dd3_legacy_core/include/json_codec.h new file mode 100644 index 0000000..475242b --- /dev/null +++ b/lib/dd3_legacy_core/include/json_codec.h @@ -0,0 +1,3 @@ +#pragma once + +#include "../../../include/json_codec.h" diff --git a/lib/dd3_legacy_core/include/payload_codec.h b/lib/dd3_legacy_core/include/payload_codec.h new file mode 100644 index 0000000..1a60b02 --- /dev/null +++ b/lib/dd3_legacy_core/include/payload_codec.h @@ -0,0 +1,37 @@ +#pragma once + +#include + +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 diff --git a/src/data_model.cpp b/lib/dd3_legacy_core/src/data_model.cpp similarity index 92% rename from src/data_model.cpp rename to lib/dd3_legacy_core/src/data_model.cpp index 0ecdbe9..34b2241 100644 --- a/src/data_model.cpp +++ b/lib/dd3_legacy_core/src/data_model.cpp @@ -21,6 +21,8 @@ const char *rx_reject_reason_text(RxRejectReason reason) { return "device_id_mismatch"; case RxRejectReason::BatchIdMismatch: return "batch_id_mismatch"; + case RxRejectReason::UnknownSender: + return "unknown_sender"; default: return "none"; } diff --git a/lib/dd3_legacy_core/src/dd3_legacy_core.cpp b/lib/dd3_legacy_core/src/dd3_legacy_core.cpp new file mode 100644 index 0000000..6de53d6 --- /dev/null +++ b/lib/dd3_legacy_core/src/dd3_legacy_core.cpp @@ -0,0 +1,3 @@ +#include "dd3_legacy_core.h" + +void dd3_legacy_core_force_link() {} diff --git a/src/html_util.cpp b/lib/dd3_legacy_core/src/html_util.cpp similarity index 100% rename from src/html_util.cpp rename to lib/dd3_legacy_core/src/html_util.cpp diff --git a/src/json_codec.cpp b/lib/dd3_legacy_core/src/json_codec.cpp similarity index 100% rename from src/json_codec.cpp rename to lib/dd3_legacy_core/src/json_codec.cpp diff --git a/src/payload_codec.cpp b/lib/dd3_legacy_core/src/payload_codec.cpp similarity index 100% rename from src/payload_codec.cpp rename to lib/dd3_legacy_core/src/payload_codec.cpp diff --git a/lib/dd3_transport_logic/include/batch_reassembly_logic.h b/lib/dd3_transport_logic/include/batch_reassembly_logic.h new file mode 100644 index 0000000..3c67142 --- /dev/null +++ b/lib/dd3_transport_logic/include/batch_reassembly_logic.h @@ -0,0 +1,28 @@ +#pragma once + +#include + +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); diff --git a/lib/dd3_transport_logic/include/ha_discovery_json.h b/lib/dd3_transport_logic/include/ha_discovery_json.h new file mode 100644 index 0000000..222f14f --- /dev/null +++ b/lib/dd3_transport_logic/include/ha_discovery_json.h @@ -0,0 +1,7 @@ +#pragma once + +#include + +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); diff --git a/lib/dd3_transport_logic/include/lora_frame_logic.h b/lib/dd3_transport_logic/include/lora_frame_logic.h new file mode 100644 index 0000000..0d64abe --- /dev/null +++ b/lib/dd3_transport_logic/include/lora_frame_logic.h @@ -0,0 +1,19 @@ +#pragma once + +#include + +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); diff --git a/lib/dd3_transport_logic/src/batch_reassembly_logic.cpp b/lib/dd3_transport_logic/src/batch_reassembly_logic.cpp new file mode 100644 index 0000000..1181086 --- /dev/null +++ b/lib/dd3_transport_logic/src/batch_reassembly_logic.cpp @@ -0,0 +1,75 @@ +#include "batch_reassembly_logic.h" + +#include + +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(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; +} diff --git a/lib/dd3_transport_logic/src/ha_discovery_json.cpp b/lib/dd3_transport_logic/src/ha_discovery_json.cpp new file mode 100644 index 0000000..4056466 --- /dev/null +++ b/lib/dd3_transport_logic/src/ha_discovery_json.cpp @@ -0,0 +1,37 @@ +#include "ha_discovery_json.h" + +#include + +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; +} diff --git a/lib/dd3_transport_logic/src/lora_frame_logic.cpp b/lib/dd3_transport_logic/src/lora_frame_logic.cpp new file mode 100644 index 0000000..17ed10e --- /dev/null +++ b/lib/dd3_transport_logic/src/lora_frame_logic.cpp @@ -0,0 +1,88 @@ +#include "lora_frame_logic.h" + +#include + +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(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(device_id_short >> 8); + out_frame[idx++] = static_cast(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(crc >> 8); + out_frame[idx++] = static_cast(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(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(frame[1] << 8) | frame[2]; + if (payload_len > 0) { + memcpy(out_payload, &frame[3], payload_len); + } + *out_payload_len = payload_len; + return LoraFrameDecodeStatus::Ok; +} diff --git a/src/app_context.h b/src/app_context.h new file mode 100644 index 0000000..2d450de --- /dev/null +++ b/src/app_context.h @@ -0,0 +1,28 @@ +#pragma once + +#include + +#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; +}; + diff --git a/src/lora_transport.cpp b/src/lora_transport.cpp index 4463629..be36277 100644 --- a/src/lora_transport.cpp +++ b/src/lora_transport.cpp @@ -1,4 +1,5 @@ #include "lora_transport.h" +#include "lora_frame_logic.h" #include #include #include @@ -35,21 +36,6 @@ bool lora_get_last_rx_signal(int16_t &rssi_dbm, float &snr_db) { 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(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() { 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); @@ -66,54 +52,35 @@ bool lora_send(const LoraPacket &pkt) { return true; } uint32_t t0 = 0; - uint32_t t1 = 0; - uint32_t t2 = 0; - uint32_t t3 = 0; - uint32_t t4 = 0; if (SERIAL_DEBUG_MODE) { t0 = millis(); } 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(pkt.msg_kind); - buffer[idx++] = static_cast(pkt.device_id_short >> 8); - buffer[idx++] = static_cast(pkt.device_id_short & 0xFF); - if (pkt.payload_len > LORA_MAX_PAYLOAD) { return false; } - memcpy(&buffer[idx], pkt.payload, pkt.payload_len); - idx += pkt.payload_len; - - uint16_t crc = crc16_ccitt(buffer, idx); - buffer[idx++] = static_cast(crc >> 8); - buffer[idx++] = static_cast(crc & 0xFF); + uint8_t buffer[1 + 2 + LORA_MAX_PAYLOAD + 2]; + size_t frame_len = 0; + if (!lora_build_frame(static_cast(pkt.msg_kind), pkt.device_id_short, pkt.payload, pkt.payload_len, + buffer, sizeof(buffer), frame_len)) { + return false; + } LoRa.beginPacket(); - if (SERIAL_DEBUG_MODE) { - t2 = millis(); - } - LoRa.write(buffer, idx); - if (SERIAL_DEBUG_MODE) { - t3 = millis(); - } + LoRa.write(buffer, frame_len); int result = LoRa.endPacket(false); + bool ok = result == 1; if (SERIAL_DEBUG_MODE) { - t4 = millis(); - Serial.printf("lora_tx: idle=%lums begin=%lums write=%lums end=%lums total=%lums len=%u\n", - static_cast(t1 - t0), - static_cast(t2 - t1), - static_cast(t3 - t2), - static_cast(t4 - t3), - static_cast(t4 - t0), - static_cast(idx)); + uint32_t tx_ms = millis() - t0; + if (!ok || tx_ms > 2000) { + Serial.printf("lora_tx: len=%u total=%lums ok=%u\n", + static_cast(frame_len), + static_cast(tx_ms), + ok ? 1U : 0U); + } } - return result == 1; + return ok; } bool lora_receive(LoraPacket &pkt, uint32_t timeout_ms) { @@ -154,26 +121,33 @@ bool lora_receive(LoraPacket &pkt, uint32_t timeout_ms) { return false; } - uint16_t crc_calc = crc16_ccitt(buffer, len - 2); - uint16_t crc_rx = static_cast(buffer[len - 2] << 8) | buffer[len - 1]; - if (crc_calc != crc_rx) { + uint8_t msg_kind = 0; + uint16_t device_id_short = 0; + size_t payload_len = 0; + LoraFrameDecodeStatus status = lora_parse_frame( + buffer, len, static_cast(LoraMsgKind::AckDown), &msg_kind, &device_id_short, + pkt.payload, sizeof(pkt.payload), &payload_len); + + if (status == LoraFrameDecodeStatus::CrcFail) { note_reject(RxRejectReason::CrcFail); return false; } - uint8_t msg_kind = buffer[0]; - if (msg_kind > static_cast(LoraMsgKind::AckDown)) { + if (status == LoraFrameDecodeStatus::InvalidMsgKind) { note_reject(RxRejectReason::InvalidMsgKind); return false; } + if (status == LoraFrameDecodeStatus::LengthMismatch) { + note_reject(RxRejectReason::LengthMismatch); + return false; + } pkt.msg_kind = static_cast(msg_kind); - pkt.device_id_short = static_cast(buffer[1] << 8) | buffer[2]; - pkt.payload_len = len - 5; + pkt.device_id_short = device_id_short; + pkt.payload_len = payload_len; if (pkt.payload_len > LORA_MAX_PAYLOAD) { note_reject(RxRejectReason::LengthMismatch); return false; } - memcpy(pkt.payload, &buffer[3], pkt.payload_len); pkt.rssi_dbm = g_last_rx_rssi_dbm; pkt.snr_db = g_last_rx_snr_db; return true; diff --git a/src/main.cpp b/src/main.cpp index 2cebc28..11dc8f9 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1,146 +1,36 @@ #include + +#include "app_context.h" #include "config.h" #include "data_model.h" -#include "payload_codec.h" -#include "lora_transport.h" -#include "meter_driver.h" -#include "power_manager.h" -#include "time_manager.h" -#include "wifi_manager.h" -#include "mqtt_client.h" -#include "web_server.h" +#include "dd3_legacy_core.h" #include "display_ui.h" -#include "test_mode.h" +#include "lora_transport.h" +#include "mqtt_client.h" +#include "payload_codec.h" +#include "power_manager.h" +#include "receiver_pipeline.h" #include "sd_logger.h" +#include "sender_state_machine.h" +#include "time_manager.h" +#include "web_server.h" +#include "wifi_manager.h" + #include -#include + #ifdef ARDUINO_ARCH_ESP32 -#include #include -#include -#include -#include +#include #endif static DeviceRole g_role = DeviceRole::Sender; static uint16_t g_short_id = 0; static char g_device_id[16] = ""; -static SenderStatus g_sender_statuses[NUM_SENDERS]; -static bool g_ap_mode = false; static WifiMqttConfig g_cfg; -static FaultCounters g_sender_faults = {}; -static FaultCounters g_receiver_faults = {}; -static FaultCounters g_receiver_faults_published = {}; -static FaultCounters g_sender_faults_remote[NUM_SENDERS] = {}; -static FaultCounters g_sender_faults_remote_published[NUM_SENDERS] = {}; -static FaultType g_sender_last_error = FaultType::None; -static FaultType g_receiver_last_error = FaultType::None; -static FaultType g_sender_last_error_remote[NUM_SENDERS] = {}; -static FaultType g_sender_last_error_remote_published[NUM_SENDERS] = {}; -static FaultType g_receiver_last_error_published = FaultType::None; -static uint32_t g_sender_last_error_utc = 0; -static uint32_t g_sender_last_error_ms = 0; -static uint32_t g_receiver_last_error_utc = 0; -static uint32_t g_receiver_last_error_ms = 0; -static uint32_t g_sender_last_error_remote_utc[NUM_SENDERS] = {}; -static uint32_t g_sender_last_error_remote_ms[NUM_SENDERS] = {}; -static bool g_sender_discovery_sent[NUM_SENDERS] = {}; -static bool g_receiver_discovery_sent = false; - -// WiFi reconnection in AP mode: retry every 30 seconds -static uint32_t g_last_wifi_reconnect_attempt_ms = 0; -static constexpr uint32_t WIFI_RECONNECT_INTERVAL_MS = 30000; - -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; - -struct BatchBuffer { - uint16_t batch_id; - bool batch_id_valid; - uint8_t count; - uint16_t attempt_count; - uint16_t valid_count; - uint16_t invalid_count; - FaultType last_error; - MeterData samples[METER_BATCH_MAX_SAMPLES]; -}; - -static BatchBuffer g_batch_queue[BATCH_QUEUE_DEPTH]; -static uint8_t g_batch_head = 0; -static uint8_t g_batch_tail = 0; -static uint8_t g_batch_count = 0; - -static MeterData g_build_samples[METER_BATCH_MAX_SAMPLES]; -static uint8_t g_build_count = 0; - -static uint32_t g_last_sample_ms = 0; -static uint32_t g_last_sample_ts_utc = 0; -static uint32_t g_last_send_ms = 0; -static uint32_t g_last_batch_send_ms = 0; -static float g_last_battery_voltage_v = NAN; -static uint8_t g_last_battery_percent = 0; -static uint32_t g_last_battery_ms = 0; -static uint16_t g_batch_id = 1; -static uint16_t g_last_sent_batch_id = 0; -static uint16_t g_last_acked_batch_id = 0; -static uint8_t g_batch_retry_count = 0; -static bool g_batch_ack_pending = false; -static uint32_t g_batch_ack_timeout_ms = BATCH_ACK_TIMEOUT_MS; -static MeterData g_inflight_samples[METER_BATCH_MAX_SAMPLES]; -static uint8_t g_inflight_count = 0; -static uint16_t g_inflight_batch_id = 0; -static bool g_inflight_active = false; -static bool g_inflight_sync_request = false; -static uint32_t g_last_debug_log_ms = 0; -static uint32_t g_sender_rx_window_ms = 0; -static uint32_t g_sender_sleep_ms = 0; -static uint32_t g_sender_power_log_ms = 0; -static RxRejectReason g_sender_rx_reject_reason = RxRejectReason::None; -static uint32_t g_sender_rx_reject_log_ms = 0; -static MeterData g_last_meter_data = {}; -static bool g_last_meter_valid = false; -static uint32_t g_last_meter_rx_ms = 0; -static uint32_t g_meter_stale_seconds = 0; -static bool g_meter_time_anchor_valid = false; -static int64_t g_meter_epoch_offset = 0; -static bool g_meter_time_prev_valid = false; -static uint32_t g_meter_time_prev_seconds = 0; -static uint32_t g_meter_time_prev_rx_ms = 0; -static bool g_meter_time_jump_pending = false; -static bool g_time_acquired = false; -static uint32_t g_last_sync_request_ms = 0; -static uint32_t g_build_attempts = 0; -static uint32_t g_build_valid = 0; -static uint32_t g_build_invalid = 0; -static constexpr uint32_t METER_SAMPLE_MAX_AGE_MS = 15000; -static constexpr uint32_t METER_TIME_DELTA_TOLERANCE_S = 2; -static constexpr int64_t METER_TIME_ANCHOR_DRIFT_TOLERANCE_S = 2; -struct MeterSampleEvent { - MeterData data; - uint32_t rx_ms; -}; -#ifdef ARDUINO_ARCH_ESP32 -static QueueHandle_t g_meter_sample_queue = nullptr; -static TaskHandle_t g_meter_reader_task = nullptr; -static bool g_meter_reader_task_running = false; -static constexpr UBaseType_t METER_SAMPLE_QUEUE_LEN = 8; -static constexpr uint32_t METER_READER_TASK_STACK_WORDS = 4096; -static constexpr UBaseType_t METER_READER_TASK_PRIORITY = 2; -static constexpr BaseType_t METER_READER_TASK_CORE = 0; -#endif - -enum class TxBuildError : uint8_t { - None = 0, - Encode = 1 -}; - -static TxBuildError g_last_tx_build_error = TxBuildError::None; - -static void watchdog_kick(); -static void finish_inflight_batch(); +static ReceiverSharedState g_receiver_shared = {}; +static SenderStateMachine g_sender_state_machine; +static ReceiverPipeline g_receiver_pipeline; static void serial_debug_printf(const char *fmt, ...) { if (!SERIAL_DEBUG_MODE) { @@ -154,382 +44,6 @@ static void serial_debug_printf(const char *fmt, ...) { 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 uint32_t abs_diff_u32(uint32_t a, uint32_t b) { - return a >= b ? (a - b) : (b - a); -} - -static void meter_time_update_snapshot(MeterData &parsed, uint32_t rx_ms) { - if (!parsed.meter_seconds_valid) { - return; - } - - bool jump = false; - const char *jump_reason = nullptr; - uint32_t delta_meter_s = 0; - uint32_t delta_wall_s = 0; - if (g_meter_time_prev_valid) { - if (parsed.meter_seconds < g_meter_time_prev_seconds) { - jump = true; - jump_reason = "rollback"; - } else { - delta_meter_s = parsed.meter_seconds - g_meter_time_prev_seconds; - uint32_t delta_wall_ms = rx_ms - g_meter_time_prev_rx_ms; - delta_wall_s = (delta_wall_ms + 500) / 1000; - if (abs_diff_u32(delta_meter_s, delta_wall_s) > METER_TIME_DELTA_TOLERANCE_S) { - jump = true; - jump_reason = "delta"; - } - } - } - - if (time_is_synced()) { - uint32_t epoch_now = time_get_utc(); - if (epoch_now >= MIN_ACCEPTED_EPOCH_UTC) { - int64_t new_offset = static_cast(epoch_now) - static_cast(parsed.meter_seconds); - if (!g_meter_time_anchor_valid || jump) { - g_meter_epoch_offset = new_offset; - g_meter_time_anchor_valid = true; - } else { - int64_t drift_s = new_offset - g_meter_epoch_offset; - if (drift_s > METER_TIME_ANCHOR_DRIFT_TOLERANCE_S || drift_s < -METER_TIME_ANCHOR_DRIFT_TOLERANCE_S) { - jump = true; - jump_reason = jump_reason ? jump_reason : "anchor"; - g_meter_epoch_offset = new_offset; - } - } - } - } - - if (g_meter_time_anchor_valid) { - int64_t epoch64 = static_cast(parsed.meter_seconds) + g_meter_epoch_offset; - if (epoch64 > 0 && epoch64 <= static_cast(UINT32_MAX)) { - parsed.ts_utc = static_cast(epoch64); - } - } - - if (jump) { - g_meter_time_jump_pending = true; - if (SERIAL_DEBUG_MODE) { - serial_debug_printf("meter_time: jump reason=%s sec=%lu prev=%lu d_meter=%lu d_wall=%lu", - jump_reason ? jump_reason : "unknown", - static_cast(parsed.meter_seconds), - static_cast(g_meter_time_prev_seconds), - static_cast(delta_meter_s), - static_cast(delta_wall_s)); - } - } - - g_meter_time_prev_seconds = parsed.meter_seconds; - g_meter_time_prev_rx_ms = rx_ms; - g_meter_time_prev_valid = true; -} - -static void set_last_meter_sample(const MeterData &parsed_in, uint32_t rx_ms) { - MeterData parsed = parsed_in; - meter_time_update_snapshot(parsed, rx_ms); - g_last_meter_data = parsed; - g_last_meter_valid = true; - g_last_meter_rx_ms = rx_ms; - g_meter_stale_seconds = 0; -} - -static bool parse_meter_frame_sample(const char *frame, size_t frame_len, MeterData &parsed) { - parsed = {}; - parsed.energy_total_kwh = NAN; - parsed.total_power_w = NAN; - parsed.phase_power_w[0] = NAN; - parsed.phase_power_w[1] = NAN; - parsed.phase_power_w[2] = NAN; - parsed.valid = false; - return meter_parse_frame(frame, frame_len, parsed); -} - -#ifdef ARDUINO_ARCH_ESP32 -static void meter_queue_push_latest(const MeterSampleEvent &event) { - if (!g_meter_sample_queue) { - return; - } - if (xQueueSend(g_meter_sample_queue, &event, 0) == pdTRUE) { - return; - } - MeterSampleEvent dropped = {}; - xQueueReceive(g_meter_sample_queue, &dropped, 0); - if (xQueueSend(g_meter_sample_queue, &event, 0) != pdTRUE && SERIAL_DEBUG_MODE) { - serial_debug_printf("meter: queue push failed"); - } -} - -static void meter_reader_task_entry(void *arg) { - (void)arg; - for (;;) { - const char *frame = nullptr; - size_t frame_len = 0; - if (!meter_poll_frame(frame, frame_len)) { - vTaskDelay(pdMS_TO_TICKS(5)); - continue; - } - - MeterData parsed = {}; - if (parse_meter_frame_sample(frame, frame_len, parsed)) { - MeterSampleEvent event = {}; - event.data = parsed; - event.rx_ms = millis(); - meter_queue_push_latest(event); - } - } -} - -static bool meter_reader_start() { - if (g_meter_reader_task_running) { - return true; - } - if (!g_meter_sample_queue) { - g_meter_sample_queue = xQueueCreate(METER_SAMPLE_QUEUE_LEN, sizeof(MeterSampleEvent)); - if (!g_meter_sample_queue) { - if (SERIAL_DEBUG_MODE) { - serial_debug_printf("meter: queue alloc failed"); - } - return false; - } - } - - BaseType_t rc = xTaskCreatePinnedToCore( - meter_reader_task_entry, - "meter_reader", - METER_READER_TASK_STACK_WORDS, - nullptr, - METER_READER_TASK_PRIORITY, - &g_meter_reader_task, - METER_READER_TASK_CORE); - if (rc != pdPASS) { - if (SERIAL_DEBUG_MODE) { - serial_debug_printf("meter: task start failed rc=%ld", static_cast(rc)); - } - return false; - } - g_meter_reader_task_running = true; - serial_debug_printf("meter: reader task core=%ld queue=%u", - static_cast(METER_READER_TASK_CORE), - static_cast(METER_SAMPLE_QUEUE_LEN)); - return true; -} -#endif - -static void meter_reader_pump(uint32_t now_ms) { -#ifdef ARDUINO_ARCH_ESP32 - if (g_meter_reader_task_running && g_meter_sample_queue) { - MeterSampleEvent event = {}; - while (xQueueReceive(g_meter_sample_queue, &event, 0) == pdTRUE) { - set_last_meter_sample(event.data, event.rx_ms); - } - return; - } -#endif - - const char *frame = nullptr; - size_t frame_len = 0; - if (!meter_poll_frame(frame, frame_len)) { - return; - } - MeterData parsed = {}; - if (parse_meter_frame_sample(frame, frame_len, parsed)) { - set_last_meter_sample(parsed, now_ms); - } -} - -static uint16_t g_last_batch_id_rx[NUM_SENDERS] = {}; - -struct BatchRxState { - 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; - uint8_t buffer[BATCH_MAX_COMPRESSED]; -}; - -static BatchRxState g_batch_rx = {}; - -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 update_battery_cache() { - MeterData tmp = {}; - read_battery(tmp); - g_last_battery_voltage_v = tmp.battery_voltage_v; - g_last_battery_percent = tmp.battery_percent; - g_last_battery_ms = millis(); -} - -static bool battery_sample_due(uint32_t now_ms) { - return g_last_battery_ms == 0 || now_ms - g_last_battery_ms >= BATTERY_SAMPLE_INTERVAL_MS; -} - -static bool batch_queue_drop_oldest() { - if (g_batch_count == 0) { - return false; - } - bool dropped_inflight = g_inflight_active && g_batch_queue[g_batch_tail].batch_id_valid && - g_inflight_batch_id == g_batch_queue[g_batch_tail].batch_id; - if (dropped_inflight) { - g_batch_ack_pending = false; - g_batch_retry_count = 0; - g_inflight_active = false; - g_inflight_count = 0; - g_inflight_batch_id = 0; - g_inflight_sync_request = false; - } - g_batch_tail = (g_batch_tail + 1) % BATCH_QUEUE_DEPTH; - g_batch_count--; - return dropped_inflight; -} - -static void sender_note_rx_reject(RxRejectReason reason, const char *context) { - if (reason == RxRejectReason::None) { - return; - } - g_sender_rx_reject_reason = reason; - uint32_t now_ms = millis(); - if (SERIAL_DEBUG_MODE && now_ms - g_sender_rx_reject_log_ms >= 1000) { - g_sender_rx_reject_log_ms = now_ms; - serial_debug_printf("rx_reject: %s reason=%s", context, rx_reject_reason_text(reason)); - } -} - -static BatchBuffer *batch_queue_peek() { - if (g_batch_count == 0) { - return nullptr; - } - return &g_batch_queue[g_batch_tail]; -} - -static void batch_queue_enqueue(const MeterData *samples, uint8_t count) { - if (!samples || count == 0) { - return; - } - if (g_batch_count >= BATCH_QUEUE_DEPTH) { - if (batch_queue_drop_oldest()) { - g_batch_id++; - } - } - BatchBuffer &slot = g_batch_queue[g_batch_head]; - slot.batch_id = 0; - slot.batch_id_valid = false; - slot.count = count; - slot.attempt_count = static_cast(g_build_attempts); - slot.valid_count = static_cast(g_build_valid); - slot.invalid_count = static_cast(g_build_invalid); - slot.last_error = g_sender_last_error; - for (uint8_t i = 0; i < count; ++i) { - slot.samples[i] = samples[i]; - } - g_batch_head = (g_batch_head + 1) % BATCH_QUEUE_DEPTH; - g_batch_count++; -} - -static void reset_build_counters() { - g_build_attempts = 0; - g_build_valid = 0; - g_build_invalid = 0; -} - -static bool append_meter_sample(const MeterData &data, bool meter_ok, bool has_snapshot) { - if (!has_snapshot) { - g_build_invalid++; - return false; - } - g_last_sample_ts_utc = data.ts_utc; - g_build_samples[g_build_count++] = data; - if (meter_ok) { - g_build_valid++; - } else { - g_build_invalid++; - } - if (g_build_count >= METER_BATCH_MAX_SAMPLES) { - batch_queue_enqueue(g_build_samples, g_build_count); - g_build_count = 0; - reset_build_counters(); - } - return true; -} - -static uint32_t last_sample_ts() { - if (g_last_sample_ts_utc == 0) { - uint32_t now_utc = time_get_utc(); - return now_utc > 0 ? now_utc : millis() / 1000; - } - return g_last_sample_ts_utc; -} - -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 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; - } -} - #ifdef ARDUINO_ARCH_ESP32 static void watchdog_init() { esp_task_wdt_deinit(); @@ -549,530 +63,11 @@ static void watchdog_init() {} static void watchdog_kick() {} #endif -static void write_u16_le(uint8_t *dst, uint16_t value) { - dst[0] = static_cast(value & 0xFF); - dst[1] = static_cast((value >> 8) & 0xFF); -} - -static uint16_t read_u16_le(const uint8_t *src) { - return static_cast(src[0]) | (static_cast(src[1]) << 8); -} - -static void write_u16_be(uint8_t *dst, uint16_t value) { - dst[0] = static_cast((value >> 8) & 0xFF); - dst[1] = static_cast(value & 0xFF); -} - -static uint16_t read_u16_be(const uint8_t *src) { - return static_cast(src[0] << 8) | static_cast(src[1]); -} - -static void write_u32_be(uint8_t *dst, uint32_t value) { - dst[0] = static_cast((value >> 24) & 0xFF); - dst[1] = static_cast((value >> 16) & 0xFF); - dst[2] = static_cast((value >> 8) & 0xFF); - dst[3] = static_cast(value & 0xFF); -} - -static uint32_t read_u32_be(const uint8_t *src) { - return (static_cast(src[0]) << 24) | - (static_cast(src[1]) << 16) | - (static_cast(src[2]) << 8) | - static_cast(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(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 kwh_to_wh_from_float(float value) { - if (isnan(value)) { - return 0; - } - double wh = static_cast(value) * 1000.0; - if (wh < 0.0) { - wh = 0.0; - } - if (wh > static_cast(UINT32_MAX)) { - wh = static_cast(UINT32_MAX); - } - return static_cast(llround(wh)); -} - -static bool float_to_i16_w(float value, int16_t &out) { - if (isnan(value)) { - out = 0; - return true; - } - long rounded = lroundf(value); - if (rounded < INT16_MIN || rounded > INT16_MAX) { - return false; - } - out = static_cast(rounded); - return true; -} - -static int16_t float_to_i16_w_clamped(float value, bool &clamped) { - clamped = false; - if (isnan(value)) { - return 0; - } - long rounded = lroundf(value); - if (rounded < INT16_MIN) { - clamped = true; - return INT16_MIN; - } - if (rounded > INT16_MAX) { - clamped = true; - return INT16_MAX; - } - return static_cast(rounded); -} - -static uint16_t battery_mv_from_voltage(float value) { - if (isnan(value) || value <= 0.0f) { - return 0; - } - long mv = lroundf(value * 1000.0f); - if (mv < 0) { - mv = 0; - } - if (mv > UINT16_MAX) { - mv = UINT16_MAX; - } - return static_cast(mv); -} - -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(chunk_count) * per_chunk_toa_ms + BATCH_RX_MARGIN_MS; - return timeout_ms < 10000 ? 10000 : timeout_ms; -} - -static uint32_t compute_batch_ack_timeout_ms(size_t payload_len) { - if (payload_len == 0) { - return 10000; - } - uint8_t chunk_count = static_cast((payload_len + BATCH_CHUNK_PAYLOAD - 1) / BATCH_CHUNK_PAYLOAD); - size_t packet_len = 3 + BATCH_HEADER_SIZE + (payload_len > BATCH_CHUNK_PAYLOAD ? BATCH_CHUNK_PAYLOAD : payload_len) + 2; - uint32_t per_chunk_toa_ms = lora_airtime_ms(packet_len); - uint32_t timeout_ms = static_cast(chunk_count) * per_chunk_toa_ms + BATCH_RX_MARGIN_MS; - return timeout_ms < 10000 ? 10000 : timeout_ms; -} - -static bool send_batch_payload(const uint8_t *data, size_t len, uint32_t ts_for_display, uint16_t batch_id) { - if (!data || len == 0 || len > BATCH_MAX_COMPRESSED) { - return false; - } - uint8_t chunk_count = static_cast((len + BATCH_CHUNK_PAYLOAD - 1) / BATCH_CHUNK_PAYLOAD); - if (chunk_count == 0) { - return false; - } - - bool all_ok = true; - size_t offset = 0; - for (uint8_t i = 0; i < chunk_count; ++i) { - size_t chunk_len = len - offset; - if (chunk_len > BATCH_CHUNK_PAYLOAD) { - chunk_len = BATCH_CHUNK_PAYLOAD; - } - LoraPacket pkt = {}; - pkt.msg_kind = LoraMsgKind::BatchUp; - pkt.device_id_short = g_short_id; - pkt.payload_len = chunk_len + BATCH_HEADER_SIZE; - - uint8_t *payload = pkt.payload; - write_u16_le(&payload[0], batch_id); - payload[2] = i; - payload[3] = chunk_count; - write_u16_le(&payload[4], static_cast(len)); - memcpy(&payload[BATCH_HEADER_SIZE], data + offset, chunk_len); - - watchdog_kick(); - uint32_t tx_start = millis(); - bool ok = lora_send(pkt); - uint32_t tx_ms = millis() - tx_start; - all_ok = all_ok && ok; - if (!ok) { - note_fault(g_sender_faults, g_sender_last_error, g_sender_last_error_utc, g_sender_last_error_ms, FaultType::LoraTx); - display_set_last_error(g_sender_last_error, g_sender_last_error_utc, g_sender_last_error_ms); - } - if (SERIAL_DEBUG_MODE && tx_ms > 500) { - serial_debug_printf("tx: chunk %u/%u took %lums ok=%u", static_cast(i + 1), - static_cast(chunk_count), static_cast(tx_ms), ok ? 1 : 0); - } - offset += chunk_len; - delay(10); - } - - display_set_last_tx(all_ok, ts_for_display); - return all_ok; -} - -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(time_valid), - static_cast(epoch), - static_cast(sample_count)); - lora_receive_continuous(); -} - -static bool prepare_inflight_from_queue() { - if (g_inflight_active) { - return true; - } - BatchBuffer *batch = batch_queue_peek(); - if (!batch || batch->count == 0) { - return false; - } - if (!batch->batch_id_valid) { - batch->batch_id = g_batch_id; - batch->batch_id_valid = true; - } - if (SERIAL_DEBUG_MODE) { - serial_debug_printf("batch: id=%u desired=%u attempts=%u valid=%u invalid=%u err_last=%u", - batch->batch_id, - static_cast(METER_BATCH_MAX_SAMPLES), - static_cast(batch->attempt_count), - static_cast(batch->valid_count), - static_cast(batch->invalid_count), - static_cast(batch->last_error)); - } - g_inflight_count = batch->count; - g_inflight_batch_id = batch->batch_id; - for (uint8_t i = 0; i < g_inflight_count; ++i) { - g_inflight_samples[i] = batch->samples[i]; - } - g_inflight_active = true; - return true; -} - -static bool send_inflight_batch(uint32_t ts_for_display) { - g_last_tx_build_error = TxBuildError::None; - if (!g_inflight_active) { - return false; - } - BatchInput input = {}; - input.sender_id = sender_id_from_short_id(g_short_id); - input.batch_id = g_inflight_batch_id; - input.t_last = g_inflight_sync_request ? time_get_utc() : - g_inflight_samples[g_inflight_count - 1].ts_utc; - input.present_mask = 0; - input.n = 0; - input.battery_mV = g_inflight_sync_request ? battery_mv_from_voltage(g_last_battery_voltage_v) : - battery_mv_from_voltage(g_inflight_samples[g_inflight_count - 1].battery_voltage_v); - input.err_m = g_sender_faults.meter_read_fail > 255 ? 255 : static_cast(g_sender_faults.meter_read_fail); - input.err_d = g_sender_faults.decode_fail > 255 ? 255 : static_cast(g_sender_faults.decode_fail); - input.err_tx = g_sender_faults.lora_tx_fail > 255 ? 255 : static_cast(g_sender_faults.lora_tx_fail); - input.err_last = static_cast(g_sender_last_error); - input.err_rx_reject = static_cast(g_sender_rx_reject_reason); - uint8_t energy_regressions = 0; - uint8_t phase_clamps = 0; - uint8_t ts_dropped = 0; - uint8_t ts_collapsed = 0; - - if (!g_inflight_sync_request) { - if (input.t_last < static_cast(METER_BATCH_MAX_SAMPLES - 1)) { - g_last_tx_build_error = TxBuildError::Encode; - return false; - } - const uint32_t window_start = input.t_last - static_cast(METER_BATCH_MAX_SAMPLES - 1); - MeterData slot_samples[METER_BATCH_MAX_SAMPLES]; - bool slot_used[METER_BATCH_MAX_SAMPLES] = {}; - for (uint8_t i = 0; i < g_inflight_count; ++i) { - const MeterData &sample = g_inflight_samples[i]; - if (sample.ts_utc < window_start || sample.ts_utc > input.t_last) { - if (ts_dropped < 255) { - ts_dropped++; - } - continue; - } - uint8_t slot = static_cast(sample.ts_utc - window_start); - if (slot_used[slot] && ts_collapsed < 255) { - ts_collapsed++; - } - slot_used[slot] = true; - slot_samples[slot] = sample; - } - for (uint8_t slot = 0; slot < METER_BATCH_MAX_SAMPLES; ++slot) { - if (!slot_used[slot]) { - continue; - } - const uint8_t out_idx = input.n; - if (out_idx >= METER_BATCH_MAX_SAMPLES) { - g_last_tx_build_error = TxBuildError::Encode; - return false; - } - input.present_mask |= (1UL << slot); - input.n++; - input.energy_wh[out_idx] = kwh_to_wh_from_float(slot_samples[slot].energy_total_kwh); - bool c1 = false; - bool c2 = false; - bool c3 = false; - input.p1_w[out_idx] = float_to_i16_w_clamped(slot_samples[slot].phase_power_w[0], c1); - input.p2_w[out_idx] = float_to_i16_w_clamped(slot_samples[slot].phase_power_w[1], c2); - input.p3_w[out_idx] = float_to_i16_w_clamped(slot_samples[slot].phase_power_w[2], c3); - if (c1 && phase_clamps < 255) { - phase_clamps++; - } - if (c2 && phase_clamps < 255) { - phase_clamps++; - } - if (c3 && phase_clamps < 255) { - phase_clamps++; - } - } - } - - for (uint8_t i = 0; i < input.n; ++i) { - if (i > 0 && input.energy_wh[i] < input.energy_wh[i - 1]) { - input.energy_wh[i] = input.energy_wh[i - 1]; - if (energy_regressions < 255) { - energy_regressions++; - } - } - } - if (SERIAL_DEBUG_MODE && (energy_regressions > 0 || phase_clamps > 0 || ts_dropped > 0 || ts_collapsed > 0)) { - serial_debug_printf("tx: sanitize batch_id=%u energy_regress=%u phase_clamps=%u ts_drop=%u ts_dup=%u", - g_inflight_batch_id, - static_cast(energy_regressions), - static_cast(phase_clamps), - static_cast(ts_dropped), - static_cast(ts_collapsed)); - } - - static uint8_t encoded[BATCH_MAX_COMPRESSED]; - size_t encoded_len = 0; - uint32_t encode_start = millis(); - if (!encode_batch(input, encoded, sizeof(encoded), &encoded_len)) { - g_last_tx_build_error = TxBuildError::Encode; - return false; - } - uint32_t encode_ms = millis() - encode_start; - if (SERIAL_DEBUG_MODE) { - serial_debug_printf("tx: batch_id=%u count=%u mask=%08lX bin_len=%u", - g_inflight_batch_id, - static_cast(input.n), - static_cast(input.present_mask), - static_cast(encoded_len)); - if (encode_ms > 200) { - serial_debug_printf("tx: encode took %lums", static_cast(encode_ms)); - } - } - g_batch_ack_timeout_ms = compute_batch_ack_timeout_ms(encoded_len); - - uint32_t send_start = millis(); - bool ok = send_batch_payload(encoded, encoded_len, ts_for_display, g_inflight_batch_id); - uint32_t send_ms = millis() - send_start; - if (SERIAL_DEBUG_MODE && send_ms > 1000) { - serial_debug_printf("tx: send batch took %lums", static_cast(send_ms)); - } - if (ok) { - g_last_batch_send_ms = millis(); - if (g_inflight_sync_request) { - serial_debug_printf("sync: request tx batch_id=%u", g_inflight_batch_id); - } else { - serial_debug_printf("tx: sent batch_id=%u len=%u", g_inflight_batch_id, static_cast(encoded_len)); - } - } else { - if (g_inflight_sync_request) { - serial_debug_printf("sync: request tx failed batch_id=%u", g_inflight_batch_id); - } else { - serial_debug_printf("tx: send failed batch_id=%u", g_inflight_batch_id); - } - } - return ok; -} - -static bool send_meter_batch(uint32_t ts_for_display) { - if (!prepare_inflight_from_queue()) { - return false; - } - g_inflight_sync_request = false; - bool ok = send_inflight_batch(ts_for_display); - if (ok) { - g_last_sent_batch_id = g_inflight_batch_id; - g_batch_ack_pending = true; - } else { - if (g_last_tx_build_error == TxBuildError::Encode) { - serial_debug_printf("tx: encode failed batch_id=%u dropped", g_inflight_batch_id); - note_fault(g_sender_faults, g_sender_last_error, g_sender_last_error_utc, g_sender_last_error_ms, FaultType::Decode); - display_set_last_error(g_sender_last_error, g_sender_last_error_utc, g_sender_last_error_ms); - finish_inflight_batch(); - return false; - } - g_inflight_active = false; - g_inflight_count = 0; - g_inflight_batch_id = 0; - g_inflight_sync_request = false; - } - return ok; -} - -static bool send_sync_request() { - if (g_batch_ack_pending) { - return false; - } - if (battery_sample_due(millis())) { - update_battery_cache(); - } - g_inflight_active = true; - g_inflight_sync_request = true; - g_inflight_count = 0; - g_inflight_batch_id = g_batch_id; - if (SERIAL_DEBUG_MODE && g_build_attempts > 0) { - serial_debug_printf("batch: id=%u desired=%u attempts=%u valid=%u invalid=%u err_last=%u sync=1", - g_inflight_batch_id, - static_cast(METER_BATCH_MAX_SAMPLES), - static_cast(g_build_attempts), - static_cast(g_build_valid), - static_cast(g_build_invalid), - static_cast(g_sender_last_error)); - } - bool ok = send_inflight_batch(time_get_utc()); - if (ok) { - g_last_sent_batch_id = g_inflight_batch_id; - g_batch_ack_pending = true; - } else { - g_inflight_active = false; - g_inflight_sync_request = false; - g_inflight_batch_id = 0; - } - return ok; -} - -static bool resend_inflight_batch(uint32_t ts_for_display) { - if (!g_batch_ack_pending || !g_inflight_active || (!g_inflight_sync_request && g_inflight_count == 0)) { - return false; - } - return send_inflight_batch(ts_for_display); -} - -static void finish_inflight_batch() { - if (g_batch_count > 0) { - batch_queue_drop_oldest(); - } - g_batch_ack_pending = false; - g_batch_retry_count = 0; - g_inflight_active = false; - g_inflight_count = 0; - g_inflight_batch_id = 0; - g_inflight_sync_request = false; - g_batch_id++; -} - -static void reset_batch_rx() { - g_batch_rx.active = false; - g_batch_rx.batch_id = 0; - g_batch_rx.next_index = 0; - g_batch_rx.expected_chunks = 0; - g_batch_rx.total_len = 0; - g_batch_rx.received_len = 0; - g_batch_rx.last_rx_ms = 0; - g_batch_rx.timeout_ms = 0; -} - -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(); - - if (!g_batch_rx.active || batch_id != g_batch_rx.batch_id || (now_ms - g_batch_rx.last_rx_ms > g_batch_rx.timeout_ms)) { - if (chunk_index != 0) { - reset_batch_rx(); - return false; - } - if (total_len == 0 || total_len > BATCH_MAX_COMPRESSED || chunk_count == 0) { - reset_batch_rx(); - return false; - } - g_batch_rx.active = true; - g_batch_rx.batch_id = batch_id; - g_batch_rx.expected_chunks = chunk_count; - g_batch_rx.total_len = total_len; - g_batch_rx.received_len = 0; - g_batch_rx.next_index = 0; - g_batch_rx.timeout_ms = compute_batch_rx_timeout_ms(total_len, chunk_count); - } - - if (!g_batch_rx.active || chunk_index != g_batch_rx.next_index || chunk_count != g_batch_rx.expected_chunks) { - reset_batch_rx(); - return false; - } - - if (g_batch_rx.received_len + chunk_len > g_batch_rx.total_len || g_batch_rx.received_len + chunk_len > BATCH_MAX_COMPRESSED) { - reset_batch_rx(); - return false; - } - - memcpy(&g_batch_rx.buffer[g_batch_rx.received_len], chunk_data, chunk_len); - g_batch_rx.received_len += static_cast(chunk_len); - g_batch_rx.next_index++; - g_batch_rx.last_rx_ms = now_ms; - - if (g_batch_rx.next_index == g_batch_rx.expected_chunks && g_batch_rx.received_len == g_batch_rx.total_len) { - if (!decode_batch(g_batch_rx.buffer, g_batch_rx.received_len, &out_batch)) { - decode_error = true; - reset_batch_rx(); - return false; - } - out_batch_id = batch_id; - reset_batch_rx(); - return true; - } - - return false; -} - void setup() { Serial.begin(115200); delay(200); + dd3_legacy_core_force_link(); + #ifdef PAYLOAD_CODEC_TEST payload_codec_self_test(); #endif @@ -1080,6 +75,7 @@ void setup() { watchdog_init(); g_role = detect_role(); init_device_ids(g_short_id, g_device_id, sizeof(g_device_id)); + display_set_role(g_role); if (SERIAL_DEBUG_MODE) { #ifdef ARDUINO_ARCH_ESP32 @@ -1094,519 +90,56 @@ void setup() { display_set_self_ids(g_short_id, g_device_id); if (g_role == DeviceRole::Sender) { - power_sender_init(); - power_configure_unused_pins_sender(); - meter_init(); -#ifdef ARDUINO_ARCH_ESP32 - if (!meter_reader_start()) { - serial_debug_printf("meter: using inline polling fallback"); - } -#endif - g_last_sample_ms = millis() - METER_SAMPLE_INTERVAL_MS; - g_last_send_ms = millis(); - g_last_sync_request_ms = millis() - SYNC_REQUEST_INTERVAL_MS; - g_time_acquired = false; - update_battery_cache(); + SenderStateMachineConfig sender_cfg = {}; + sender_cfg.short_id = g_short_id; + sender_cfg.device_id = g_device_id; + g_sender_state_machine.begin(sender_cfg); + return; + } + + power_receiver_init(); + lora_receive_continuous(); + pinMode(PIN_ROLE, INPUT); // release pulldown before SD uses GPIO14 as SCK + sd_logger_init(); + wifi_manager_init(); + + ReceiverPipelineConfig receiver_cfg = {}; + receiver_cfg.short_id = g_short_id; + receiver_cfg.device_id = g_device_id; + receiver_cfg.shared = &g_receiver_shared; + g_receiver_pipeline.begin(receiver_cfg); + + display_set_sender_statuses(g_receiver_shared.sender_statuses, NUM_SENDERS); + + bool has_cfg = wifi_load_config(g_cfg); + if (has_cfg && wifi_connect_sta(g_cfg)) { + g_receiver_shared.ap_mode = false; + time_receiver_init(g_cfg.ntp_server_1.c_str(), g_cfg.ntp_server_2.c_str()); + mqtt_init(g_cfg, g_device_id); + web_server_set_config(g_cfg); + web_server_set_sender_faults(g_receiver_shared.sender_faults_remote, g_receiver_shared.sender_last_error_remote); + web_server_begin_sta(g_receiver_shared.sender_statuses, NUM_SENDERS); } else { - power_receiver_init(); - lora_receive_continuous(); - pinMode(PIN_ROLE, INPUT); // release pulldown before SD uses GPIO14 as SCK - sd_logger_init(); - wifi_manager_init(); - init_sender_statuses(); - display_set_sender_statuses(g_sender_statuses, NUM_SENDERS); - - bool has_cfg = wifi_load_config(g_cfg); - if (has_cfg && wifi_connect_sta(g_cfg)) { - g_ap_mode = false; - time_receiver_init(g_cfg.ntp_server_1.c_str(), g_cfg.ntp_server_2.c_str()); - mqtt_init(g_cfg, g_device_id); - web_server_set_config(g_cfg); - web_server_set_sender_faults(g_sender_faults_remote, g_sender_last_error_remote); - web_server_begin_sta(g_sender_statuses, NUM_SENDERS); - } else { - g_ap_mode = true; - g_last_wifi_reconnect_attempt_ms = millis(); - char ap_ssid[32]; - snprintf(ap_ssid, sizeof(ap_ssid), "%s%04X", AP_SSID_PREFIX, g_short_id); - wifi_start_ap(ap_ssid, AP_PASSWORD); - if (g_cfg.ntp_server_1.isEmpty()) { - g_cfg.ntp_server_1 = "pool.ntp.org"; - } - if (g_cfg.ntp_server_2.isEmpty()) { - g_cfg.ntp_server_2 = "time.nist.gov"; - } - web_server_set_config(g_cfg); - web_server_set_sender_faults(g_sender_faults_remote, g_sender_last_error_remote); - web_server_begin_ap(g_sender_statuses, NUM_SENDERS); + g_receiver_shared.ap_mode = true; + char ap_ssid[32]; + snprintf(ap_ssid, sizeof(ap_ssid), "%s%04X", AP_SSID_PREFIX, g_short_id); + wifi_start_ap(ap_ssid, AP_PASSWORD); + if (g_cfg.ntp_server_1.isEmpty()) { + g_cfg.ntp_server_1 = "pool.ntp.org"; } + if (g_cfg.ntp_server_2.isEmpty()) { + g_cfg.ntp_server_2 = "time.nist.gov"; + } + web_server_set_config(g_cfg); + web_server_set_sender_faults(g_receiver_shared.sender_faults_remote, g_receiver_shared.sender_last_error_remote); + web_server_begin_ap(g_receiver_shared.sender_statuses, NUM_SENDERS); } } -static void sender_loop() { - watchdog_kick(); - uint32_t now_ms = millis(); - display_set_sender_queue(g_batch_count, g_build_count > 0); - display_set_sender_batches(g_last_acked_batch_id, g_batch_id); - if (SERIAL_DEBUG_MODE && now_ms - g_last_debug_log_ms >= 5000) { - g_last_debug_log_ms = now_ms; - serial_debug_printf("state: Q=%u%s A=%u C=%u inflight=%u ack_pending=%u retries=%u", - g_batch_count, - g_build_count > 0 ? "+" : "", - g_last_acked_batch_id, - g_batch_id, - g_inflight_count, - g_batch_ack_pending ? 1 : 0, - g_batch_retry_count); - } - - meter_reader_pump(now_ms); - - if (g_time_acquired) { - while (now_ms - g_last_sample_ms >= METER_SAMPLE_INTERVAL_MS) { - g_last_sample_ms += METER_SAMPLE_INTERVAL_MS; - MeterData data = {}; - data.short_id = g_short_id; - strncpy(data.device_id, g_device_id, sizeof(data.device_id)); - data.energy_total_kwh = NAN; - data.total_power_w = NAN; - data.phase_power_w[0] = NAN; - data.phase_power_w[1] = NAN; - data.phase_power_w[2] = NAN; - - g_build_attempts++; - uint32_t meter_age_ms = g_last_meter_valid ? (now_ms - g_last_meter_rx_ms) : UINT32_MAX; - // Reuse recent good samples to bridge short parser gaps without accepting stale data forever. - bool has_snapshot = g_last_meter_valid; - bool meter_ok = has_snapshot && meter_age_ms <= METER_SAMPLE_MAX_AGE_MS; - if (has_snapshot) { - data.meter_seconds = g_last_meter_data.meter_seconds; - data.meter_seconds_valid = g_last_meter_data.meter_seconds_valid; - data.energy_total_kwh = g_last_meter_data.energy_total_kwh; - data.total_power_w = g_last_meter_data.total_power_w; - data.phase_power_w[0] = g_last_meter_data.phase_power_w[0]; - data.phase_power_w[1] = g_last_meter_data.phase_power_w[1]; - data.phase_power_w[2] = g_last_meter_data.phase_power_w[2]; - g_meter_stale_seconds = meter_age_ms >= 1000 ? (meter_age_ms / 1000) : 0; - } else { - g_meter_stale_seconds = g_last_meter_valid ? (meter_age_ms / 1000) : (g_meter_stale_seconds + 1); - } - if (!meter_ok) { - note_fault(g_sender_faults, g_sender_last_error, g_sender_last_error_utc, g_sender_last_error_ms, FaultType::MeterRead); - display_set_last_error(g_sender_last_error, g_sender_last_error_utc, g_sender_last_error_ms); - } - if (g_meter_time_jump_pending) { - g_meter_time_jump_pending = false; - note_fault(g_sender_faults, g_sender_last_error, g_sender_last_error_utc, g_sender_last_error_ms, FaultType::MeterRead); - display_set_last_error(g_sender_last_error, g_sender_last_error_utc, g_sender_last_error_ms); - } - if (g_build_count == 0 && battery_sample_due(now_ms)) { - update_battery_cache(); - } - data.battery_voltage_v = g_last_battery_voltage_v; - data.battery_percent = g_last_battery_percent; - data.rx_reject_reason = static_cast(g_sender_rx_reject_reason); - uint32_t sample_ts_utc = 0; - if (has_snapshot && g_last_meter_data.meter_seconds_valid && g_last_meter_data.ts_utc >= MIN_ACCEPTED_EPOCH_UTC) { - sample_ts_utc = g_last_meter_data.ts_utc; - } else { - sample_ts_utc = time_get_utc(); - if (sample_ts_utc > 0 && now_ms > g_last_sample_ms) { - uint32_t lag_s = (now_ms - g_last_sample_ms) / 1000; - if (sample_ts_utc > lag_s) { - sample_ts_utc -= lag_s; - } - } - } - data.ts_utc = sample_ts_utc; - data.valid = has_snapshot; - - bool appended = append_meter_sample(data, meter_ok, has_snapshot); - if (SERIAL_DEBUG_MODE) { - serial_debug_printf("sample: i=%lu ok=%u appended=%u e_kwh=%.3f p1=%.1f p2=%.1f p3=%.1f ms=%lu", - static_cast(g_build_attempts), - meter_ok ? 1U : 0U, - appended ? 1U : 0U, - static_cast(data.energy_total_kwh), - static_cast(data.phase_power_w[0]), - static_cast(data.phase_power_w[1]), - static_cast(data.phase_power_w[2]), - static_cast(now_ms)); - } - display_set_last_meter(data); - display_set_last_read(meter_ok, data.ts_utc); - } - - if (!g_batch_ack_pending && now_ms - g_last_send_ms >= METER_SEND_INTERVAL_MS) { - g_last_send_ms = now_ms; - if (g_build_count > 0) { - batch_queue_enqueue(g_build_samples, g_build_count); - g_build_count = 0; - reset_build_counters(); - } - if (g_batch_count > 0) { - send_meter_batch(last_sample_ts()); - } else if (g_build_attempts > 0) { - if (send_sync_request()) { - reset_build_counters(); - } - } - } - } else { - if (!g_batch_ack_pending && now_ms - g_last_sync_request_ms >= SYNC_REQUEST_INTERVAL_MS) { - g_last_sync_request_ms = now_ms; - send_sync_request(); - } - } - - if (g_batch_ack_pending) { - LoraPacket ack_pkt = {}; - constexpr size_t ack_len = lora_frame_size(LORA_ACK_DOWN_PAYLOAD_LEN); - uint32_t ack_air_ms = lora_airtime_ms(ack_len); - uint32_t ack_window_ms = ack_air_ms + 300; - if (ack_window_ms < 1200) { - ack_window_ms = 1200; - } - if (ack_window_ms > 4000) { - ack_window_ms = 4000; - } - if (SERIAL_DEBUG_MODE) { - serial_debug_printf("ack: rx window=%lu airtime=%lu", static_cast(ack_window_ms), - static_cast(ack_air_ms)); - } - uint32_t rx_start = millis(); - bool got_ack = lora_receive_window(ack_pkt, ack_window_ms); - if (!got_ack) { - got_ack = lora_receive_window(ack_pkt, ack_window_ms / 2); - } - uint32_t rx_elapsed = millis() - rx_start; - if (SERIAL_DEBUG_MODE) { - g_sender_rx_window_ms += rx_elapsed; - } - if (!got_ack) { - RxRejectReason reason = lora_get_last_rx_reject_reason(); - sender_note_rx_reject(reason, "ack"); - if (SERIAL_DEBUG_MODE) { - int16_t rssi_dbm = 0; - float snr_db = 0.0f; - bool has_signal = lora_get_last_rx_signal(rssi_dbm, snr_db); - const char *reason_text = reason == RxRejectReason::None ? "timeout" : rx_reject_reason_text(reason); - if (has_signal) { - serial_debug_printf("ack: rx miss reason=%s rssi=%d snr=%.1f", - reason_text, - static_cast(rssi_dbm), - static_cast(snr_db)); - } else { - serial_debug_printf("ack: rx miss reason=%s", reason_text); - } - } - } else if (ack_pkt.msg_kind != LoraMsgKind::AckDown) { - sender_note_rx_reject(RxRejectReason::InvalidMsgKind, "ack"); - if (SERIAL_DEBUG_MODE) { - uint16_t ack_id = ack_pkt.payload_len >= 3 ? read_u16_be(&ack_pkt.payload[1]) : 0; - serial_debug_printf("ack: reject msg_kind=%u payload_len=%u ack_id=%u", - static_cast(ack_pkt.msg_kind), - static_cast(ack_pkt.payload_len), - ack_id); - } - } else if (ack_pkt.payload_len < LORA_ACK_DOWN_PAYLOAD_LEN) { - sender_note_rx_reject(RxRejectReason::LengthMismatch, "ack"); - if (SERIAL_DEBUG_MODE) { - uint16_t ack_id = ack_pkt.payload_len >= 3 ? read_u16_be(&ack_pkt.payload[1]) : 0; - serial_debug_printf("ack: reject msg_kind=%u payload_len=%u ack_id=%u", - static_cast(ack_pkt.msg_kind), - static_cast(ack_pkt.payload_len), - ack_id); - } - } else { - uint8_t time_valid = ack_pkt.payload[0] & 0x01; - uint16_t ack_id = read_u16_be(&ack_pkt.payload[1]); - uint32_t ack_epoch = read_u32_be(&ack_pkt.payload[3]); - bool set_time = false; - if (g_batch_ack_pending && ack_id == g_last_sent_batch_id) { - if (time_valid == 1 && ack_epoch >= MIN_ACCEPTED_EPOCH_UTC) { - time_set_utc(ack_epoch); - g_time_acquired = true; - set_time = true; - } - g_last_acked_batch_id = ack_id; - serial_debug_printf("ack: rx ok batch_id=%u time_valid=%u epoch=%lu set=%u", - ack_id, - static_cast(time_valid), - static_cast(ack_epoch), - set_time ? 1 : 0); - finish_inflight_batch(); - } else { - if (ack_id != g_last_sent_batch_id) { - sender_note_rx_reject(RxRejectReason::BatchIdMismatch, "ack"); - if (SERIAL_DEBUG_MODE) { - serial_debug_printf("ack: reject msg_kind=%u payload_len=%u ack_id=%u", - static_cast(ack_pkt.msg_kind), - static_cast(ack_pkt.payload_len), - ack_id); - } - } - } - } - } - if (!g_batch_ack_pending) { - lora_sleep(); - } - - if (g_batch_ack_pending && (now_ms - g_last_batch_send_ms >= g_batch_ack_timeout_ms)) { - if (g_batch_retry_count < BATCH_MAX_RETRIES) { - g_batch_retry_count++; - serial_debug_printf("ack: timeout batch_id=%u retry=%u", g_inflight_batch_id, g_batch_retry_count); - resend_inflight_batch(last_sample_ts()); - } else { - serial_debug_printf("ack: failed batch_id=%u policy=%s", g_inflight_batch_id, - BATCH_RETRY_POLICY == BatchRetryPolicy::Drop ? "drop" : "keep"); - if (BATCH_RETRY_POLICY == BatchRetryPolicy::Drop) { - finish_inflight_batch(); - } else { - g_batch_ack_pending = false; - g_batch_retry_count = 0; - g_inflight_active = false; - g_inflight_count = 0; - g_inflight_batch_id = 0; - g_inflight_sync_request = false; - } - note_fault(g_sender_faults, g_sender_last_error, g_sender_last_error_utc, g_sender_last_error_ms, FaultType::LoraTx); - display_set_last_error(g_sender_last_error, g_sender_last_error_utc, g_sender_last_error_ms); - } - } - - display_tick(); - - uint32_t next_due = g_time_acquired ? (g_last_sample_ms + METER_SAMPLE_INTERVAL_MS) : - (g_last_sync_request_ms + SYNC_REQUEST_INTERVAL_MS); - if (g_time_acquired) { - uint32_t next_send_due = g_last_send_ms + METER_SEND_INTERVAL_MS; - if (next_send_due < next_due) { - next_due = next_send_due; - } - } - if (!g_batch_ack_pending && next_due > now_ms) { - watchdog_kick(); - uint32_t idle_ms = next_due - now_ms; - if (SERIAL_DEBUG_MODE) { - g_sender_sleep_ms += idle_ms; - if (now_ms - g_sender_power_log_ms >= 10000) { - g_sender_power_log_ms = now_ms; - serial_debug_printf("power: rx_ms=%lu sleep_ms=%lu", static_cast(g_sender_rx_window_ms), - static_cast(g_sender_sleep_ms)); - } - } - lora_sleep(); - if (g_time_acquired) { - // Keep the meter reader task running while metering is active. - delay(idle_ms); - } else { - light_sleep_ms(idle_ms); - } - } -} - -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(i); - break; - } - } - - bool duplicate = sender_idx >= 0 && g_last_batch_id_rx[sender_idx] == batch_id; - if (sender_idx >= 0) { - 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; - } - if (sender_idx >= 0) { - 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(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(METER_BATCH_MAX_SAMPLES - 1); - - MeterData samples[METER_BATCH_MAX_SAMPLES]; - float bat_v = batch.battery_mV > 0 ? static_cast(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(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(batch.energy_wh[s]) / 1000.0f; - data.phase_power_w[0] = static_cast(batch.p1_w[s]); - data.phase_power_w[1] = static_cast(batch.p2_w[s]); - data.phase_power_w[2] = static_cast(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(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; - } - - if (sender_idx >= 0) { - web_server_set_last_batch(static_cast(sender_idx), samples, count); - for (size_t s = 0; s < count; ++s) { - mqtt_publish_state(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: - // Attempt WiFi reconnection if in AP mode and timer has elapsed - if (g_ap_mode && g_cfg.valid) { - uint32_t now_ms = millis(); - if (now_ms - g_last_wifi_reconnect_attempt_ms >= WIFI_RECONNECT_INTERVAL_MS) { - g_last_wifi_reconnect_attempt_ms = now_ms; - if (wifi_connect_sta(g_cfg)) { - // WiFi reconnected! Switch off AP mode and resume normal operation - g_ap_mode = false; - time_receiver_init(g_cfg.ntp_server_1.c_str(), g_cfg.ntp_server_2.c_str()); - mqtt_init(g_cfg, g_device_id); - web_server_set_config(g_cfg); - web_server_set_sender_faults(g_sender_faults_remote, g_sender_last_error_remote); - web_server_begin_sta(g_sender_statuses, NUM_SENDERS); - if (SERIAL_DEBUG_MODE) { - serial_debug_printf("WiFi reconnected! Exiting AP mode."); - } - } - } - } - - 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(); -} - void loop() { -#ifdef ENABLE_TEST_MODE if (g_role == DeviceRole::Sender) { - test_sender_loop(g_short_id, g_device_id); - display_tick(); - watchdog_kick(); - delay(50); + g_sender_state_machine.loop(); } else { - test_receiver_loop(g_sender_statuses, NUM_SENDERS, g_short_id); - mqtt_loop(); - web_server_loop(); - display_set_receiver_status(g_ap_mode, wifi_is_connected() ? wifi_get_ssid().c_str() : "AP", mqtt_is_connected()); - display_tick(); - watchdog_kick(); - delay(50); - } - return; -#endif - - if (g_role == DeviceRole::Sender) { - sender_loop(); - } else { - receiver_loop(); + g_receiver_pipeline.loop(); } } diff --git a/src/meter_driver.cpp b/src/meter_driver.cpp index bc34210..c445a89 100644 --- a/src/meter_driver.cpp +++ b/src/meter_driver.cpp @@ -24,6 +24,8 @@ static uint32_t g_frames_parse_fail = 0; static uint32_t g_rx_overflow = 0; static uint32_t g_rx_timeout = 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() { #ifdef ARDUINO_ARCH_ESP32 @@ -33,47 +35,138 @@ void meter_init() { Serial2.begin(9600, SERIAL_7E1, PIN_METER_RX, -1); } -static bool parse_obis_ascii_value(const char *line, const char *obis, float &out_value) { - const char *p = strstr(line, obis); - if (!p) { +enum class ObisField : uint8_t { + None = 0, + 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; } - const char *lparen = strchr(p, '('); - if (!lparen) { - return false; - } - const char *cur = lparen + 1; - char num_buf[24]; - size_t n = 0; - while (*cur && *cur != ')' && *cur != '*') { + + const char *cur = start; + bool started = false; + bool negative = false; + bool in_fraction = false; + bool saw_digit = false; + uint64_t int_part = 0; + uint32_t frac_part = 0; + uint32_t frac_div = 1; + + while (cur < end) { char c = *cur++; - if ((c >= '0' && c <= '9') || c == '-' || c == '+' || c == '.' || c == ',') { - if (c == ',') { - c = '.'; + if (!started) { + if (c == '+' || c == '-') { + started = true; + negative = (c == '-'); + continue; } - if (n + 1 < sizeof(num_buf)) { - num_buf[n++] = c; + if (c >= '0' && c <= '9') { + started = true; + saw_digit = true; + int_part = static_cast(c - '0'); + continue; + } + if (c == '.' || c == ',') { + started = true; + in_fraction = true; + continue; } - } else if (n == 0) { continue; - } else { - break; } + + if (c >= '0' && c <= '9') { + saw_digit = true; + uint32_t digit = static_cast(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; } - if (n == 0) { + + if (!saw_digit) { return false; } - num_buf[n] = '\0'; - out_value = static_cast(atof(num_buf)); + double value = static_cast(int_part); + if (frac_div > 1U) { + value += static_cast(frac_part) / static_cast(frac_div); + } + if (negative) { + value = -value; + } + out_value = static_cast(value); return true; } -static bool parse_obis_ascii_unit_scale(const char *line, const char *obis, float &value) { - const char *p = strstr(line, obis); - if (!p) { +static bool parse_obis_ascii_payload_value(const char *line, float &out_value) { + const char *lparen = strchr(line, '('); + if (!lparen) { 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) { return false; } @@ -113,12 +206,8 @@ static int8_t hex_nibble(char c) { return -1; } -static bool parse_obis_hex_u32(const char *line, const char *obis, uint32_t &out_value) { - const char *p = strstr(line, obis); - if (!p) { - return false; - } - const char *lparen = strchr(p, '('); +static bool parse_obis_hex_payload_u32(const char *line, uint32_t &out_value) { + const char *lparen = strchr(line, '('); if (!lparen) { return false; } @@ -163,6 +252,16 @@ static void meter_debug_log() { static_cast(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) { frame = nullptr; 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; if (data.valid) { g_frames_ok++; + g_last_good_frame_ms = millis(); } else { g_frames_parse_fail++; } @@ -255,43 +355,68 @@ 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; if (data.valid) { g_frames_ok++; + g_last_good_frame_ms = millis(); } else { g_frames_parse_fail++; } return data.valid; } + ObisField field = detect_obis_field(line); float value = NAN; - if (parse_obis_ascii_value(line, "1-0:1.8.0", value)) { - parse_obis_ascii_unit_scale(line, "1-0:1.8.0", value); - data.energy_total_kwh = value; - energy_ok = true; - got_any = true; - } - if (parse_obis_ascii_value(line, "1-0:16.7.0", value)) { - data.total_power_w = value; - total_p_ok = true; - got_any = true; - } - if (parse_obis_ascii_value(line, "1-0:36.7.0", value)) { - data.phase_power_w[0] = value; - p1_ok = true; - got_any = true; - } - if (parse_obis_ascii_value(line, "1-0:56.7.0", value)) { - data.phase_power_w[1] = value; - p2_ok = true; - got_any = true; - } - if (parse_obis_ascii_value(line, "1-0:76.7.0", value)) { - data.phase_power_w[2] = value; - p3_ok = true; - got_any = true; - } uint32_t meter_seconds = 0; - if (parse_obis_hex_u32(line, "0-0:96.8.0*255", meter_seconds)) { - data.meter_seconds = meter_seconds; - data.meter_seconds_valid = true; + 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; + energy_ok = true; + got_any = true; + } + break; + case ObisField::TotalPower: + if (parse_obis_ascii_payload_value(line, value)) { + data.total_power_w = value; + total_p_ok = true; + got_any = true; + } + break; + case ObisField::Phase1: + if (parse_obis_ascii_payload_value(line, value)) { + data.phase_power_w[0] = value; + p1_ok = true; + got_any = true; + } + break; + case ObisField::Phase2: + if (parse_obis_ascii_payload_value(line, value)) { + data.phase_power_w[1] = value; + p2_ok = true; + got_any = true; + } + break; + case ObisField::Phase3: + if (parse_obis_ascii_payload_value(line, value)) { + data.phase_power_w[2] = value; + p3_ok = true; + got_any = true; + } + break; + case ObisField::MeterSeconds: + if (parse_obis_hex_payload_u32(line, meter_seconds)) { + data.meter_seconds = meter_seconds; + 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; @@ -305,6 +430,7 @@ bool meter_parse_frame(const char *frame, size_t len, MeterData &data) { data.valid = got_any; if (data.valid) { g_frames_ok++; + g_last_good_frame_ms = millis(); } else { g_frames_parse_fail++; } diff --git a/src/mqtt_client.cpp b/src/mqtt_client.cpp index 35e09c4..0a09e6b 100644 --- a/src/mqtt_client.cpp +++ b/src/mqtt_client.cpp @@ -2,6 +2,7 @@ #include #include #include +#include "ha_discovery_json.h" #include "config.h" #include "json_codec.h" @@ -10,6 +11,13 @@ static PubSubClient mqtt_client(wifi_client); static WifiMqttConfig g_cfg; 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) { switch (fault) { 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, 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; - size_t len = serializeJson(doc, payload); - if (len == 0) { + if (!ha_build_discovery_sensor_payload(device_id, key, name, unit, device_class, state_topic, value_template, + ha_manufacturer_anchor(), payload)) { return false; } diff --git a/src/receiver_pipeline.cpp b/src/receiver_pipeline.cpp new file mode 100644 index 0000000..0ef466c --- /dev/null +++ b/src/receiver_pipeline.cpp @@ -0,0 +1,523 @@ +#include "receiver_pipeline.h" + +#include +#include +#include + +#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 +#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(value & 0xFF); + dst[1] = static_cast((value >> 8) & 0xFF); +} + +static uint16_t read_u16_le(const uint8_t *src) { + return static_cast(src[0]) | (static_cast(src[1]) << 8); +} + +static void write_u16_be(uint8_t *dst, uint16_t value) { + dst[0] = static_cast((value >> 8) & 0xFF); + dst[1] = static_cast(value & 0xFF); +} + +static uint16_t read_u16_be(const uint8_t *src) { + return static_cast(src[0] << 8) | static_cast(src[1]); +} + +static void write_u32_be(uint8_t *dst, uint32_t value) { + dst[0] = static_cast((value >> 24) & 0xFF); + dst[1] = static_cast((value >> 16) & 0xFF); + dst[2] = static_cast((value >> 8) & 0xFF); + dst[3] = static_cast(value & 0xFF); +} + +static uint32_t read_u32_be(const uint8_t *src) { + return (static_cast(src[0]) << 24) | + (static_cast(src[1]) << 16) | + (static_cast(src[2]) << 8) | + static_cast(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(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(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(time_valid), + static_cast(epoch), + static_cast(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(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(batch.sender_id), + static_cast(batch_id)); + goto receiver_loop_done; + } + + uint16_t expected_sender_id = static_cast(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(batch.sender_id), + static_cast(expected_sender_id), + static_cast(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(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(METER_BATCH_MAX_SAMPLES - 1); + + MeterData samples[METER_BATCH_MAX_SAMPLES]; + float bat_v = batch.battery_mV > 0 ? static_cast(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(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(batch.energy_wh[s]) / 1000.0f; + data.phase_power_w[0] = static_cast(batch.p1_w[s]); + data.phase_power_w[1] = static_cast(batch.p2_w[s]); + data.phase_power_w[2] = static_cast(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(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(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; +} + diff --git a/src/receiver_pipeline.h b/src/receiver_pipeline.h new file mode 100644 index 0000000..1b65af4 --- /dev/null +++ b/src/receiver_pipeline.h @@ -0,0 +1,27 @@ +#pragma once + +#include + +#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; +}; + diff --git a/src/sd_logger.cpp b/src/sd_logger.cpp index 5041c6f..8e1db43 100644 --- a/src/sd_logger.cpp +++ b/src/sd_logger.cpp @@ -27,30 +27,30 @@ static bool ensure_dir(const String &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(ts_utc); - struct tm tm_utc; - gmtime_r(&t, &tm_utc); + struct tm tm_local; + localtime_r(&t, &tm_local); char buf[16]; snprintf(buf, sizeof(buf), "%04d-%02d-%02d", - tm_utc.tm_year + 1900, - tm_utc.tm_mon + 1, - tm_utc.tm_mday); + tm_local.tm_year + 1900, + tm_local.tm_mon + 1, + tm_local.tm_mday); 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) { return ""; } time_t t = static_cast(ts_utc); - struct tm tm_utc; - gmtime_r(&t, &tm_utc); + struct tm tm_local; + localtime_r(&t, &tm_local); char buf[16]; snprintf(buf, sizeof(buf), "%02d:%02d:%02d", - tm_utc.tm_hour, - tm_utc.tm_min, - tm_utc.tm_sec); + tm_local.tm_hour, + tm_local.tm_min, + tm_local.tm_sec); return String(buf); } @@ -94,7 +94,7 @@ void sd_logger_log_sample(const MeterData &data, bool include_error_text) { 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); File f = SD.open(filename, FILE_APPEND); if (!f) { @@ -102,13 +102,13 @@ void sd_logger_log_sample(const MeterData &data, bool include_error_text) { } 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(','); - f.print(ts_hms_utc); + f.print(ts_hms_local); f.print(','); f.print(data.total_power_w, 1); f.print(','); diff --git a/src/sender_state_machine.cpp b/src/sender_state_machine.cpp new file mode 100644 index 0000000..4e23ac6 --- /dev/null +++ b/src/sender_state_machine.cpp @@ -0,0 +1,1604 @@ +#include "sender_state_machine.h" + +#include +#include "config.h" +#include "data_model.h" +#include "payload_codec.h" +#include "lora_transport.h" +#include "meter_driver.h" +#include "power_manager.h" +#include "time_manager.h" +#include "wifi_manager.h" +#include "display_ui.h" +#include +#include +#ifdef ARDUINO_ARCH_ESP32 +#include +#include +#include +#include +#endif + +namespace { + +#if defined(DD3_DEBUG) +static constexpr bool DD3_DEBUG_ENABLED = true; +#else +static constexpr bool DD3_DEBUG_ENABLED = false; +#endif + +enum class SenderPhase : uint8_t { + Syncing = 0, + Normal = 1, + Catchup = 2, + WaitAck = 3 +}; + +static SenderPhase g_sender_phase = SenderPhase::Syncing; + +static DeviceRole g_role = DeviceRole::Sender; +static uint16_t g_short_id = 0; +static char g_device_id[16] = ""; + +static SenderStatus g_sender_statuses[NUM_SENDERS]; +static bool g_ap_mode = false; +static WifiMqttConfig g_cfg; +static FaultCounters g_sender_faults = {}; +static FaultCounters g_receiver_faults = {}; +static FaultCounters g_receiver_faults_published = {}; +static FaultCounters g_sender_faults_remote[NUM_SENDERS] = {}; +static FaultCounters g_sender_faults_remote_published[NUM_SENDERS] = {}; +static FaultType g_sender_last_error = FaultType::None; +static FaultType g_receiver_last_error = FaultType::None; +static FaultType g_sender_last_error_remote[NUM_SENDERS] = {}; +static FaultType g_sender_last_error_remote_published[NUM_SENDERS] = {}; +static FaultType g_receiver_last_error_published = FaultType::None; +static uint32_t g_sender_last_error_utc = 0; +static uint32_t g_sender_last_error_ms = 0; +static uint32_t g_receiver_last_error_utc = 0; +static uint32_t g_receiver_last_error_ms = 0; +static uint32_t g_sender_last_error_remote_utc[NUM_SENDERS] = {}; +static uint32_t g_sender_last_error_remote_ms[NUM_SENDERS] = {}; +static bool g_sender_discovery_sent[NUM_SENDERS] = {}; +static bool g_receiver_discovery_sent = false; + +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; + +struct BatchBuffer { + uint16_t batch_id; + bool batch_id_valid; + uint8_t count; + uint16_t attempt_count; + uint16_t valid_count; + uint16_t invalid_count; + FaultType last_error; + MeterData samples[METER_BATCH_MAX_SAMPLES]; +}; + +static BatchBuffer g_batch_queue[BATCH_QUEUE_DEPTH]; +static uint8_t g_batch_head = 0; +static uint8_t g_batch_tail = 0; +static uint8_t g_batch_count = 0; + +static MeterData g_build_samples[METER_BATCH_MAX_SAMPLES]; +static uint8_t g_build_count = 0; + +static uint32_t g_last_sample_ms = 0; +static uint32_t g_last_sample_ts_utc = 0; +static uint32_t g_last_send_ms = 0; +static uint32_t g_last_batch_send_ms = 0; +static float g_last_battery_voltage_v = NAN; +static uint8_t g_last_battery_percent = 0; +static uint32_t g_last_battery_ms = 0; +static uint16_t g_batch_id = 1; +static uint16_t g_last_sent_batch_id = 0; +static uint16_t g_last_acked_batch_id = 0; +static uint8_t g_batch_retry_count = 0; +static bool g_batch_ack_pending = false; +static uint32_t g_batch_ack_timeout_ms = BATCH_ACK_TIMEOUT_MS; +static MeterData g_inflight_samples[METER_BATCH_MAX_SAMPLES]; +static uint8_t g_inflight_count = 0; +static uint16_t g_inflight_batch_id = 0; +static bool g_inflight_active = false; +static bool g_inflight_sync_request = false; +static uint8_t g_inflight_encoded_payload[BATCH_MAX_COMPRESSED]; +static size_t g_inflight_encoded_payload_len = 0; +static uint16_t g_inflight_encoded_batch_id = 0; +static bool g_inflight_encoded_sync_request = false; +static bool g_inflight_encoded_valid = false; +static uint32_t g_last_debug_log_ms = 0; +static uint32_t g_sender_rx_window_ms = 0; +static uint32_t g_sender_sleep_ms = 0; +static uint32_t g_sender_power_log_ms = 0; +static uint32_t g_meter_queue_high_water = 0; +static uint32_t g_meter_queue_drop_count = 0; +static uint32_t g_sender_ack_timeout_total = 0; +static uint32_t g_sender_ack_retry_total = 0; +static uint32_t g_sender_ack_rtt_last_ms = 0; +static uint32_t g_sender_ack_rtt_ewma_ms = 0; +static uint32_t g_sender_ack_miss_streak = 0; +static uint32_t g_last_ack_window_log_ms = 0; +static RxRejectReason g_sender_rx_reject_reason = RxRejectReason::None; +static uint32_t g_sender_rx_reject_log_ms = 0; +static RxRejectReason g_receiver_rx_reject_reason = RxRejectReason::None; +static uint32_t g_receiver_rx_reject_log_ms = 0; +static MeterData g_last_meter_data = {}; +static bool g_last_meter_valid = false; +static uint32_t g_last_meter_rx_ms = 0; +static uint32_t g_meter_stale_seconds = 0; +static bool g_meter_time_anchor_valid = false; +static int64_t g_meter_epoch_offset = 0; +static bool g_meter_time_prev_valid = false; +static uint32_t g_meter_time_prev_seconds = 0; +static uint32_t g_meter_time_prev_rx_ms = 0; +static bool g_meter_time_jump_pending = false; +static bool g_time_acquired = false; +static bool g_sender_faults_reset_after_first_sync = false; +static uint32_t g_sender_faults_reset_hour_utc = UINT32_MAX; +static uint32_t g_last_sync_request_ms = 0; +static uint32_t g_build_attempts = 0; +static uint32_t g_build_valid = 0; +static uint32_t g_build_invalid = 0; +static constexpr uint32_t METER_SAMPLE_MAX_AGE_MS = 15000; +static constexpr uint32_t METER_TIME_DELTA_TOLERANCE_S = 2; +static constexpr int64_t METER_TIME_ANCHOR_DRIFT_TOLERANCE_S = 2; +#ifdef ENABLE_TEST_MODE +static uint32_t g_test_meter_last_emit_ms = 0; +static uint32_t g_test_meter_tick = 0; +#endif + +struct MeterSampleEvent { + MeterData data; + uint32_t rx_ms; +}; +#ifdef ARDUINO_ARCH_ESP32 +static QueueHandle_t g_meter_sample_queue = nullptr; +static TaskHandle_t g_meter_reader_task = nullptr; +static bool g_meter_reader_task_running = false; +static constexpr UBaseType_t METER_SAMPLE_QUEUE_LEN = 8; +static constexpr uint32_t METER_READER_TASK_STACK_WORDS = 4096; +static constexpr UBaseType_t METER_READER_TASK_PRIORITY = 2; +static constexpr BaseType_t METER_READER_TASK_CORE = 0; +#endif + +enum class TxBuildError : uint8_t { + None = 0, + Encode = 1 +}; + +static TxBuildError g_last_tx_build_error = TxBuildError::None; + +static void watchdog_kick(); +static void finish_inflight_batch(); +static void invalidate_inflight_encode_cache(); + +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); +} + +#ifdef ARDUINO_ARCH_ESP32 +static void update_meter_queue_high_water() { + if (!g_meter_sample_queue) { + return; + } + uint32_t depth = static_cast(uxQueueMessagesWaiting(g_meter_sample_queue)); + if (depth > g_meter_queue_high_water) { + g_meter_queue_high_water = depth; + } +} +#endif + +static void sender_log_diagnostics(uint32_t now_ms) { + if (!SERIAL_DEBUG_MODE) { + return; + } + if (now_ms - g_last_debug_log_ms < 5000) { + return; + } + g_last_debug_log_ms = now_ms; + + MeterDriverStats meter_stats = {}; + meter_get_stats(meter_stats); + + uint32_t queue_depth = 0; +#ifdef ARDUINO_ARCH_ESP32 + if (g_meter_sample_queue) { + queue_depth = static_cast(uxQueueMessagesWaiting(g_meter_sample_queue)); + } +#endif + + uint32_t meter_age_ms = 0; + if (meter_stats.last_good_frame_ms > 0 && now_ms >= meter_stats.last_good_frame_ms) { + meter_age_ms = now_ms - meter_stats.last_good_frame_ms; + } else if (meter_stats.last_good_frame_ms == 0) { + meter_age_ms = UINT32_MAX; + } + + serial_debug_printf( + "diag: q_depth=%lu q_hi=%lu q_drop=%lu batch_q=%u build=%u ack_pending=%u ack_retry_cur=%u ack_retry_total=%lu ack_timeout_total=%lu ack_rtt_last_ms=%lu ack_rtt_ewma_ms=%lu ack_miss_streak=%lu meter_ok=%lu meter_fail=%lu meter_ovf=%lu meter_timeout=%lu meter_age_ms=%lu rx_win_ms=%lu sleep_ms=%lu", + static_cast(queue_depth), + static_cast(g_meter_queue_high_water), + static_cast(g_meter_queue_drop_count), + static_cast(g_batch_count), + static_cast(g_build_count), + g_batch_ack_pending ? 1U : 0U, + static_cast(g_batch_retry_count), + static_cast(g_sender_ack_retry_total), + static_cast(g_sender_ack_timeout_total), + static_cast(g_sender_ack_rtt_last_ms), + static_cast(g_sender_ack_rtt_ewma_ms), + static_cast(g_sender_ack_miss_streak), + static_cast(meter_stats.frames_ok), + static_cast(meter_stats.frames_parse_fail), + static_cast(meter_stats.rx_overflow), + static_cast(meter_stats.rx_timeout), + static_cast(meter_age_ms), + static_cast(g_sender_rx_window_ms), + static_cast(g_sender_sleep_ms)); +} + +static void invalidate_inflight_encode_cache() { + g_inflight_encoded_payload_len = 0; + g_inflight_encoded_batch_id = 0; + g_inflight_encoded_sync_request = false; + g_inflight_encoded_valid = false; +} + +static uint8_t bit_count32(uint32_t value) { + uint8_t count = 0; + while (value != 0) { + value &= (value - 1); + count++; + } + return count; +} + +static uint32_t abs_diff_u32(uint32_t a, uint32_t b) { + return a >= b ? (a - b) : (b - a); +} + +static void meter_time_update_snapshot(MeterData &parsed, uint32_t rx_ms) { + if (!parsed.meter_seconds_valid) { + return; + } + + bool jump = false; + const char *jump_reason = nullptr; + uint32_t delta_meter_s = 0; + uint32_t delta_wall_s = 0; + if (g_meter_time_prev_valid) { + if (parsed.meter_seconds < g_meter_time_prev_seconds) { + jump = true; + jump_reason = "rollback"; + } else { + delta_meter_s = parsed.meter_seconds - g_meter_time_prev_seconds; + uint32_t delta_wall_ms = rx_ms - g_meter_time_prev_rx_ms; + delta_wall_s = (delta_wall_ms + 500) / 1000; + if (abs_diff_u32(delta_meter_s, delta_wall_s) > METER_TIME_DELTA_TOLERANCE_S) { + jump = true; + jump_reason = "delta"; + } + } + } + + if (time_is_synced()) { + uint32_t epoch_now = time_get_utc(); + if (epoch_now >= MIN_ACCEPTED_EPOCH_UTC) { + int64_t new_offset = static_cast(epoch_now) - static_cast(parsed.meter_seconds); + if (!g_meter_time_anchor_valid || jump) { + g_meter_epoch_offset = new_offset; + g_meter_time_anchor_valid = true; + } else { + int64_t drift_s = new_offset - g_meter_epoch_offset; + if (drift_s > METER_TIME_ANCHOR_DRIFT_TOLERANCE_S || drift_s < -METER_TIME_ANCHOR_DRIFT_TOLERANCE_S) { + jump = true; + jump_reason = jump_reason ? jump_reason : "anchor"; + g_meter_epoch_offset = new_offset; + } + } + } + } + + if (g_meter_time_anchor_valid) { + int64_t epoch64 = static_cast(parsed.meter_seconds) + g_meter_epoch_offset; + if (epoch64 > 0 && epoch64 <= static_cast(UINT32_MAX)) { + parsed.ts_utc = static_cast(epoch64); + } + } + + if (jump) { + g_meter_time_jump_pending = true; + if (SERIAL_DEBUG_MODE) { + serial_debug_printf("meter_time: jump reason=%s sec=%lu prev=%lu d_meter=%lu d_wall=%lu", + jump_reason ? jump_reason : "unknown", + static_cast(parsed.meter_seconds), + static_cast(g_meter_time_prev_seconds), + static_cast(delta_meter_s), + static_cast(delta_wall_s)); + } + } + + g_meter_time_prev_seconds = parsed.meter_seconds; + g_meter_time_prev_rx_ms = rx_ms; + g_meter_time_prev_valid = true; +} + +static void set_last_meter_sample(const MeterData &parsed_in, uint32_t rx_ms) { + MeterData parsed = parsed_in; + meter_time_update_snapshot(parsed, rx_ms); + g_last_meter_data = parsed; + g_last_meter_valid = true; + g_last_meter_rx_ms = rx_ms; + g_meter_stale_seconds = 0; +} + +static bool parse_meter_frame_sample(const char *frame, size_t frame_len, MeterData &parsed) { + parsed = {}; + parsed.energy_total_kwh = NAN; + parsed.total_power_w = NAN; + parsed.phase_power_w[0] = NAN; + parsed.phase_power_w[1] = NAN; + parsed.phase_power_w[2] = NAN; + parsed.valid = false; + return meter_parse_frame(frame, frame_len, parsed); +} + +#ifdef ENABLE_TEST_MODE +static bool generate_test_meter_sample(uint32_t now_ms, MeterData &parsed) { + if (g_test_meter_last_emit_ms != 0 && now_ms - g_test_meter_last_emit_ms < METER_SAMPLE_INTERVAL_MS) { + return false; + } + g_test_meter_last_emit_ms = now_ms; + g_test_meter_tick++; + + parsed = {}; + parsed.valid = true; + parsed.meter_seconds_valid = true; + parsed.meter_seconds = MIN_ACCEPTED_EPOCH_UTC + g_test_meter_tick; + parsed.energy_total_kwh = static_cast(g_test_meter_tick) / 1000.0f; // 1 Wh step per sample. + parsed.phase_power_w[0] = static_cast(g_test_meter_tick); + parsed.phase_power_w[1] = static_cast(g_test_meter_tick); + parsed.phase_power_w[2] = static_cast(g_test_meter_tick); + parsed.total_power_w = parsed.phase_power_w[0] + parsed.phase_power_w[1] + parsed.phase_power_w[2]; + return true; +} +#endif + +#ifdef ARDUINO_ARCH_ESP32 +static void meter_queue_push_latest(const MeterSampleEvent &event) { + if (!g_meter_sample_queue) { + return; + } + if (xQueueSend(g_meter_sample_queue, &event, 0) == pdTRUE) { + update_meter_queue_high_water(); + return; + } + g_meter_queue_drop_count++; + MeterSampleEvent dropped = {}; + xQueueReceive(g_meter_sample_queue, &dropped, 0); + if (xQueueSend(g_meter_sample_queue, &event, 0) == pdTRUE) { + update_meter_queue_high_water(); + return; + } + if (SERIAL_DEBUG_MODE) { + serial_debug_printf("meter: queue push failed"); + } +} + +static void meter_reader_task_entry(void *arg) { + (void)arg; + for (;;) { +#ifdef ENABLE_TEST_MODE + MeterData test_sample = {}; + uint32_t now_ms = millis(); + if (!generate_test_meter_sample(now_ms, test_sample)) { + vTaskDelay(pdMS_TO_TICKS(5)); + continue; + } + MeterSampleEvent event = {}; + event.data = test_sample; + event.rx_ms = now_ms; + meter_queue_push_latest(event); + continue; +#endif + + const char *frame = nullptr; + size_t frame_len = 0; + if (!meter_poll_frame(frame, frame_len)) { + vTaskDelay(pdMS_TO_TICKS(5)); + continue; + } + + MeterData parsed = {}; + if (parse_meter_frame_sample(frame, frame_len, parsed)) { + MeterSampleEvent event = {}; + event.data = parsed; + event.rx_ms = millis(); + meter_queue_push_latest(event); + } + } +} + +static bool meter_reader_start() { + if (g_meter_reader_task_running) { + return true; + } + if (!g_meter_sample_queue) { + g_meter_sample_queue = xQueueCreate(METER_SAMPLE_QUEUE_LEN, sizeof(MeterSampleEvent)); + if (!g_meter_sample_queue) { + if (SERIAL_DEBUG_MODE) { + serial_debug_printf("meter: queue alloc failed"); + } + return false; + } + } + + BaseType_t rc = xTaskCreatePinnedToCore( + meter_reader_task_entry, + "meter_reader", + METER_READER_TASK_STACK_WORDS, + nullptr, + METER_READER_TASK_PRIORITY, + &g_meter_reader_task, + METER_READER_TASK_CORE); + if (rc != pdPASS) { + if (SERIAL_DEBUG_MODE) { + serial_debug_printf("meter: task start failed rc=%ld", static_cast(rc)); + } + return false; + } + g_meter_reader_task_running = true; + serial_debug_printf("meter: reader task core=%ld queue=%u", + static_cast(METER_READER_TASK_CORE), + static_cast(METER_SAMPLE_QUEUE_LEN)); + return true; +} +#endif + +static void meter_reader_pump(uint32_t now_ms) { +#ifdef ARDUINO_ARCH_ESP32 + if (g_meter_reader_task_running && g_meter_sample_queue) { + MeterSampleEvent event = {}; + while (xQueueReceive(g_meter_sample_queue, &event, 0) == pdTRUE) { + set_last_meter_sample(event.data, event.rx_ms); + } + return; + } +#endif + +#ifdef ENABLE_TEST_MODE + MeterData test_sample = {}; + if (generate_test_meter_sample(now_ms, test_sample)) { + set_last_meter_sample(test_sample, now_ms); + } + return; +#endif + + const char *frame = nullptr; + size_t frame_len = 0; + if (!meter_poll_frame(frame, frame_len)) { + return; + } + MeterData parsed = {}; + if (parse_meter_frame_sample(frame, frame_len, parsed)) { + set_last_meter_sample(parsed, now_ms); + } +} + + +static void update_battery_cache() { + MeterData tmp = {}; + read_battery(tmp); + g_last_battery_voltage_v = tmp.battery_voltage_v; + g_last_battery_percent = tmp.battery_percent; + g_last_battery_ms = millis(); +} + +static bool battery_sample_due(uint32_t now_ms) { + return g_last_battery_ms == 0 || now_ms - g_last_battery_ms >= BATTERY_SAMPLE_INTERVAL_MS; +} + +static bool batch_queue_drop_oldest() { + if (g_batch_count == 0) { + return false; + } + bool dropped_inflight = g_inflight_active && g_batch_queue[g_batch_tail].batch_id_valid && + g_inflight_batch_id == g_batch_queue[g_batch_tail].batch_id; + if (dropped_inflight) { + g_batch_ack_pending = false; + g_batch_retry_count = 0; + g_inflight_active = false; + g_inflight_count = 0; + g_inflight_batch_id = 0; + g_inflight_sync_request = false; + invalidate_inflight_encode_cache(); + } + g_batch_tail = (g_batch_tail + 1) % BATCH_QUEUE_DEPTH; + g_batch_count--; + return dropped_inflight; +} + +static void sender_note_rx_reject(RxRejectReason reason, const char *context) { + if (reason == RxRejectReason::None) { + return; + } + g_sender_rx_reject_reason = reason; + uint32_t now_ms = millis(); + if (SERIAL_DEBUG_MODE && now_ms - g_sender_rx_reject_log_ms >= 1000) { + g_sender_rx_reject_log_ms = now_ms; + serial_debug_printf("rx_reject: %s reason=%s", context, rx_reject_reason_text(reason)); + } +} + +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 BatchBuffer *batch_queue_peek() { + if (g_batch_count == 0) { + return nullptr; + } + return &g_batch_queue[g_batch_tail]; +} + +static void batch_queue_enqueue(const MeterData *samples, uint8_t count) { + if (!samples || count == 0) { + return; + } + if (g_batch_count >= BATCH_QUEUE_DEPTH) { + if (batch_queue_drop_oldest()) { + g_batch_id++; + } + } + BatchBuffer &slot = g_batch_queue[g_batch_head]; + slot.batch_id = 0; + slot.batch_id_valid = false; + slot.count = count; + slot.attempt_count = static_cast(g_build_attempts); + slot.valid_count = static_cast(g_build_valid); + slot.invalid_count = static_cast(g_build_invalid); + slot.last_error = g_sender_last_error; + for (uint8_t i = 0; i < count; ++i) { + slot.samples[i] = samples[i]; + } + g_batch_head = (g_batch_head + 1) % BATCH_QUEUE_DEPTH; + g_batch_count++; +} + +static void reset_build_counters() { + g_build_attempts = 0; + g_build_valid = 0; + g_build_invalid = 0; +} + +static bool append_meter_sample(const MeterData &data, bool meter_ok, bool has_snapshot) { + if (!has_snapshot) { + g_build_invalid++; + return false; + } + g_last_sample_ts_utc = data.ts_utc; + g_build_samples[g_build_count++] = data; + if (meter_ok) { + g_build_valid++; + } else { + g_build_invalid++; + } + if (g_build_count >= METER_BATCH_MAX_SAMPLES) { + batch_queue_enqueue(g_build_samples, g_build_count); + g_build_count = 0; + reset_build_counters(); + } + return true; +} + +static uint32_t last_sample_ts() { + if (g_last_sample_ts_utc == 0) { + uint32_t now_utc = time_get_utc(); + return now_utc > 0 ? now_utc : millis() / 1000; + } + return g_last_sample_ts_utc; +} + +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 void sender_reset_fault_stats(const char *reason, uint32_t now_utc) { + clear_faults(g_sender_faults, g_sender_last_error, g_sender_last_error_utc, g_sender_last_error_ms); + g_sender_rx_reject_reason = RxRejectReason::None; + display_set_last_error(g_sender_last_error, g_sender_last_error_utc, g_sender_last_error_ms); + if (SERIAL_DEBUG_MODE) { + serial_debug_printf("faults: reset scope=sender reason=%s ts_utc=%lu", + reason ? reason : "unknown", + static_cast(now_utc)); + } +} + +static void sender_reset_fault_stats_on_first_sync(uint32_t synced_utc) { + if (g_sender_faults_reset_after_first_sync || synced_utc < MIN_ACCEPTED_EPOCH_UTC) { + return; + } + sender_reset_fault_stats("first_sync", synced_utc); + g_sender_faults_reset_after_first_sync = true; + g_sender_faults_reset_hour_utc = synced_utc / 3600U; +} + +static void sender_reset_fault_stats_on_hour_boundary() { + if (!g_time_acquired || !g_sender_faults_reset_after_first_sync) { + return; + } + uint32_t now_utc = time_get_utc(); + if (now_utc < MIN_ACCEPTED_EPOCH_UTC) { + return; + } + uint32_t now_hour_utc = now_utc / 3600U; + if (g_sender_faults_reset_hour_utc == UINT32_MAX) { + g_sender_faults_reset_hour_utc = now_hour_utc; + return; + } + if (now_hour_utc > g_sender_faults_reset_hour_utc) { + sender_reset_fault_stats("hourly", now_utc); + g_sender_faults_reset_hour_utc = now_hour_utc; + } +} + + +#ifdef ARDUINO_ARCH_ESP32 +static void watchdog_init() { + esp_task_wdt_deinit(); + esp_task_wdt_config_t config = {}; + config.timeout_ms = WATCHDOG_TIMEOUT_SEC * 1000; + config.idle_core_mask = 0; + config.trigger_panic = true; + esp_task_wdt_init(&config); + esp_task_wdt_add(nullptr); +} + +static void watchdog_kick() { + esp_task_wdt_reset(); +} +#else +static void watchdog_init() {} +static void watchdog_kick() {} +#endif + +static void write_u16_le(uint8_t *dst, uint16_t value) { + dst[0] = static_cast(value & 0xFF); + dst[1] = static_cast((value >> 8) & 0xFF); +} + +static uint16_t read_u16_le(const uint8_t *src) { + return static_cast(src[0]) | (static_cast(src[1]) << 8); +} + +static void write_u16_be(uint8_t *dst, uint16_t value) { + dst[0] = static_cast((value >> 8) & 0xFF); + dst[1] = static_cast(value & 0xFF); +} + +static uint16_t read_u16_be(const uint8_t *src) { + return static_cast(src[0] << 8) | static_cast(src[1]); +} + +static void write_u32_be(uint8_t *dst, uint32_t value) { + dst[0] = static_cast((value >> 24) & 0xFF); + dst[1] = static_cast((value >> 16) & 0xFF); + dst[2] = static_cast((value >> 8) & 0xFF); + dst[3] = static_cast(value & 0xFF); +} + +static uint32_t read_u32_be(const uint8_t *src) { + return (static_cast(src[0]) << 24) | + (static_cast(src[1]) << 16) | + (static_cast(src[2]) << 8) | + static_cast(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(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 kwh_to_wh_from_float(float value) { + if (isnan(value)) { + return 0; + } + double wh = static_cast(value) * 1000.0; + if (wh < 0.0) { + wh = 0.0; + } + if (wh > static_cast(UINT32_MAX)) { + wh = static_cast(UINT32_MAX); + } + return static_cast(llround(wh)); +} + +static bool float_to_i16_w(float value, int16_t &out) { + if (isnan(value)) { + out = 0; + return true; + } + long rounded = lroundf(value); + if (rounded < INT16_MIN || rounded > INT16_MAX) { + return false; + } + out = static_cast(rounded); + return true; +} + +static int16_t float_to_i16_w_clamped(float value, bool &clamped) { + clamped = false; + if (isnan(value)) { + return 0; + } + long rounded = lroundf(value); + if (rounded < INT16_MIN) { + clamped = true; + return INT16_MIN; + } + if (rounded > INT16_MAX) { + clamped = true; + return INT16_MAX; + } + return static_cast(rounded); +} + +static uint16_t battery_mv_from_voltage(float value) { + if (isnan(value) || value <= 0.0f) { + return 0; + } + long mv = lroundf(value * 1000.0f); + if (mv < 0) { + mv = 0; + } + if (mv > UINT16_MAX) { + mv = UINT16_MAX; + } + return static_cast(mv); +} + +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(chunk_count) * per_chunk_toa_ms + BATCH_RX_MARGIN_MS; + return timeout_ms < 10000 ? 10000 : timeout_ms; +} + +static uint32_t compute_batch_ack_timeout_ms(size_t payload_len) { + if (payload_len == 0) { + return 10000; + } + uint8_t chunk_count = static_cast((payload_len + BATCH_CHUNK_PAYLOAD - 1) / BATCH_CHUNK_PAYLOAD); + size_t packet_len = 3 + BATCH_HEADER_SIZE + (payload_len > BATCH_CHUNK_PAYLOAD ? BATCH_CHUNK_PAYLOAD : payload_len) + 2; + uint32_t per_chunk_toa_ms = lora_airtime_ms(packet_len); + uint32_t timeout_ms = static_cast(chunk_count) * per_chunk_toa_ms + BATCH_RX_MARGIN_MS; + return timeout_ms < 10000 ? 10000 : timeout_ms; +} + +static bool send_batch_payload(const uint8_t *data, size_t len, uint32_t ts_for_display, uint16_t batch_id) { + if (!data || len == 0 || len > BATCH_MAX_COMPRESSED) { + return false; + } + uint8_t chunk_count = static_cast((len + BATCH_CHUNK_PAYLOAD - 1) / BATCH_CHUNK_PAYLOAD); + if (chunk_count == 0) { + return false; + } + + bool all_ok = true; + size_t offset = 0; + for (uint8_t i = 0; i < chunk_count; ++i) { + size_t chunk_len = len - offset; + if (chunk_len > BATCH_CHUNK_PAYLOAD) { + chunk_len = BATCH_CHUNK_PAYLOAD; + } + LoraPacket pkt = {}; + pkt.msg_kind = LoraMsgKind::BatchUp; + pkt.device_id_short = g_short_id; + pkt.payload_len = chunk_len + BATCH_HEADER_SIZE; + + uint8_t *payload = pkt.payload; + write_u16_le(&payload[0], batch_id); + payload[2] = i; + payload[3] = chunk_count; + write_u16_le(&payload[4], static_cast(len)); + memcpy(&payload[BATCH_HEADER_SIZE], data + offset, chunk_len); + + watchdog_kick(); + uint32_t tx_start = millis(); + bool ok = lora_send(pkt); + uint32_t tx_ms = millis() - tx_start; + all_ok = all_ok && ok; + if (!ok) { + note_fault(g_sender_faults, g_sender_last_error, g_sender_last_error_utc, g_sender_last_error_ms, FaultType::LoraTx); + display_set_last_error(g_sender_last_error, g_sender_last_error_utc, g_sender_last_error_ms); + } + if (SERIAL_DEBUG_MODE && (!ok || tx_ms > 2000)) { + serial_debug_printf("tx: chunk %u/%u took %lums ok=%u", static_cast(i + 1), + static_cast(chunk_count), static_cast(tx_ms), ok ? 1 : 0); + } + offset += chunk_len; + delay(10); + } + + display_set_last_tx(all_ok, ts_for_display); + return all_ok; +} + +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(time_valid), + static_cast(epoch), + static_cast(sample_count)); + lora_receive_continuous(); +} + +static bool prepare_inflight_from_queue() { + if (g_inflight_active) { + return true; + } + BatchBuffer *batch = batch_queue_peek(); + if (!batch || batch->count == 0) { + return false; + } + if (!batch->batch_id_valid) { + batch->batch_id = g_batch_id; + batch->batch_id_valid = true; + } + if (SERIAL_DEBUG_MODE) { + serial_debug_printf("batch: id=%u desired=%u attempts=%u valid=%u invalid=%u err_last=%u", + batch->batch_id, + static_cast(METER_BATCH_MAX_SAMPLES), + static_cast(batch->attempt_count), + static_cast(batch->valid_count), + static_cast(batch->invalid_count), + static_cast(batch->last_error)); + } + g_inflight_count = batch->count; + g_inflight_batch_id = batch->batch_id; + for (uint8_t i = 0; i < g_inflight_count; ++i) { + g_inflight_samples[i] = batch->samples[i]; + } + g_inflight_active = true; + return true; +} + +static bool send_inflight_batch(uint32_t ts_for_display) { + g_last_tx_build_error = TxBuildError::None; + if (!g_inflight_active) { + return false; + } + + bool cache_match = g_inflight_encoded_valid && + g_inflight_encoded_batch_id == g_inflight_batch_id && + g_inflight_encoded_sync_request == g_inflight_sync_request; + if (cache_match) { + g_batch_ack_timeout_ms = compute_batch_ack_timeout_ms(g_inflight_encoded_payload_len); + uint32_t send_start = millis(); + bool ok = send_batch_payload(g_inflight_encoded_payload, g_inflight_encoded_payload_len, ts_for_display, g_inflight_batch_id); + uint32_t send_ms = millis() - send_start; + if (SERIAL_DEBUG_MODE && send_ms > 1000) { + serial_debug_printf("tx: resend batch took %lums", static_cast(send_ms)); + } + if (ok) { + g_last_batch_send_ms = millis(); + if (g_inflight_sync_request) { + serial_debug_printf("sync: request tx batch_id=%u", g_inflight_batch_id); + } else { + serial_debug_printf("tx: resent batch_id=%u len=%u", g_inflight_batch_id, static_cast(g_inflight_encoded_payload_len)); + } + } else if (g_inflight_sync_request) { + serial_debug_printf("sync: request tx failed batch_id=%u", g_inflight_batch_id); + } else { + serial_debug_printf("tx: resend failed batch_id=%u", g_inflight_batch_id); + } + return ok; + } + + BatchInput input = {}; + input.sender_id = sender_id_from_short_id(g_short_id); + input.batch_id = g_inflight_batch_id; + input.t_last = g_inflight_sync_request ? time_get_utc() : + g_inflight_samples[g_inflight_count - 1].ts_utc; + input.present_mask = 0; + input.n = 0; + input.battery_mV = g_inflight_sync_request ? battery_mv_from_voltage(g_last_battery_voltage_v) : + battery_mv_from_voltage(g_inflight_samples[g_inflight_count - 1].battery_voltage_v); + input.err_m = g_sender_faults.meter_read_fail > 255 ? 255 : static_cast(g_sender_faults.meter_read_fail); + input.err_d = g_sender_faults.decode_fail > 255 ? 255 : static_cast(g_sender_faults.decode_fail); + input.err_tx = g_sender_faults.lora_tx_fail > 255 ? 255 : static_cast(g_sender_faults.lora_tx_fail); + input.err_last = static_cast(g_sender_last_error); + input.err_rx_reject = static_cast(g_sender_rx_reject_reason); + uint8_t energy_regressions = 0; + uint8_t phase_clamps = 0; + uint8_t ts_dropped = 0; + uint8_t ts_collapsed = 0; + + if (!g_inflight_sync_request) { + if (input.t_last < static_cast(METER_BATCH_MAX_SAMPLES - 1)) { + g_last_tx_build_error = TxBuildError::Encode; + return false; + } + const uint32_t window_start = input.t_last - static_cast(METER_BATCH_MAX_SAMPLES - 1); + MeterData slot_samples[METER_BATCH_MAX_SAMPLES]; + bool slot_used[METER_BATCH_MAX_SAMPLES] = {}; + for (uint8_t i = 0; i < g_inflight_count; ++i) { + const MeterData &sample = g_inflight_samples[i]; + if (sample.ts_utc < window_start || sample.ts_utc > input.t_last) { + if (ts_dropped < 255) { + ts_dropped++; + } + continue; + } + uint8_t slot = static_cast(sample.ts_utc - window_start); + if (slot_used[slot] && ts_collapsed < 255) { + ts_collapsed++; + } + slot_used[slot] = true; + slot_samples[slot] = sample; + } + for (uint8_t slot = 0; slot < METER_BATCH_MAX_SAMPLES; ++slot) { + if (!slot_used[slot]) { + continue; + } + const uint8_t out_idx = input.n; + if (out_idx >= METER_BATCH_MAX_SAMPLES) { + g_last_tx_build_error = TxBuildError::Encode; + return false; + } + input.present_mask |= (1UL << slot); + input.n++; + input.energy_wh[out_idx] = kwh_to_wh_from_float(slot_samples[slot].energy_total_kwh); + bool c1 = false; + bool c2 = false; + bool c3 = false; + input.p1_w[out_idx] = float_to_i16_w_clamped(slot_samples[slot].phase_power_w[0], c1); + input.p2_w[out_idx] = float_to_i16_w_clamped(slot_samples[slot].phase_power_w[1], c2); + input.p3_w[out_idx] = float_to_i16_w_clamped(slot_samples[slot].phase_power_w[2], c3); + if (c1 && phase_clamps < 255) { + phase_clamps++; + } + if (c2 && phase_clamps < 255) { + phase_clamps++; + } + if (c3 && phase_clamps < 255) { + phase_clamps++; + } + } + } + + for (uint8_t i = 0; i < input.n; ++i) { + if (i > 0 && input.energy_wh[i] < input.energy_wh[i - 1]) { + input.energy_wh[i] = input.energy_wh[i - 1]; + if (energy_regressions < 255) { + energy_regressions++; + } + } + } + if (SERIAL_DEBUG_MODE && (energy_regressions > 0 || phase_clamps > 0 || ts_dropped > 0 || ts_collapsed > 0)) { + serial_debug_printf("tx: sanitize batch_id=%u energy_regress=%u phase_clamps=%u ts_drop=%u ts_dup=%u", + g_inflight_batch_id, + static_cast(energy_regressions), + static_cast(phase_clamps), + static_cast(ts_dropped), + static_cast(ts_collapsed)); + } + + static uint8_t encoded[BATCH_MAX_COMPRESSED]; + size_t encoded_len = 0; + uint32_t encode_start = millis(); + if (!encode_batch(input, encoded, sizeof(encoded), &encoded_len)) { + g_last_tx_build_error = TxBuildError::Encode; + invalidate_inflight_encode_cache(); + return false; + } + memcpy(g_inflight_encoded_payload, encoded, encoded_len); + g_inflight_encoded_payload_len = encoded_len; + g_inflight_encoded_batch_id = g_inflight_batch_id; + g_inflight_encoded_sync_request = g_inflight_sync_request; + g_inflight_encoded_valid = true; + uint32_t encode_ms = millis() - encode_start; + if (SERIAL_DEBUG_MODE) { + serial_debug_printf("tx: batch_id=%u count=%u mask=%08lX bin_len=%u", + g_inflight_batch_id, + static_cast(input.n), + static_cast(input.present_mask), + static_cast(encoded_len)); + if (encode_ms > 200) { + serial_debug_printf("tx: encode took %lums", static_cast(encode_ms)); + } + } + g_batch_ack_timeout_ms = compute_batch_ack_timeout_ms(encoded_len); + + uint32_t send_start = millis(); + bool ok = send_batch_payload(encoded, encoded_len, ts_for_display, g_inflight_batch_id); + uint32_t send_ms = millis() - send_start; + if (SERIAL_DEBUG_MODE && send_ms > 1000) { + serial_debug_printf("tx: send batch took %lums", static_cast(send_ms)); + } + if (ok) { + g_last_batch_send_ms = millis(); + if (g_inflight_sync_request) { + serial_debug_printf("sync: request tx batch_id=%u", g_inflight_batch_id); + } else { + serial_debug_printf("tx: sent batch_id=%u len=%u", g_inflight_batch_id, static_cast(encoded_len)); + } + } else { + if (g_inflight_sync_request) { + serial_debug_printf("sync: request tx failed batch_id=%u", g_inflight_batch_id); + } else { + serial_debug_printf("tx: send failed batch_id=%u", g_inflight_batch_id); + } + } + return ok; +} + +static bool send_meter_batch(uint32_t ts_for_display) { + if (!prepare_inflight_from_queue()) { + return false; + } + g_inflight_sync_request = false; + bool ok = send_inflight_batch(ts_for_display); + if (ok) { + g_last_sent_batch_id = g_inflight_batch_id; + g_batch_ack_pending = true; + } else { + if (g_last_tx_build_error == TxBuildError::Encode) { + serial_debug_printf("tx: encode failed batch_id=%u dropped", g_inflight_batch_id); + note_fault(g_sender_faults, g_sender_last_error, g_sender_last_error_utc, g_sender_last_error_ms, FaultType::Decode); + display_set_last_error(g_sender_last_error, g_sender_last_error_utc, g_sender_last_error_ms); + finish_inflight_batch(); + return false; + } + g_inflight_active = false; + g_inflight_count = 0; + g_inflight_batch_id = 0; + g_inflight_sync_request = false; + invalidate_inflight_encode_cache(); + } + return ok; +} + +static bool send_sync_request() { + if (g_batch_ack_pending) { + return false; + } + if (battery_sample_due(millis())) { + update_battery_cache(); + } + g_inflight_active = true; + g_inflight_sync_request = true; + g_inflight_count = 0; + g_inflight_batch_id = g_batch_id; + if (SERIAL_DEBUG_MODE && g_build_attempts > 0) { + serial_debug_printf("batch: id=%u desired=%u attempts=%u valid=%u invalid=%u err_last=%u sync=1", + g_inflight_batch_id, + static_cast(METER_BATCH_MAX_SAMPLES), + static_cast(g_build_attempts), + static_cast(g_build_valid), + static_cast(g_build_invalid), + static_cast(g_sender_last_error)); + } + bool ok = send_inflight_batch(time_get_utc()); + if (ok) { + g_last_sent_batch_id = g_inflight_batch_id; + g_batch_ack_pending = true; + } else { + g_inflight_active = false; + g_inflight_sync_request = false; + g_inflight_batch_id = 0; + invalidate_inflight_encode_cache(); + } + return ok; +} + +static bool resend_inflight_batch(uint32_t ts_for_display) { + if (!g_batch_ack_pending || !g_inflight_active || (!g_inflight_sync_request && g_inflight_count == 0)) { + return false; + } + return send_inflight_batch(ts_for_display); +} + +static void finish_inflight_batch() { + if (g_batch_count > 0) { + batch_queue_drop_oldest(); + } + g_batch_ack_pending = false; + g_batch_retry_count = 0; + g_inflight_active = false; + g_inflight_count = 0; + g_inflight_batch_id = 0; + g_inflight_sync_request = false; + invalidate_inflight_encode_cache(); + g_batch_id++; +} + +static void sender_loop() { + watchdog_kick(); + uint32_t now_ms = millis(); + display_set_sender_queue(g_batch_count, g_build_count > 0); + display_set_sender_batches(g_last_acked_batch_id, g_batch_id); + sender_log_diagnostics(now_ms); + + meter_reader_pump(now_ms); + + if (g_time_acquired) { + sender_reset_fault_stats_on_hour_boundary(); + while (now_ms - g_last_sample_ms >= METER_SAMPLE_INTERVAL_MS) { + g_last_sample_ms += METER_SAMPLE_INTERVAL_MS; + MeterData data = {}; + data.short_id = g_short_id; + strncpy(data.device_id, g_device_id, sizeof(data.device_id)); + data.energy_total_kwh = NAN; + data.total_power_w = NAN; + data.phase_power_w[0] = NAN; + data.phase_power_w[1] = NAN; + data.phase_power_w[2] = NAN; + + g_build_attempts++; + uint32_t meter_age_ms = g_last_meter_valid ? (now_ms - g_last_meter_rx_ms) : UINT32_MAX; + // Reuse recent good samples to bridge short parser gaps without accepting stale data forever. + bool has_snapshot = g_last_meter_valid; + bool meter_ok = has_snapshot && meter_age_ms <= METER_SAMPLE_MAX_AGE_MS; + if (has_snapshot) { + data.meter_seconds = g_last_meter_data.meter_seconds; + data.meter_seconds_valid = g_last_meter_data.meter_seconds_valid; + data.energy_total_kwh = g_last_meter_data.energy_total_kwh; + data.total_power_w = g_last_meter_data.total_power_w; + data.phase_power_w[0] = g_last_meter_data.phase_power_w[0]; + data.phase_power_w[1] = g_last_meter_data.phase_power_w[1]; + data.phase_power_w[2] = g_last_meter_data.phase_power_w[2]; + g_meter_stale_seconds = meter_age_ms >= 1000 ? (meter_age_ms / 1000) : 0; + } else { + g_meter_stale_seconds = g_last_meter_valid ? (meter_age_ms / 1000) : (g_meter_stale_seconds + 1); + } + if (!meter_ok) { + note_fault(g_sender_faults, g_sender_last_error, g_sender_last_error_utc, g_sender_last_error_ms, FaultType::MeterRead); + display_set_last_error(g_sender_last_error, g_sender_last_error_utc, g_sender_last_error_ms); + } + if (g_meter_time_jump_pending) { + g_meter_time_jump_pending = false; + note_fault(g_sender_faults, g_sender_last_error, g_sender_last_error_utc, g_sender_last_error_ms, FaultType::MeterRead); + display_set_last_error(g_sender_last_error, g_sender_last_error_utc, g_sender_last_error_ms); + } + if (g_build_count == 0 && battery_sample_due(now_ms)) { + update_battery_cache(); + } + data.battery_voltage_v = g_last_battery_voltage_v; + data.battery_percent = g_last_battery_percent; + data.rx_reject_reason = static_cast(g_sender_rx_reject_reason); + uint32_t sample_ts_utc = 0; + if (has_snapshot && g_last_meter_data.meter_seconds_valid && g_last_meter_data.ts_utc >= MIN_ACCEPTED_EPOCH_UTC) { + sample_ts_utc = g_last_meter_data.ts_utc; + } else { + sample_ts_utc = time_get_utc(); + if (sample_ts_utc > 0 && now_ms > g_last_sample_ms) { + uint32_t lag_s = (now_ms - g_last_sample_ms) / 1000; + if (sample_ts_utc > lag_s) { + sample_ts_utc -= lag_s; + } + } + } + data.ts_utc = sample_ts_utc; + data.valid = has_snapshot; + + bool appended = append_meter_sample(data, meter_ok, has_snapshot); + (void)appended; + display_set_last_meter(data); + display_set_last_read(meter_ok, data.ts_utc); + } + + if (!g_batch_ack_pending && now_ms - g_last_send_ms >= METER_SEND_INTERVAL_MS) { + g_last_send_ms = now_ms; + if (g_build_count > 0) { + batch_queue_enqueue(g_build_samples, g_build_count); + g_build_count = 0; + reset_build_counters(); + } + if (g_batch_count > 0) { + send_meter_batch(last_sample_ts()); + } else if (g_build_attempts > 0) { + if (send_sync_request()) { + reset_build_counters(); + } + } + } + + // Catch-up mode: when backlog exists, send next queued batch without waiting + // for the regular 30s cadence. + if (!g_batch_ack_pending && g_batch_count > 1) { + send_meter_batch(last_sample_ts()); + } + } else { + if (!g_batch_ack_pending && now_ms - g_last_sync_request_ms >= SYNC_REQUEST_INTERVAL_MS) { + g_last_sync_request_ms = now_ms; + send_sync_request(); + } + } + + if (g_batch_ack_pending) { + LoraPacket ack_pkt = {}; + constexpr size_t ack_len = lora_frame_size(LORA_ACK_DOWN_PAYLOAD_LEN); + uint32_t ack_air_ms = lora_airtime_ms(ack_len); + uint32_t ack_window_first_ms = ack_air_ms + 200; + if (g_sender_ack_rtt_ewma_ms > 0) { + uint32_t rtt_based_ms = g_sender_ack_rtt_ewma_ms + 150; + if (rtt_based_ms > ack_window_first_ms) { + ack_window_first_ms = rtt_based_ms; + } + } + uint32_t miss_boost_ms = g_sender_ack_miss_streak * 150; + if (miss_boost_ms > 1200) { + miss_boost_ms = 1200; + } + ack_window_first_ms += miss_boost_ms; + if (ack_window_first_ms < 600) { + ack_window_first_ms = 600; + } + if (ack_window_first_ms > 2500) { + ack_window_first_ms = 2500; + } + uint32_t ack_window_second_ms = ack_window_first_ms + (ack_window_first_ms / 2); + uint32_t min_second_ms = ack_air_ms + 400; + if (ack_window_second_ms < min_second_ms) { + ack_window_second_ms = min_second_ms; + } + if (ack_window_second_ms > 5000) { + ack_window_second_ms = 5000; + } + if (SERIAL_DEBUG_MODE && (g_sender_ack_miss_streak > 0 || now_ms - g_last_ack_window_log_ms >= 10000)) { + g_last_ack_window_log_ms = now_ms; + serial_debug_printf("ack: rx windows=%lu/%lu airtime=%lu miss_streak=%lu", + static_cast(ack_window_first_ms), + static_cast(ack_window_second_ms), + static_cast(ack_air_ms), + static_cast(g_sender_ack_miss_streak)); + } + uint32_t rx_start = millis(); + bool got_ack = lora_receive_window(ack_pkt, ack_window_first_ms); + if (!got_ack) { + got_ack = lora_receive_window(ack_pkt, ack_window_second_ms); + } + uint32_t rx_elapsed = millis() - rx_start; + if (SERIAL_DEBUG_MODE) { + g_sender_rx_window_ms += rx_elapsed; + } + bool ack_accepted = false; + if (!got_ack) { + RxRejectReason reason = lora_get_last_rx_reject_reason(); + sender_note_rx_reject(reason, "ack"); + if (SERIAL_DEBUG_MODE) { + int16_t rssi_dbm = 0; + float snr_db = 0.0f; + bool has_signal = lora_get_last_rx_signal(rssi_dbm, snr_db); + const char *reason_text = reason == RxRejectReason::None ? "timeout" : rx_reject_reason_text(reason); + if (has_signal) { + serial_debug_printf("ack: rx miss reason=%s rssi=%d snr=%.1f", + reason_text, + static_cast(rssi_dbm), + static_cast(snr_db)); + } else { + serial_debug_printf("ack: rx miss reason=%s", reason_text); + } + } + } else if (ack_pkt.msg_kind != LoraMsgKind::AckDown) { + sender_note_rx_reject(RxRejectReason::InvalidMsgKind, "ack"); + if (SERIAL_DEBUG_MODE) { + uint16_t ack_id = ack_pkt.payload_len >= 3 ? read_u16_be(&ack_pkt.payload[1]) : 0; + serial_debug_printf("ack: reject msg_kind=%u payload_len=%u ack_id=%u", + static_cast(ack_pkt.msg_kind), + static_cast(ack_pkt.payload_len), + ack_id); + } + } else if (ack_pkt.payload_len < LORA_ACK_DOWN_PAYLOAD_LEN) { + sender_note_rx_reject(RxRejectReason::LengthMismatch, "ack"); + if (SERIAL_DEBUG_MODE) { + uint16_t ack_id = ack_pkt.payload_len >= 3 ? read_u16_be(&ack_pkt.payload[1]) : 0; + serial_debug_printf("ack: reject msg_kind=%u payload_len=%u ack_id=%u", + static_cast(ack_pkt.msg_kind), + static_cast(ack_pkt.payload_len), + ack_id); + } + } else { + uint8_t time_valid = ack_pkt.payload[0] & 0x01; + uint16_t ack_id = read_u16_be(&ack_pkt.payload[1]); + uint32_t ack_epoch = read_u32_be(&ack_pkt.payload[3]); + bool set_time = false; + if (g_batch_ack_pending && ack_id == g_last_sent_batch_id) { + ack_accepted = true; + g_sender_ack_rtt_last_ms = rx_elapsed; + if (g_sender_ack_rtt_ewma_ms == 0) { + g_sender_ack_rtt_ewma_ms = rx_elapsed; + } else { + g_sender_ack_rtt_ewma_ms = (g_sender_ack_rtt_ewma_ms * 3U + rx_elapsed + 1U) / 4U; + } + if (time_valid == 1 && ack_epoch >= MIN_ACCEPTED_EPOCH_UTC) { + time_set_utc(ack_epoch); + g_time_acquired = true; + sender_reset_fault_stats_on_first_sync(ack_epoch); + set_time = true; + } + g_last_acked_batch_id = ack_id; + serial_debug_printf("ack: rx ok batch_id=%u time_valid=%u epoch=%lu set=%u", + ack_id, + static_cast(time_valid), + static_cast(ack_epoch), + set_time ? 1 : 0); + finish_inflight_batch(); + } else { + if (ack_id != g_last_sent_batch_id) { + sender_note_rx_reject(RxRejectReason::BatchIdMismatch, "ack"); + if (SERIAL_DEBUG_MODE) { + serial_debug_printf("ack: reject msg_kind=%u payload_len=%u ack_id=%u", + static_cast(ack_pkt.msg_kind), + static_cast(ack_pkt.payload_len), + ack_id); + } + } + } + } + if (ack_accepted) { + g_sender_ack_miss_streak = 0; + } else if (g_sender_ack_miss_streak < UINT32_MAX) { + g_sender_ack_miss_streak++; + } + } + if (!g_batch_ack_pending) { + lora_sleep(); + } + + if (g_batch_ack_pending && (now_ms - g_last_batch_send_ms >= g_batch_ack_timeout_ms)) { + g_sender_ack_timeout_total++; + if (g_batch_retry_count < BATCH_MAX_RETRIES) { + g_batch_retry_count++; + g_sender_ack_retry_total++; + serial_debug_printf("ack: timeout batch_id=%u retry=%u", g_inflight_batch_id, g_batch_retry_count); + resend_inflight_batch(last_sample_ts()); + } else { + serial_debug_printf("ack: failed batch_id=%u policy=%s", g_inflight_batch_id, + BATCH_RETRY_POLICY == BatchRetryPolicy::Drop ? "drop" : "keep"); + if (BATCH_RETRY_POLICY == BatchRetryPolicy::Drop) { + finish_inflight_batch(); + } else { + g_batch_ack_pending = false; + g_batch_retry_count = 0; + g_inflight_active = false; + g_inflight_count = 0; + g_inflight_batch_id = 0; + g_inflight_sync_request = false; + invalidate_inflight_encode_cache(); + } + note_fault(g_sender_faults, g_sender_last_error, g_sender_last_error_utc, g_sender_last_error_ms, FaultType::LoraTx); + display_set_last_error(g_sender_last_error, g_sender_last_error_utc, g_sender_last_error_ms); + } + } + + display_tick(); + + uint32_t next_due = g_time_acquired ? (g_last_sample_ms + METER_SAMPLE_INTERVAL_MS) : + (g_last_sync_request_ms + SYNC_REQUEST_INTERVAL_MS); + if (g_time_acquired) { + uint32_t next_send_due = g_last_send_ms + METER_SEND_INTERVAL_MS; + if (next_send_due < next_due) { + next_due = next_send_due; + } + } + if (!g_batch_ack_pending && next_due > now_ms) { + watchdog_kick(); + uint32_t idle_ms = next_due - now_ms; + if (SERIAL_DEBUG_MODE) { + g_sender_sleep_ms += idle_ms; + if (now_ms - g_sender_power_log_ms >= 10000) { + g_sender_power_log_ms = now_ms; + serial_debug_printf("power: rx_ms=%lu sleep_ms=%lu", static_cast(g_sender_rx_window_ms), + static_cast(g_sender_sleep_ms)); + } + } + lora_sleep(); + if (g_time_acquired) { + // Keep the meter reader task running while metering is active. + delay(idle_ms); + } else { + light_sleep_ms(idle_ms); + } + } +} + +static const char *sender_phase_text(SenderPhase phase) { + switch (phase) { + case SenderPhase::Syncing: return "SYNCING"; + case SenderPhase::Normal: return "NORMAL"; + case SenderPhase::Catchup: return "CATCHUP"; + case SenderPhase::WaitAck: return "WAIT_ACK"; + default: return "UNKNOWN"; + } +} + +static void sender_transition(SenderPhase next, const char *reason) { + if (next == g_sender_phase) { + return; + } + if (DD3_DEBUG_ENABLED && SERIAL_DEBUG_MODE) { + serial_debug_printf("state: %s -> %s reason=%s", sender_phase_text(g_sender_phase), sender_phase_text(next), reason ? reason : "none"); + } + g_sender_phase = next; +} + +static void sender_update_phase() { + if (g_batch_ack_pending) { + sender_transition(SenderPhase::WaitAck, "ack_pending"); + } else if (!g_time_acquired) { + sender_transition(SenderPhase::Syncing, "time_unsynced"); + } else if (g_batch_count > 1) { + sender_transition(SenderPhase::Catchup, "backlog"); + } else { + sender_transition(SenderPhase::Normal, "steady"); + } +} + +static void sender_validate_invariants() { + if (g_batch_count > BATCH_QUEUE_DEPTH) { + serial_debug_printf("inv: queue overflow count=%u max=%u", static_cast(g_batch_count), static_cast(BATCH_QUEUE_DEPTH)); + g_batch_count = BATCH_QUEUE_DEPTH; + } + if (g_batch_retry_count > BATCH_MAX_RETRIES) { + serial_debug_printf("inv: retry overflow retry=%u max=%u", static_cast(g_batch_retry_count), static_cast(BATCH_MAX_RETRIES)); + g_batch_retry_count = BATCH_MAX_RETRIES; + } + if (g_batch_ack_pending && !g_inflight_active && SERIAL_DEBUG_MODE) { + serial_debug_printf("inv: ack pending without inflight"); + } +} + +} // namespace + +bool SenderStateMachine::begin(const SenderStateMachineConfig &config) { + 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'; + } + + power_sender_init(); + power_configure_unused_pins_sender(); + meter_init(); +#ifdef ARDUINO_ARCH_ESP32 + if (!meter_reader_start()) { + serial_debug_printf("meter: using inline polling fallback"); + } +#endif + g_last_sample_ms = millis() - METER_SAMPLE_INTERVAL_MS; + g_last_send_ms = millis(); + g_last_sync_request_ms = millis() - SYNC_REQUEST_INTERVAL_MS; + g_time_acquired = false; + g_sender_faults_reset_after_first_sync = false; + g_sender_faults_reset_hour_utc = UINT32_MAX; +#ifdef ENABLE_TEST_MODE + g_test_meter_last_emit_ms = 0; + g_test_meter_tick = 0; +#endif + update_battery_cache(); + sender_transition(SenderPhase::Syncing, "begin"); + return true; +} + +void SenderStateMachine::loop() { + sender_update_phase(); + sender_loop(); + sender_validate_invariants(); +} + +SenderStats SenderStateMachine::stats() const { + SenderStats stats = {}; + stats.queue_depth = g_batch_count; + stats.build_count = g_build_count; + stats.inflight_batch_id = g_inflight_batch_id; + stats.last_sent_batch_id = g_last_sent_batch_id; + stats.last_acked_batch_id = g_last_acked_batch_id; + stats.retry_count = g_batch_retry_count; + stats.ack_pending = g_batch_ack_pending; + stats.ack_timeout_total = g_sender_ack_timeout_total; + stats.ack_retry_total = g_sender_ack_retry_total; + stats.ack_miss_streak = g_sender_ack_miss_streak; + stats.rx_window_ms = g_sender_rx_window_ms; + stats.sleep_ms = g_sender_sleep_ms; + return stats; +} + +void SenderStateMachine::handleMeterRead(uint32_t now_ms) { + meter_reader_pump(now_ms); +} + +void SenderStateMachine::maybeSendBatch(uint32_t now_ms) { + (void)now_ms; +} + +void SenderStateMachine::handleAckWindow(uint32_t now_ms) { + (void)now_ms; +} + +bool SenderStateMachine::applyTimeFromAck(uint8_t time_valid, uint32_t ack_epoch) { + if (time_valid == 1 && ack_epoch >= MIN_ACCEPTED_EPOCH_UTC) { + time_set_utc(ack_epoch); + g_time_acquired = true; + sender_reset_fault_stats_on_first_sync(ack_epoch); + return true; + } + return false; +} + +void SenderStateMachine::validateInvariants() { + sender_validate_invariants(); +} diff --git a/src/sender_state_machine.h b/src/sender_state_machine.h new file mode 100644 index 0000000..a2816da --- /dev/null +++ b/src/sender_state_machine.h @@ -0,0 +1,44 @@ +#pragma once + +#include + +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(); +}; diff --git a/src/time_manager.cpp b/src/time_manager.cpp index 6f0144c..17a8110 100644 --- a/src/time_manager.cpp +++ b/src/time_manager.cpp @@ -1,10 +1,15 @@ #include "time_manager.h" #include "config.h" #include +#ifdef ARDUINO_ARCH_ESP32 +#include +#endif static bool g_time_synced = false; +static bool g_clock_plausible = false; static bool g_tz_set = false; 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) { if (epoch == 0) { @@ -13,6 +18,32 @@ static void note_last_sync(uint32_t epoch) { g_last_sync_utc = epoch; } +static bool epoch_is_plausible(time_t epoch) { + return epoch >= static_cast(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(UINT32_MAX)) { + return; + } + mark_synced(static_cast(epoch)); +} +#endif + static void ensure_timezone_set() { if (g_tz_set) { return; @@ -25,24 +56,31 @@ static void ensure_timezone_set() { 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 *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); ensure_timezone_set(); } uint32_t time_get_utc() { time_t now = time(nullptr); - if (now < 1672531200) { + if (!epoch_is_plausible(now)) { + g_clock_plausible = false; return 0; } - if (!g_time_synced) { - g_time_synced = true; - note_last_sync(static_cast(now)); + g_clock_plausible = true; +#ifdef ARDUINO_ARCH_ESP32 + if (!g_time_synced && sntp_get_sync_status() == SNTP_SYNC_STATUS_COMPLETED) { + mark_synced(static_cast(now)); } +#endif return static_cast(now); } 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) { @@ -51,8 +89,12 @@ void time_set_utc(uint32_t epoch) { tv.tv_sec = epoch; tv.tv_usec = 0; settimeofday(&tv, nullptr); - g_time_synced = true; - note_last_sync(epoch); + if (epoch_is_plausible(static_cast(epoch))) { + mark_synced(epoch); + } else { + g_clock_plausible = false; + g_time_synced = false; + } } void time_get_local_hhmm(char *out, size_t out_len) { diff --git a/src/web_server.cpp b/src/web_server.cpp index 3bd11b7..186ca42 100644 --- a/src/web_server.cpp +++ b/src/web_server.cpp @@ -243,7 +243,16 @@ static void history_reset() { 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(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(ts_utc); struct tm tm_utc; gmtime_r(&t, &tm_utc); @@ -252,6 +261,40 @@ static String history_date_from_epoch(uint32_t ts_utc) { 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(UINT32_MAX)) { + return false; + } + out = static_cast(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() { if (!g_history.active || g_history.done || g_history.error) { return false; @@ -264,8 +307,17 @@ static bool history_open_next_file() { g_history.done = true; 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); + 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++; 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') { return false; } + const char *comma1 = strchr(line, ','); if (!comma1) { return false; } - char ts_buf[16]; - size_t ts_len = static_cast(comma1 - line); - if (ts_len >= sizeof(ts_buf)) { - return false; - } - memcpy(ts_buf, line, ts_len); - ts_buf[ts_len] = '\0'; - char *end = nullptr; - uint32_t ts = static_cast(strtoul(ts_buf, &end, 10)); - if (end == ts_buf) { + + uint32_t ts = 0; + if (!history_parse_u32_field(line, static_cast(comma1 - line), ts)) { return false; } + const char *comma2 = strchr(comma1 + 1, ','); if (!comma2) { return false; } - const char *p_start = comma2 + 1; - const char *p_end = strchr(p_start, ','); - char p_buf[16]; - size_t p_len = p_end ? static_cast(p_end - p_start) : strlen(p_start); - if (p_len == 0 || p_len >= sizeof(p_buf)) { - 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; + + float p = 0.0f; + if (!history_parse_float_field(comma1 + 1, static_cast(comma2 - (comma1 + 1)), p)) { + const char *p_start = comma2 + 1; + const char *p_end = strchr(p_start, ','); + size_t p_len = p_end ? static_cast(p_end - p_start) : strlen(p_start); + if (!history_parse_float_field(p_start, p_len, p)) { + return false; + } } + ts_out = ts; p_out = p; return true; @@ -567,10 +611,21 @@ static void handle_wifi_post() { cfg.ntp_server_2 = server.arg("ntp2"); } 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 += "

Save failed. Configuration was not persisted and reboot was cancelled.

"; + html += "

Back to config

"; + html += html_footer(); + server.send(500, "text/html", html); + return; + } + g_config = cfg; g_web_user = cfg.web_user; g_web_pass = cfg.web_pass; - wifi_save_config(cfg); server.send(200, "text/html", "Saved. Rebooting..."); delay(1000); ESP.restart(); @@ -638,10 +693,11 @@ static void handle_sender() { html += "if(min===max){min=0;}"; html += "ctx.strokeStyle='#333';ctx.lineWidth=1;ctx.beginPath();"; html += "let first=true;"; + html += "const xDen=series.length>1?(series.length-1):1;"; html += "for(let i=0;i(0xFFFF) ? 0 : static_cast(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() { prefs.begin("dd3cfg", false); } @@ -28,17 +81,39 @@ bool wifi_load_config(WifiMqttConfig &config) { } bool wifi_save_config(const WifiMqttConfig &config) { - prefs.putBool("valid", true); - prefs.putString("ssid", config.ssid); - prefs.putString("pass", config.password); - prefs.putString("mqhost", config.mqtt_host); - prefs.putUShort("mqport", config.mqtt_port); - prefs.putString("mquser", config.mqtt_user); - prefs.putString("mqpass", config.mqtt_pass); - prefs.putString("ntp1", config.ntp_server_1); - prefs.putString("ntp2", config.ntp_server_2); - prefs.putString("webuser", config.web_user); - prefs.putString("webpass", config.web_pass); + if (!wifi_write_bool_pref("valid", true)) { + return false; + } + if (!wifi_write_string_pref("ssid", config.ssid)) { + return false; + } + if (!wifi_write_string_pref("pass", config.password)) { + return false; + } + if (!wifi_write_string_pref("mqhost", config.mqtt_host)) { + 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; } diff --git a/test/check_ha_manufacturer.ps1 b/test/check_ha_manufacturer.ps1 new file mode 100644 index 0000000..18d632d --- /dev/null +++ b/test/check_ha_manufacturer.ps1 @@ -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." diff --git a/test/test_html_escape/test_html_escape.cpp b/test/test_html_escape/test_html_escape.cpp index 2f782cc..6731b9c 100644 --- a/test/test_html_escape/test_html_escape.cpp +++ b/test/test_html_escape/test_html_escape.cpp @@ -1,5 +1,6 @@ #include #include +#include "dd3_legacy_core.h" #include "html_util.h" static void test_html_escape_basic() { @@ -12,25 +13,122 @@ static void test_html_escape_basic() { 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(0x01); + control += static_cast(0x1F); + control += static_cast(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; - TEST_ASSERT_TRUE(sanitize_device_id("F19C", out)); - 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)); - TEST_ASSERT_FALSE(sanitize_device_id("../F19C", out)); - TEST_ASSERT_FALSE(sanitize_device_id("dd3-%2f", out)); - TEST_ASSERT_FALSE(sanitize_device_id("dd3-12/3", out)); - TEST_ASSERT_FALSE(sanitize_device_id("dd3-12\\3", 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()); + } + } +} + +static void test_sanitize_device_id_rejects_invalid() { + String out = "dd3-KEEP"; + const char *reject_cases[] = { + "", + "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() { + dd3_legacy_core_force_link(); UNITY_BEGIN(); 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(); } diff --git a/test/test_json_codec/test_json_codec.cpp b/test/test_json_codec/test_json_codec.cpp new file mode 100644 index 0000000..cb3aa94 --- /dev/null +++ b/test/test_json_codec/test_json_codec.cpp @@ -0,0 +1,129 @@ +#include +#include + +#include + +#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(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(FaultType::Decode), doc["err_last"] | 0U); + TEST_ASSERT_EQUAL_UINT8(static_cast(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(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(); + 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() {} diff --git a/test/test_lora_transport/test_lora_transport.cpp b/test/test_lora_transport/test_lora_transport.cpp new file mode 100644 index 0000000..0a9a7e1 --- /dev/null +++ b/test/test_lora_transport/test_lora_transport.cpp @@ -0,0 +1,131 @@ +#include +#include + +#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(LoraFrameDecodeStatus::Ok), static_cast(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(LoraFrameDecodeStatus::CrcFail), static_cast(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(LoraFrameDecodeStatus::InvalidMsgKind), static_cast(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(LoraFrameDecodeStatus::LengthMismatch), static_cast(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(BatchReassemblyStatus::InProgress), + static_cast(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(BatchReassemblyStatus::InProgress), + static_cast(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(BatchReassemblyStatus::Complete), + static_cast(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(BatchReassemblyStatus::InProgress), + static_cast(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(BatchReassemblyStatus::ErrorReset), + static_cast(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(BatchReassemblyStatus::ErrorReset), + static_cast(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(BatchReassemblyStatus::InProgress), + static_cast(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(BatchReassemblyStatus::ErrorReset), + static_cast(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() {} diff --git a/test/test_payload_codec/test_payload_codec.cpp b/test/test_payload_codec/test_payload_codec.cpp new file mode 100644 index 0000000..e58a077 --- /dev/null +++ b/test/test_payload_codec/test_payload_codec.cpp @@ -0,0 +1,279 @@ +#include +#include + +#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(i) * static_cast(i) * 3UL; + in.p1_w[i] = static_cast(-1000 + static_cast(i) * 25); + in.p2_w[i] = static_cast(500 - static_cast(i) * 30); + in.p3_w[i] = static_cast(((i % 2) == 0 ? 100 : -100) + static_cast(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() {} diff --git a/test/test_refactor_smoke/test_refactor_smoke.cpp b/test/test_refactor_smoke/test_refactor_smoke.cpp new file mode 100644 index 0000000..7bc22da --- /dev/null +++ b/test/test_refactor_smoke/test_refactor_smoke.cpp @@ -0,0 +1,41 @@ +#include +#include + +#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() {} +