diff --git a/README.md b/README.md index 7a765d7..c9b5b8c 100644 --- a/README.md +++ b/README.md @@ -1,104 +1,100 @@ # DD3-LoRa-Bridge-MultiSender -Firmware for LilyGO T3 v1.6.1 (`ESP32 + SX1276 + SSD1306`) that runs as either: -- `Sender` (PIN `GPIO14` HIGH): reads one IEC 62056-21 meter, batches samples, sends over LoRa. -- `Receiver` (PIN `GPIO14` LOW): receives/ACKs batches, publishes MQTT, serves web UI, logs to SD. +Firmware for LilyGO T3 v1.6.1 (`ESP32 + SX1276 + SSD1306`) that runs in two roles: +- `Sender` (`GPIO14` HIGH): reads one IEC 62056-21 meter, builds 30-slot sparse batches, sends via LoRa. +- `Receiver` (`GPIO14` LOW): receives/ACKs batches, publishes MQTT, serves web UI, logs to SD. -## Current Architecture +## Architecture Summary -- Single codebase, role selected at boot via `detect_role()` (`include/config.h`, `src/config.cpp`). -- LoRa link uses explicit CRC16 frame protection in firmware (`src/lora_transport.cpp`). -- Sender batches up to `30` samples and retries on missing ACK (`BATCH_MAX_RETRIES=2`, retry policy `Keep`). -- Sender meter parsing is decoupled from LoRa ACK waits using a dedicated FreeRTOS reader task + queue (`src/main.cpp`). -- Batch payload codec is schema v3 and uses a sparse `present_mask` over a 30-second window (no schema v2 compatibility). -- Sender derives epoch timestamps from meter Sekundenindex (`0-0:96.8.0*255`) using an epoch anchor when time is synced. -- Receiver uses STA mode when config is valid, otherwise AP fallback with web config. -- No debug auto-reboot timer is active in normal firmware loops. +- 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 only starts normal metering/transmit flow after valid time bootstrap from receiver ACK. +- Receiver runs STA mode if stored config is valid and connects, otherwise AP fallback. -## LoRa Frame Protocol (Current) +## LoRa Protocol -On-air frame format: +On-air frame: `[msg_kind:1][device_short_id:2][payload...][crc16:2]` `msg_kind`: -- `0` = `BatchUp` -- `1` = `AckDown` +- `0`: `BatchUp` +- `1`: `AckDown` -### `BatchUp` +### BatchUp -Transport is chunked (`batch_id`, `chunk_index`, `chunk_count`, `total_len`) and reassembled before payload decode. +Transport layer chunks payload into: -Payload codec (`src/payload_codec.cpp`) currently uses: -- `kMagic=0xDDB3` -- `kSchema=3` -- metadata: sender, batch, `t_last`, `present_mask`, battery, fault counters -- data arrays: `energy_wh[]`, `p1_w[]`, `p2_w[]`, `p3_w[]` +`[batch_id_le:2][chunk_index:1][chunk_count:1][total_len_le:2][chunk_payload...]` -`present_mask` is a 30-bit second map in the `[t_last-29, t_last]` window. -Only set bits carry samples, so missing seconds are explicitly represented. +Receiver reassembles all chunks before decode. -`n == 0` is valid for sync request packets (`present_mask == 0`). +Payload codec (`schema=3`, magic `0xDDB3`) carries: +- metadata: sender ID, batch ID, `t_last`, `present_mask`, battery mV, error counters +- arrays per present sample: `energy_wh[]`, `p1_w[]`, `p2_w[]`, `p3_w[]` -### `AckDown` (7 bytes) +`n == 0` with `present_mask == 0` is valid and used for sync request packets. + +### AckDown (7 bytes payload) `[flags:1][batch_id_be:2][epoch_utc_be:4]` - `flags bit0`: `time_valid` -- Receiver repeats ACK (`ACK_REPEAT_COUNT=3`, `ACK_REPEAT_DELAY_MS=200`). -- Sender accepts time only if `time_valid=1` and `epoch >= MIN_ACCEPTED_EPOCH_UTC` (`2026-02-01 00:00:00 UTC`). +- 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`) -## Time Bootstrap Guardrail +## Time Bootstrap and Timezone -On sender boot: +Sender boot starts in sync-only mode: - `g_time_acquired=false` -- no normal sampling/transmit yet -- sync request every `SYNC_REQUEST_INTERVAL_MS` (15s) +- sends sync requests every `SYNC_REQUEST_INTERVAL_MS` (`15s`) +- does not run normal 1 Hz sample/batch flow yet -Only after valid ACK time is received: -- system time is set -- normal 1 Hz sampling and periodic LoRa batch transmit start +After valid ACK time: +- `time_set_utc()` is called +- `g_time_acquired=true` +- normal 1 Hz sampling + periodic batch transmission starts -This blocks pre-threshold timestamps from MQTT/SD paths. - -Timezone handling: -- Local time rendering uses `TIMEZONE_TZ` from `include/config.h`. -- Default value is `CET-1CEST,M3.5.0/2,M10.5.0/3` and can be changed at compile time. +Timezone: +- `TIMEZONE_TZ` from `include/config.h` is applied in `time_manager`. +- Web/OLED local-time rendering uses this timezone. +- Default: `CET-1CEST,M3.5.0/2,M10.5.0/3`. ## Sender Meter Path -Implemented in `src/meter_driver.cpp` + sender loop in `src/main.cpp`: +Implemented by `src/meter_driver.cpp` and sender loop in `src/main.cpp`: +- UART: `Serial2`, `GPIO34`, `9600 7E1` +- ESP32 RX buffer enlarged to `8192` +- Frame detection `/ ... !`, timeout `METER_FRAME_TIMEOUT_MS=3000` +- 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) + - `1-0:16.7.0` total active power + - `1-0:36.7.0`, `56.7.0`, `76.7.0` phase powers -- UART: `Serial2`, RX pin `GPIO34` (`PIN_METER_RX`), `9600 7E1` -- ESP32 RX buffer is enlarged to `8192` bytes to survive long LoRa blocking sections. -- Frame detection: starts at `'/'`, ends at `'!'`, timeout protection included (`METER_FRAME_TIMEOUT_MS=3000`). -- Parsing runs in a dedicated sender task and is handed to the main sender loop via queue. -- Parsed OBIS values: - - `0-0:96.8.0*255` (meter Sekundenindex, 4-byte hex) - - `1-0:1.8.0` (total energy) - - `1-0:16.7.0` (total power) - - `1-0:36.7.0`, `56.7.0`, `76.7.0` (phase powers) -- `1-0:1.8.0*Wh` is automatically scaled to kWh +Timestamp derivation: +- anchor offset: `epoch_offset = epoch_now - meter_seconds` +- sample epoch: `ts_utc = meter_seconds + epoch_offset` +- jump checks: rollback, wall-time delta mismatch, anchor drift -Timestamping/validation on sender: -- Anchor when time is valid: `epoch_offset = epoch_now - meter_seconds`. -- Derived sample time: `ts_utc = meter_seconds + epoch_offset`. -- Meter-time checks: monotonicity and `delta_meter_seconds` vs elapsed wall time (with tolerance) plus anchor drift checks. -- On detected jump/drift, sender records meter fault and resulting timestamp discontinuities propagate through `present_mask` to receiver. - -Sender samples every second and transmits batches every 30 seconds. +Sender builds sparse 30-slot windows and sends every `METER_SEND_INTERVAL_MS` (`30s`). ## Receiver Behavior -For valid `BatchUp` decode: -1. Reassemble chunks and decode payload. +For decoded `BatchUp`: +1. Reassemble and decode. 2. Send `AckDown` immediately. -3. Drop duplicate batches per sender (`batch_id` tracking). -4. Track duplicate stats per sender: absolute duplicates, total received, duplicate percentage, last duplicate timestamp. -5. If `n==0`: treat as sync request only. -6. Else reconstruct timestamps from `t_last` + `present_mask`, preserving skipped seconds, then log to SD, update web UI, publish MQTT. +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: + - append to SD CSV + - publish MQTT state + - update web status and last batch table -## MQTT Topics and Payloads +## MQTT State topic: - `smartmeter//state` @@ -106,29 +102,43 @@ State topic: Fault topic (retained): - `smartmeter//faults` -State JSON fields (`src/json_codec.cpp`): +State JSON (`src/json_codec.cpp`) includes: - `id`, `ts`, `e_kwh` - `p_w`, `p1_w`, `p2_w`, `p3_w` - `bat_v`, `bat_pct` -- optional link fields: `rssi`, `snr` -- fault/reject fields: `err_last`, `rx_reject`, `rx_reject_text` (+ non-zero counters) +- optional link: `rssi`, `snr` +- `err_last`, `rx_reject`, `rx_reject_text` +- non-zero fault counters when available -Home Assistant discovery is enabled (`ENABLE_HA_DISCOVERY=true`) and publishes config topics under: -- `homeassistant/sensor///config` +Home Assistant discovery: +- enabled by `ENABLE_HA_DISCOVERY=true` +- publishes to `homeassistant/sensor///config` -## Web UI, Wi-Fi, Storage +## Web UI, Wi-Fi, SD -- Wi-Fi/MQTT/NTP/web-auth config persists in Preferences (`wifi_manager`). +- Wi-Fi/MQTT/NTP/web-auth config is stored in Preferences. - AP fallback SSID prefix: `DD3-Bridge-`. - Default web credentials: `admin/admin`. -- SD logging enabled (`ENABLE_SD_LOGGING=true`). -- Sender-specific web status includes duplicate-batch counters and the last duplicate time. -- Sender-specific OLED page shows duplicate rate as `pct (absolute)` and last duplicate as `HH:MM`. +- AP auth requirement is controlled by `WEB_AUTH_REQUIRE_AP` (default `false`). +- 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` + +History parser (`src/web_server.cpp`): +- expects the current CSV layout above +- legacy CSV layouts are not parsed (no backward compatibility) + +OLED duplicate display: +- receiver sender-pages show duplicate rate as `pct (absolute)` and last duplicate as `HH:MM`. ## Build Environments From `platformio.ini`: - - `lilygo-t3-v1-6-1` - `lilygo-t3-v1-6-1-test` - `lilygo-t3-v1-6-1-868` @@ -141,10 +151,12 @@ From `platformio.ini`: Example: ```bash -~/.platformio/penv/bin/pio run -e lilygo-t3-v1-6-1 +python -m platformio run -e lilygo-t3-v1-6-1 ``` ## Test Mode -`ENABLE_TEST_MODE` replaces normal sender/receiver loops with dedicated test loops (`src/test_mode.cpp`). -It sends/receives JSON test frames and publishes to `smartmeter//test`. +`ENABLE_TEST_MODE` replaces normal loops with `test_sender_loop` / `test_receiver_loop` (`src/test_mode.cpp`): +- Sender emits periodic JSON test payloads over LoRa. +- Receiver decodes test payloads, updates display test codes, publishes MQTT to: + - `smartmeter//test` diff --git a/Requirements.md b/Requirements.md new file mode 100644 index 0000000..221cabb --- /dev/null +++ b/Requirements.md @@ -0,0 +1,419 @@ +# Firmware Requirements (Rust Port Preparation) + +## 1. Scope + +This document defines the behavior that must be preserved when recreating this firmware in another language (target: Rust). +It is based on the current `lora-refactor` code state and captures: +- functional behavior +- protocol/data contracts +- module and function responsibilities +- runtime state-machine requirements + +Function names below are C++ references. Rust naming/layout may differ, but the behavior must remain equivalent. + +## 2. System-Level Requirements + +- Role selection: + - `Sender` when `GPIO14` reads HIGH. + - `Receiver` when `GPIO14` reads LOW. +- Device identity: + - derive `short_id` from MAC bytes 4/5. + - canonical `device_id` format: `dd3-XXXX` uppercase hex. +- LoRa transport: + - frame format: `[msg_kind][short_id_be][payload][crc16_ccitt]`. + - reject invalid CRC/msg-kind/length. +- Payload codec: + - schema `3` with `present_mask` (30-bit sparse second map). + - support `n==0` sync-request packets. +- 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`. +- Sampling/transmit cadence: + - sender sample cadence 1 Hz. + - sender batch cadence 30 s. + - sync-request cadence 15 s while unsynced. +- Receiver behavior: + - decode/reconstruct sparse timestamps. + - ACK each decoded batch promptly. + - update MQTT, web status, SD logging. +- Persistence: + - Wi-Fi/MQTT/NTP/web credentials in Preferences namespace `dd3cfg`. +- Web and display time rendering: + - local timezone from `TIMEZONE_TZ`. +- SD logging: + - CSV columns include both `ts_utc` and `ts_hms_utc`. + - history parser expects this current layout. + +## 3. Protocol and Data Contracts + +- `LoraMsgKind`: + - `BatchUp=0` + - `AckDown=1` +- `AckDown` payload fixed length `7` bytes: + - `[flags:1][batch_id_be:2][epoch_utc_be:4]` + - `flags bit0 = time_valid` +- `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` + +## 4. 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` + +- `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` + +- `String html_escape(const String&)` + - escape `& < > " '`. +- `String url_encode_component(const String&)` + - percent-encode non-safe characters. +- `bool sanitize_device_id(const String&, String&)` + - accept `XXXX` or `dd3-XXXX`; reject path traversal, `%`, invalid hex. +- Internal helpers to preserve behavior: + - `is_hex_char` + - `to_upper_hex4` + +## `src/meter_driver.cpp` + +- `void meter_init()` + - configure `Serial2` at `9600 7E1`, RX pin `PIN_METER_RX`, RX buffer size `8192` on ESP32. +- `bool meter_poll_frame(const char *&, size_t&)` + - incremental frame collector with start `/`, end `!`, timeout, overflow handling. +- `bool meter_parse_frame(const char*, size_t, MeterData&)` + - parse OBIS values and set meter data fields. +- `bool meter_read(MeterData&)` + - compatibility wrapper around poll+parse. +- Internal parse helpers to preserve numeric behavior: + - `parse_obis_ascii_value` + - `parse_obis_ascii_unit_scale` + - `hex_nibble` + - `parse_obis_hex_u32` + - `meter_debug_log` + +## `src/power_manager.cpp` + +- `void power_sender_init()` + - sender low-power setup (CPU freq, Wi-Fi/BT off, ADC setup). +- `void power_receiver_init()` + - receiver power setup. +- `void power_configure_unused_pins_sender()` + - configure known-unused pins with pulldown. +- `void read_battery(MeterData&)` + - averaged ADC conversion and voltage calibration. +- `uint8_t battery_percent_from_voltage(float)` + - LUT + interpolation. +- `void light_sleep_ms(uint32_t)` + - timer-based light sleep. +- `void go_to_deep_sleep(uint32_t)` + - timer-based deep sleep. + +## `src/time_manager.cpp` + +- `void time_receiver_init(const char*, const char*)` + - configure NTP servers and timezone env. +- `uint32_t time_get_utc()` + - return epoch or `0` when not plausible. +- `bool time_is_synced()` + - sync status helper. +- `void time_set_utc(uint32_t)` + - set system time and sync flags. +- `void time_get_local_hhmm(char*, size_t)` + - timezone-based local `HH:MM` output. +- `uint32_t time_get_last_sync_utc()` +- `uint32_t time_get_last_sync_age_sec()` +- Internal behavior-critical helpers: + - `note_last_sync` + - `ensure_timezone_set` + +## `src/lora_transport.cpp` + +- `void lora_init()` + - initialize SX1276 with configured LoRa params. +- `bool lora_send(const LoraPacket&)` + - frame pack + CRC append + transmit. +- `bool lora_receive(LoraPacket&, uint32_t timeout_ms)` + - parse frame, validate, return metadata including RSSI/SNR. +- `RxRejectReason lora_get_last_rx_reject_reason()` + - consume-and-clear reject reason. +- `bool lora_get_last_rx_signal(int16_t&, float&)` + - access last RX signal snapshot. +- `void lora_idle()` +- `void lora_sleep()` +- `void lora_receive_continuous()` +- `bool lora_receive_window(LoraPacket&, uint32_t)` +- `uint32_t lora_airtime_ms(size_t)` + - compute packet airtime from SF/BW/CR/preamble. +- Internal behavior-critical helpers: + - `note_reject` + - `crc16_ccitt` + +## `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. +- `bool decode_batch(const uint8_t*, size_t, BatchInput*)` + - strict schema/magic/flags decode + bounds checks. +- Varint primitives: + - `uleb128_encode`, `uleb128_decode` + - `zigzag32`, `unzigzag32` + - `svarint_encode`, `svarint_decode` +- Internal helpers: + - `write_u16_le`, `write_u32_le` + - `read_u16_le`, `read_u32_le` + - `ensure_capacity` + - `bit_count32` +- Optional self-test: + - `payload_codec_self_test` (when `PAYLOAD_CODEC_TEST`). + +## `src/json_codec.cpp` + +- `bool meterDataToJson(const MeterData&, String&)` + - create MQTT state JSON with stable field semantics. +- Internal numeric formatting helpers: + - `round2` + - `round_to_i32` + - `short_id_from_device_id` + - `format_float_2` + - `set_int_or_null` + +## `src/mqtt_client.cpp` + +- `void mqtt_init(const WifiMqttConfig&, const char*)` +- `void mqtt_loop()` +- `bool mqtt_is_connected()` +- `bool mqtt_publish_state(const MeterData&)` +- `bool mqtt_publish_faults(const char*, const FaultCounters&, FaultType, uint32_t)` +- `bool mqtt_publish_discovery(const char*)` +- `bool mqtt_publish_test(const char*, const String&)` (test mode only) +- Internal behavior-critical helpers: + - `fault_text` + - `mqtt_connect` + - `publish_discovery_sensor` + +## `src/wifi_manager.cpp` + +- `void wifi_manager_init()` +- `bool wifi_load_config(WifiMqttConfig&)` +- `bool wifi_save_config(const WifiMqttConfig&)` +- `bool wifi_connect_sta(const WifiMqttConfig&, uint32_t timeout_ms)` +- `void wifi_start_ap(const char*, const char*)` +- `bool wifi_is_connected()` +- `String wifi_get_ssid()` + +## `src/sd_logger.cpp` + +- `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`. +- Internal behavior-critical helpers: + - `fault_text` + - `ensure_dir` + - `format_date_utc` + - `format_hms_utc` + +## `src/display_ui.cpp` + +Public display API that must remain behavior-equivalent: +- `display_power_down` +- `display_init` +- `display_set_role` +- `display_set_self_ids` +- `display_set_sender_statuses` +- `display_set_last_meter` +- `display_set_last_read` +- `display_set_last_tx` +- `display_set_sender_queue` +- `display_set_sender_batches` +- `display_set_last_error` +- `display_set_receiver_status` +- `display_set_test_code` (test mode) +- `display_set_test_code_for_sender` (test mode) +- `display_tick` + +Internal rendering helpers to preserve behavior: +- `oled_set_power` +- `age_seconds` +- `round_power_w` +- `render_last_error_line` +- `render_last_sync_line` +- `render_sender_status` +- `render_sender_measurement` +- `render_receiver_status` +- `render_receiver_sender` + +## `src/web_server.cpp` + +Public web API: +- `web_server_set_config` +- `web_server_set_sender_faults` +- `web_server_set_last_batch` +- `web_server_begin_ap` +- `web_server_begin_sta` +- `web_server_loop` + +Internal route/state functions to preserve behavior: +- `format_local_hms` +- `format_epoch_local_hms` +- `timestamp_age_seconds` +- `round_power_w` +- `auth_required` +- `fault_text` +- `ensure_auth` +- `html_header` +- `html_footer` +- `format_faults` +- `sanitize_sd_download_path` +- `checkbox_checked` +- `sanitize_history_device_id` +- `sanitize_download_filename` +- `history_reset` +- `history_date_from_epoch` +- `history_open_next_file` +- `history_parse_line` +- `history_tick` +- `render_sender_block` +- `append_sd_listing` +- `handle_root` +- `handle_wifi_get` +- `handle_wifi_post` +- `handle_sender` +- `handle_manual` +- `handle_history_start` +- `handle_history_data` +- `handle_sd_download` + +## `src/test_mode.cpp` (`ENABLE_TEST_MODE`) + +- `test_sender_loop` + - periodic JSON test frame transmit. +- `test_receiver_loop` + - decode test JSON, update display test markers, publish MQTT test topic. + +## `src/main.cpp` (Core Orchestration) + +These functions define end-to-end firmware behavior and must have equivalents: + +- Logging/utilities: + - `serial_debug_printf` + - `bit_count32` + - `abs_diff_u32` + +- Meter-time anchoring and ingest: + - `meter_time_update_snapshot` + - `set_last_meter_sample` + - `parse_meter_frame_sample` + - `meter_queue_push_latest` + - `meter_reader_task_entry` + - `meter_reader_start` + - `meter_reader_pump` + +- Sender/receiver state setup and shared state: + - `init_sender_statuses` + - `update_battery_cache` + - `battery_sample_due` + +- Queue and sample batching: + - `batch_queue_drop_oldest` + - `sender_note_rx_reject` + - `batch_queue_peek` + - `batch_queue_enqueue` + - `reset_build_counters` + - `append_meter_sample` + - `last_sample_ts` + +- Fault tracking/publish: + - `note_fault` + - `age_seconds` + - `counters_changed` + - `publish_faults_if_needed` + +- Watchdog: + - `watchdog_init` + - `watchdog_kick` + +- Binary helpers and ID conversion: + - `write_u16_le` + - `read_u16_le` + - `write_u16_be` + - `read_u16_be` + - `write_u32_be` + - `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: + - `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 + +- Preserve wire compatibility first: + - LoRa frame byte layout, CRC16, ACK format, payload schema v3. +- 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 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`. + +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 + +- Sender unsynced boot sends only sync requests. +- ACK time bootstrap unlocks normal sender sampling. +- Sparse present-mask encode/decode round-trip matches C++. +- Receiver reconstructs timestamps correctly for gaps. +- 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. +- MQTT state/fault payload fields match existing names and semantics.