diff --git a/README.md b/README.md index 5a1e810..3d63ad2 100644 --- a/README.md +++ b/README.md @@ -9,13 +9,14 @@ 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/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`). +- Batch payload codec is schema `v4` with a 30-bit `present_mask` over `[meter_t_last-29, meter_t_last]` and a separate UTC anchor (`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. +- Current bench defaults in `include/config.h`: 868 MHz LoRa, sender short-ID `0x6540`, receiver short-ID `0x7EB4`. ## LoRa Protocol @@ -35,12 +36,14 @@ Transport layer chunks payload into: Receiver reassembles all chunks before decode. -Payload codec (`schema=3`, magic `0xDDB3`) carries: -- metadata: sender ID, batch ID, `t_last`, `present_mask`, battery mV, error counters +Payload codec (`schema=4`, magic `0xDDB3`) carries: +- metadata: sender ID, batch ID, `meter_t_last`, `ts_utc_last`, `present_mask`, battery mV, error counters - arrays per present sample: `energy_wh[]`, `p1_w[]`, `p2_w[]`, `p3_w[]` `n == 0` with `present_mask == 0` is valid and used for sync request packets. +Schema `v4` is not wire-compatible with schema `v3`: both sender and receiver must be flashed with matching firmware. + ### AckDown (7 bytes payload) `[flags:1][batch_id_be:2][epoch_utc_be:4]` @@ -90,7 +93,8 @@ Timestamp derivation: - sample epoch: `ts_utc = meter_seconds + epoch_offset` - jump checks: rollback, wall-time delta mismatch, anchor drift -Sender builds sparse 30-slot windows and sends every `METER_SEND_INTERVAL_MS` (`30s`). +Sender builds sparse 30-slot windows in meter-seconds space and sends every `METER_SEND_INTERVAL_MS` (`30s`). +Samples without a valid meter seconds value are rejected for normal batch transmission. 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): @@ -111,11 +115,13 @@ For decoded `BatchUp`: 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: +8. Else reconstruct each sample from `meter_t_last + present_mask` and `ts_utc_last + present_mask`, then: - append to SD CSV - publish MQTT state - update web status and last batch table +ACK validation accepts only configured sender IDs, the sender's own short-ID, or configured receiver short-IDs (`EXPECTED_RECEIVER_IDS`) so receiver-originated ACKs are allowed without accepting arbitrary device IDs. + ## MQTT State topic: @@ -154,6 +160,7 @@ Home Assistant discovery: - Default web credentials: `admin/admin`. - AP auth requirement is controlled by `WEB_AUTH_REQUIRE_AP` (default `true`). - STA auth requirement is controlled by `WEB_AUTH_REQUIRE_STA` (default `true`). +- If the receiver boots into AP mode with saved Wi-Fi and MQTT config, it periodically retries STA mode and reinitializes NTP, MQTT, and the web server after a successful reconnect. Web timestamp display: - human-facing timestamps show `epoch (HH:MM:SS TZ)` in local configured timezone. @@ -176,19 +183,14 @@ OLED duplicate display: ## Build Environments From `platformio.ini`: -- `lilygo-t3-v1-6-1` -- `lilygo-t3-v1-6-1-test` -- `lilygo-t3-v1-6-1-868` -- `lilygo-t3-v1-6-1-868-test` -- `lilygo-t3-v1-6-1-payload-test` -- `lilygo-t3-v1-6-1-868-payload-test` -- `lilygo-t3-v1-6-1-prod` -- `lilygo-t3-v1-6-1-868-prod` +- `production`: serial debug off, light sleep on +- `debug`: serial diagnostics on, real meter and real LoRa +- `test`: synthetic meter samples and payload codec self-test, serial debug off Example: ```bash -python -m platformio run -e lilygo-t3-v1-6-1 +python -m platformio run -e production ``` ## Test Mode diff --git a/include/config.h b/include/config.h index 180526f..fdc3c31 100644 --- a/include/config.h +++ b/include/config.h @@ -17,16 +17,22 @@ enum class BatchRetryPolicy : uint8_t { // ============================================================================= // LoRa frequency — uncomment ONE line: -#define LORA_FREQUENCY_HZ 433E6 // 433 MHz (EU ISM, default) -// #define LORA_FREQUENCY_HZ 868E6 // 868 MHz (EU SRD) +// #define LORA_FREQUENCY_HZ 433E6 // 433 MHz (EU ISM, default) +#define LORA_FREQUENCY_HZ 868E6 // 868 MHz (EU SRD) // #define LORA_FREQUENCY_HZ 915E6 // 915 MHz (US ISM) // Expected sender device IDs (short-IDs). The receiver will only accept // batches from these senders. Add one entry per physical sender board. constexpr uint8_t NUM_SENDERS = 1; inline constexpr uint16_t EXPECTED_SENDER_IDS[NUM_SENDERS] = { - 0xF19C // TTGO #1 – 433 MHz sender - // 0x7EB4 // TTGO #2 – 868 MHz sender (uncomment & adjust NUM_SENDERS) + 0x6540 // TTGO #1 – detected 868 MHz sender on /dev/ttyACM1 +}; + +// Receiver short-ID(s) allowed to originate AckDown packets. On the current +// bench setup the receiver board on /dev/ttyACM0 identifies as 0x7EB4. +constexpr uint8_t NUM_RECEIVERS = 1; +inline constexpr uint16_t EXPECTED_RECEIVER_IDS[NUM_RECEIVERS] = { + 0x7EB4 // TTGO receiver – detected 868 MHz receiver on /dev/ttyACM0 }; // ============================================================================= @@ -79,12 +85,17 @@ constexpr uint8_t METER_BATCH_MAX_SAMPLES = 30; constexpr uint8_t BATCH_QUEUE_DEPTH = 10; constexpr BatchRetryPolicy BATCH_RETRY_POLICY = BatchRetryPolicy::Keep; constexpr uint32_t WATCHDOG_TIMEOUT_SEC = 120; -constexpr uint32_t WIFI_RECONNECT_INTERVAL_MS = 60000; // WiFi reconnection retry interval (1 minute) +constexpr uint32_t WIFI_RECONNECT_INTERVAL_MS = 80UL * 1000UL; // WiFi reconnection retry interval (80 seconds) constexpr bool ENABLE_HA_DISCOVERY = true; #ifndef SERIAL_DEBUG_MODE_FLAG #define SERIAL_DEBUG_MODE_FLAG 0 #endif constexpr bool SERIAL_DEBUG_MODE = SERIAL_DEBUG_MODE_FLAG != 0; +#if defined(DD3_DEBUG) +constexpr bool CONFIG_FLOW_DEBUG = SERIAL_DEBUG_MODE; +#else +constexpr bool CONFIG_FLOW_DEBUG = false; +#endif constexpr bool SERIAL_DEBUG_DUMP_JSON = false; constexpr bool LORA_SEND_BYPASS = false; @@ -130,6 +141,7 @@ constexpr bool WEB_AUTH_REQUIRE_AP = true; // the web config page (/wifi). The first-boot AP forces password change. 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' && diff --git a/lib/dd3_legacy_core/include/payload_codec.h b/lib/dd3_legacy_core/include/payload_codec.h index 1a60b02..9439f6f 100644 --- a/lib/dd3_legacy_core/include/payload_codec.h +++ b/lib/dd3_legacy_core/include/payload_codec.h @@ -5,7 +5,8 @@ struct BatchInput { uint16_t sender_id; uint16_t batch_id; - uint32_t t_last; + uint32_t meter_t_last; + uint32_t ts_utc_last; uint32_t present_mask; uint8_t n; uint16_t battery_mV; diff --git a/lib/dd3_legacy_core/src/payload_codec.cpp b/lib/dd3_legacy_core/src/payload_codec.cpp index 0758371..8652f0c 100644 --- a/lib/dd3_legacy_core/src/payload_codec.cpp +++ b/lib/dd3_legacy_core/src/payload_codec.cpp @@ -2,8 +2,9 @@ #include static constexpr uint16_t kMagic = 0xDDB3; -// Breaking change: schema v3 replaces fixed dt_s spacing with a 30-bit present_mask. -static constexpr uint8_t kSchema = 3; +// Breaking change: schema v4 uses a 30-bit present_mask in meter_seconds space +// plus a ts_utc anchor for receiver-side wall-clock reconstruction. +static constexpr uint8_t kSchema = 4; static constexpr uint8_t kFlags = 0x01; static constexpr size_t kMaxSamples = 30; static constexpr uint32_t kPresentMaskValidBits = 0x3FFFFFFFUL; @@ -125,7 +126,7 @@ bool encode_batch(const BatchInput &in, uint8_t *out, size_t out_cap, size_t *ou return false; } size_t pos = 0; - if (!ensure_capacity(24, out_cap, pos)) { + if (!ensure_capacity(28, out_cap, pos)) { return false; } write_u16_le(&out[pos], kMagic); @@ -136,7 +137,9 @@ bool encode_batch(const BatchInput &in, uint8_t *out, size_t out_cap, size_t *ou pos += 2; write_u16_le(&out[pos], in.batch_id); pos += 2; - write_u32_le(&out[pos], in.t_last); + write_u32_le(&out[pos], in.meter_t_last); + pos += 4; + write_u32_le(&out[pos], in.ts_utc_last); pos += 4; write_u32_le(&out[pos], in.present_mask); pos += 4; @@ -207,7 +210,7 @@ bool decode_batch(const uint8_t *buf, size_t len, BatchInput *out) { return false; } size_t pos = 0; - if (len < 24) { + if (len < 28) { return false; } uint16_t magic = read_u16_le(&buf[pos]); @@ -221,7 +224,9 @@ bool decode_batch(const uint8_t *buf, size_t len, BatchInput *out) { pos += 2; out->batch_id = read_u16_le(&buf[pos]); pos += 2; - out->t_last = read_u32_le(&buf[pos]); + out->meter_t_last = read_u32_le(&buf[pos]); + pos += 4; + out->ts_utc_last = read_u32_le(&buf[pos]); pos += 4; out->present_mask = read_u32_le(&buf[pos]); pos += 4; @@ -319,7 +324,8 @@ bool payload_codec_self_test() { BatchInput in = {}; in.sender_id = 1; in.batch_id = 42; - in.t_last = 1700000000; + in.meter_t_last = 123456789; + in.ts_utc_last = 1700000000; in.present_mask = (1UL << 0) | (1UL << 2) | (1UL << 3) | (1UL << 10) | (1UL << 29); in.n = 5; in.battery_mV = 3750; @@ -362,7 +368,8 @@ bool payload_codec_self_test() { return false; } - if (out.sender_id != in.sender_id || out.batch_id != in.batch_id || out.t_last != in.t_last || + if (out.sender_id != in.sender_id || out.batch_id != in.batch_id || out.meter_t_last != in.meter_t_last || + out.ts_utc_last != in.ts_utc_last || out.present_mask != in.present_mask || out.n != in.n || out.battery_mV != in.battery_mV || out.err_m != in.err_m || out.err_d != in.err_d || out.err_tx != in.err_tx || out.err_last != in.err_last || out.err_rx_reject != in.err_rx_reject) { diff --git a/platformio.ini b/platformio.ini index e4f69c2..9ba5aea 100644 --- a/platformio.ini +++ b/platformio.ini @@ -50,8 +50,6 @@ build_flags = [env:test] build_flags = ${env.build_flags} - -DSERIAL_DEBUG_MODE_FLAG=1 + -DSERIAL_DEBUG_MODE_FLAG=0 -DENABLE_TEST_MODE -DPAYLOAD_CODEC_TEST - -DDEBUG_METER_DIAG - -DDD3_DEBUG diff --git a/src/main.cpp b/src/main.cpp index e4c354c..ca35efe 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -112,6 +112,14 @@ void setup() { display_set_sender_statuses(g_receiver_shared.sender_statuses, NUM_SENDERS); bool has_cfg = wifi_load_config(g_cfg); + if (CONFIG_FLOW_DEBUG) { + serial_debug_printf("cfg: has=%u ssid=%s mqtt=%s:%u valid=%u", + has_cfg ? 1U : 0U, + g_cfg.ssid.c_str(), + g_cfg.mqtt_host.c_str(), + static_cast(g_cfg.mqtt_port), + g_cfg.valid ? 1U : 0U); + } // Store WiFi config in shared state for later reconnection attempts g_receiver_shared.wifi_config = g_cfg; @@ -142,6 +150,7 @@ void setup() { if (g_cfg.ntp_server_2.isEmpty()) { g_cfg.ntp_server_2 = "time.nist.gov"; } + 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_ap(g_receiver_shared.sender_statuses, NUM_SENDERS); diff --git a/src/mqtt_client.cpp b/src/mqtt_client.cpp index 0a09e6b..0419def 100644 --- a/src/mqtt_client.cpp +++ b/src/mqtt_client.cpp @@ -39,6 +39,12 @@ void mqtt_init(const WifiMqttConfig &config, const char *device_id) { } else { g_client_id = String("dd3-bridge-") + String(random(0xffff), HEX); } + if (CONFIG_FLOW_DEBUG) { + Serial.printf("mqtt_init: host=%s port=%u client=%s\n", + g_cfg.mqtt_host.c_str(), + static_cast(g_cfg.mqtt_port), + g_client_id.c_str()); + } } static bool mqtt_connect() { @@ -46,10 +52,26 @@ static bool mqtt_connect() { return true; } String client_id = g_client_id.length() > 0 ? g_client_id : String("dd3-bridge-") + String(random(0xffff), HEX); + bool connected = false; if (g_cfg.mqtt_user.length() > 0) { - return mqtt_client.connect(client_id.c_str(), g_cfg.mqtt_user.c_str(), g_cfg.mqtt_pass.c_str()); + connected = mqtt_client.connect(client_id.c_str(), g_cfg.mqtt_user.c_str(), g_cfg.mqtt_pass.c_str()); + } else { + connected = mqtt_client.connect(client_id.c_str()); } - return mqtt_client.connect(client_id.c_str()); + if (CONFIG_FLOW_DEBUG) { + if (connected) { + Serial.printf("mqtt: connected host=%s port=%u client=%s\n", + g_cfg.mqtt_host.c_str(), + static_cast(g_cfg.mqtt_port), + client_id.c_str()); + } else { + Serial.printf("mqtt: connect failed host=%s port=%u state=%d\n", + g_cfg.mqtt_host.c_str(), + static_cast(g_cfg.mqtt_port), + mqtt_client.state()); + } + } + return connected; } void mqtt_loop() { diff --git a/src/payload_codec.h b/src/payload_codec.h index 1a60b02..9439f6f 100644 --- a/src/payload_codec.h +++ b/src/payload_codec.h @@ -5,7 +5,8 @@ struct BatchInput { uint16_t sender_id; uint16_t batch_id; - uint32_t t_last; + uint32_t meter_t_last; + uint32_t ts_utc_last; uint32_t present_mask; uint8_t n; uint16_t battery_mV; diff --git a/src/receiver_pipeline.cpp b/src/receiver_pipeline.cpp index 9e3030d..e6f7471 100644 --- a/src/receiver_pipeline.cpp +++ b/src/receiver_pipeline.cpp @@ -308,43 +308,42 @@ static bool process_batch_packet(const LoraPacket &pkt, BatchInput &out_batch, b // to recover from temporary WiFi outages static void try_wifi_reconnect_if_in_ap_mode() { if (!g_ap_mode) { - // Already in STA mode, no need to reconnect return; } - - if (!g_shared || g_shared->wifi_config.ssid.length() == 0) { - // No valid WiFi config to reconnect with + + if (!g_shared || g_shared->wifi_config.ssid.length() == 0 || g_shared->wifi_config.mqtt_host.length() == 0) { return; } - + uint32_t now_ms = millis(); - - if (g_shared->last_wifi_reconnect_attempt_ms == 0 || - now_ms - g_shared->last_wifi_reconnect_attempt_ms >= WIFI_RECONNECT_INTERVAL_MS) { - - // Update the last attempt time - g_shared->last_wifi_reconnect_attempt_ms = now_ms; - + if (g_shared->last_wifi_reconnect_attempt_ms != 0 && + now_ms - g_shared->last_wifi_reconnect_attempt_ms < WIFI_RECONNECT_INTERVAL_MS) { + return; + } + + g_shared->last_wifi_reconnect_attempt_ms = now_ms; + + if (SERIAL_DEBUG_MODE) { + serial_debug_printf("wifi_reconnect: attempting STA reconnect from AP mode"); + } + + if (wifi_try_reconnect_sta(g_shared->wifi_config, 10000)) { + g_ap_mode = false; + time_receiver_init(g_shared->wifi_config.ntp_server_1.c_str(), g_shared->wifi_config.ntp_server_2.c_str()); + mqtt_init(g_shared->wifi_config, g_device_id); + web_server_set_config(g_shared->wifi_config); + web_server_begin_sta(g_sender_statuses, NUM_SENDERS); if (SERIAL_DEBUG_MODE) { - serial_debug_printf("wifi_reconnect: attempting to reconnect from AP mode"); - } - - // Try to reconnect with 10 second timeout - if (wifi_try_reconnect_sta(g_shared->wifi_config, 10000)) { - // Reconnection successful! - g_ap_mode = false; - if (SERIAL_DEBUG_MODE) { - serial_debug_printf("wifi_reconnect: reconnection successful, switching from AP to STA mode"); - } - } else { - // Reconnection failed, restore AP mode to ensure web interface is available - if (g_shared->ap_ssid[0] != '\0') { - wifi_restore_ap_mode(g_shared->ap_ssid, g_shared->ap_password); - if (SERIAL_DEBUG_MODE) { - serial_debug_printf("wifi_reconnect: reconnection failed, restored AP mode"); - } - } + serial_debug_printf("wifi_reconnect: STA reconnect successful"); } + return; + } + + if (g_shared->ap_ssid[0] != '\0') { + wifi_restore_ap_mode(g_shared->ap_ssid, g_shared->ap_password); + } + if (SERIAL_DEBUG_MODE) { + serial_debug_printf("wifi_reconnect: reconnect failed, AP restored"); } } @@ -400,7 +399,7 @@ static void receiver_loop() { } uint32_t duplicate_ts = time_get_utc(); if (duplicate_ts == 0) { - duplicate_ts = batch.t_last; + duplicate_ts = batch.ts_utc_last; } status.rx_last_duplicate_ts_utc = duplicate_ts; } @@ -429,12 +428,13 @@ static void receiver_loop() { 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) { + if (batch.meter_t_last < static_cast(METER_BATCH_MAX_SAMPLES - 1) || batch.ts_utc_last < static_cast(METER_BATCH_MAX_SAMPLES - 1) || batch.ts_utc_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); + const uint32_t meter_window_start = batch.meter_t_last - static_cast(METER_BATCH_MAX_SAMPLES - 1); + const uint32_t ts_utc_window_start = batch.ts_utc_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; @@ -456,7 +456,9 @@ static void receiver_loop() { } else { snprintf(data.device_id, sizeof(data.device_id), "dd3-0000"); } - data.ts_utc = window_start + static_cast(slot); + data.meter_seconds = meter_window_start + static_cast(slot); + data.meter_seconds_valid = true; + data.ts_utc = 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); diff --git a/src/sender_state_machine.cpp b/src/sender_state_machine.cpp index f4a2d82..a5cb1bb 100644 --- a/src/sender_state_machine.cpp +++ b/src/sender_state_machine.cpp @@ -161,7 +161,7 @@ struct MeterSampleEvent { 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 UBaseType_t METER_SAMPLE_QUEUE_LEN = 32; 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; @@ -778,6 +778,15 @@ static uint16_t short_id_from_sender_id(uint16_t sender_id) { return EXPECTED_SENDER_IDS[sender_id - 1]; } +static bool is_expected_receiver_short_id(uint16_t short_id) { + for (uint8_t i = 0; i < NUM_RECEIVERS; ++i) { + if (EXPECTED_RECEIVER_IDS[i] == short_id) { + return true; + } + } + return false; +} + static uint32_t kwh_to_wh_from_float(float value) { if (isnan(value)) { return 0; @@ -1001,7 +1010,9 @@ static bool send_inflight_batch(uint32_t ts_for_display) { 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() : + input.meter_t_last = g_inflight_sync_request ? 0 : + g_inflight_samples[g_inflight_count - 1].meter_seconds; + input.ts_utc_last = g_inflight_sync_request ? time_get_utc() : g_inflight_samples[g_inflight_count - 1].ts_utc; input.present_mask = 0; input.n = 0; @@ -1018,22 +1029,23 @@ static bool send_inflight_batch(uint32_t ts_for_display) { uint8_t ts_collapsed = 0; if (!g_inflight_sync_request) { - if (input.t_last < static_cast(METER_BATCH_MAX_SAMPLES - 1)) { + if (!g_inflight_samples[g_inflight_count - 1].meter_seconds_valid || + input.meter_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); + const uint32_t window_start = input.meter_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 (!sample.meter_seconds_valid || sample.meter_seconds < window_start || sample.meter_seconds > input.meter_t_last) { if (ts_dropped < 255) { ts_dropped++; } continue; } - uint8_t slot = static_cast(sample.ts_utc - window_start); + uint8_t slot = static_cast(sample.meter_seconds - window_start); if (slot_used[slot] && ts_collapsed < 255) { ts_collapsed++; } @@ -1308,7 +1320,11 @@ static void sender_loop() { data.ts_utc = sample_ts_utc; data.valid = has_snapshot; - bool appended = append_meter_sample(data, meter_ok, has_snapshot); + if (!data.meter_seconds_valid) { + data.valid = false; + } + + bool appended = append_meter_sample(data, meter_ok, has_snapshot && data.meter_seconds_valid); (void)appended; display_set_last_meter(data); display_set_last_read(meter_ok, data.ts_utc); @@ -1426,8 +1442,10 @@ static void sender_loop() { ack_id); } } else if (sender_id_from_short_id(ack_pkt.device_id_short) == 0 && - ack_pkt.device_id_short != g_short_id) { - // Reject ACKs from unknown device IDs to prevent spoofing. + ack_pkt.device_id_short != g_short_id && + !is_expected_receiver_short_id(ack_pkt.device_id_short)) { + // Reject ACKs from unknown device IDs to prevent spoofing, but allow + // ACKs from configured receiver short-IDs. sender_note_rx_reject(RxRejectReason::DeviceIdMismatch, "ack"); if (SERIAL_DEBUG_MODE) { serial_debug_printf("ack: reject device_id=%04X (unknown)", @@ -1656,7 +1674,7 @@ SenderStats SenderStateMachine::stats() const { } void SenderStateMachine::handleMeterRead(uint32_t now_ms) { - meter_reader_pump(now_ms); + (void)now_ms; } void SenderStateMachine::maybeSendBatch(uint32_t now_ms) { diff --git a/src/wifi_manager.cpp b/src/wifi_manager.cpp index 830126c..bf1b44f 100644 --- a/src/wifi_manager.cpp +++ b/src/wifi_manager.cpp @@ -127,6 +127,15 @@ bool wifi_connect_sta(const WifiMqttConfig &config, uint32_t timeout_ms) { bool connected = WiFi.status() == WL_CONNECTED; if (connected) { esp_wifi_set_ps(WIFI_PS_MIN_MODEM); + if (CONFIG_FLOW_DEBUG) { + Serial.printf("wifi_sta: connected ssid=%s ip=%s\n", + config.ssid.c_str(), + WiFi.localIP().toString().c_str()); + } + } else if (CONFIG_FLOW_DEBUG) { + Serial.printf("wifi_sta: failed ssid=%s status=%d\n", + config.ssid.c_str(), + static_cast(WiFi.status())); } return connected; } diff --git a/test/test_payload_codec/test_payload_codec.cpp b/test/test_payload_codec/test_payload_codec.cpp index e58a077..85c0e41 100644 --- a/test/test_payload_codec/test_payload_codec.cpp +++ b/test/test_payload_codec/test_payload_codec.cpp @@ -10,7 +10,8 @@ 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.meter_t_last = 123456789; + in.ts_utc_last = 1700000000; in.present_mask = (1UL << 0) | (1UL << 2) | (1UL << 3) | (1UL << 10) | (1UL << 29); in.n = 5; in.battery_mV = 3750; @@ -45,7 +46,8 @@ 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.meter_t_last = 1769904999 - 29; + in.ts_utc_last = 1769904999; in.present_mask = 0x3FFFFFFFUL; in.n = kMaxSamples; in.battery_mV = 4095; @@ -65,7 +67,8 @@ static void fill_full_batch(BatchInput &in) { 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.meter_t_last, actual.meter_t_last); + TEST_ASSERT_EQUAL_UINT32(expected.ts_utc_last, actual.ts_utc_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); @@ -89,14 +92,14 @@ static void assert_batch_equals(const BatchInput &expected, const BatchInput &ac } } -static void test_encode_decode_roundtrip_schema_v3() { +static void test_encode_decode_roundtrip_schema_v4() { 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); + TEST_ASSERT_TRUE(encoded_len > 28); BatchInput out = {}; TEST_ASSERT_TRUE(decode_batch(encoded, encoded_len, &out)); @@ -199,12 +202,22 @@ static void test_encode_rejects_invalid_n_and_regression_cases() { } 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, + 0xB3, 0xDD, 0x04, 0x01, 0x01, 0x00, 0x34, 0x12, + 0x15, 0xCD, 0x5B, 0x07, + 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, + 0xB3, 0xDD, 0x04, 0x01, 0x01, 0x00, 0x2A, 0x00, + 0x15, 0xCD, 0x5B, 0x07, + 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}; @@ -224,7 +237,8 @@ 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.meter_t_last = 123456789; + expected_sync.ts_utc_last = 1769904100; expected_sync.present_mask = 0; expected_sync.n = 0; expected_sync.battery_mV = 3750; @@ -267,7 +281,7 @@ static void test_payload_golden_vectors() { void setup() { dd3_legacy_core_force_link(); UNITY_BEGIN(); - RUN_TEST(test_encode_decode_roundtrip_schema_v3); + RUN_TEST(test_encode_decode_roundtrip_schema_v4); 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); diff --git a/test/test_security_fuzz/test_security_fuzz.cpp b/test/test_security_fuzz/test_security_fuzz.cpp index 3831fdb..c6090a5 100644 --- a/test/test_security_fuzz/test_security_fuzz.cpp +++ b/test/test_security_fuzz/test_security_fuzz.cpp @@ -17,8 +17,8 @@ static void test_decode_batch_null_args() { uint8_t dummy[32] = {}; BatchInput out = {}; - TEST_ASSERT_FALSE(decode_batch(nullptr, 24, &out)); - TEST_ASSERT_FALSE(decode_batch(dummy, 24, nullptr)); + TEST_ASSERT_FALSE(decode_batch(nullptr, 28, &out)); + TEST_ASSERT_FALSE(decode_batch(dummy, 28, nullptr)); TEST_ASSERT_FALSE(decode_batch(nullptr, 0, nullptr)); } @@ -29,90 +29,103 @@ static void test_decode_batch_zero_length() { } static void test_decode_batch_minimal_valid_sync() { - // Sync-only (n=0) payload: 24 bytes header, no samples. - uint8_t buf[24] = {}; + // Sync-only (n=0) payload: 28 bytes header, no samples. + uint8_t buf[28] = {}; // magic 0xDDB3 LE buf[0] = 0xB3; buf[1] = 0xDD; - buf[2] = 3; // schema + buf[2] = 4; // schema buf[3] = 0x01; // flags // sender_id=1 buf[4] = 0x01; buf[5] = 0x00; // batch_id=1 buf[6] = 0x01; buf[7] = 0x00; - // t_last=1769904000 LE - uint32_t t = 1769904000UL; - buf[8] = t & 0xFF; buf[9] = (t >> 8) & 0xFF; - buf[10] = (t >> 16) & 0xFF; buf[11] = (t >> 24) & 0xFF; + // meter_t_last=123456789 LE + uint32_t meter_t = 123456789UL; + buf[8] = meter_t & 0xFF; buf[9] = (meter_t >> 8) & 0xFF; + buf[10] = (meter_t >> 16) & 0xFF; buf[11] = (meter_t >> 24) & 0xFF; + // ts_utc_last=1769904000 LE + uint32_t utc_t = 1769904000UL; + buf[12] = utc_t & 0xFF; buf[13] = (utc_t >> 8) & 0xFF; + buf[14] = (utc_t >> 16) & 0xFF; buf[15] = (utc_t >> 24) & 0xFF; // present_mask=0 - buf[12] = 0; buf[13] = 0; buf[14] = 0; buf[15] = 0; + buf[16] = 0; buf[17] = 0; buf[18] = 0; buf[19] = 0; // n=0 - buf[16] = 0; + buf[20] = 0; // battery_mV=3750 LE - buf[17] = 0xA6; buf[18] = 0x0E; + buf[21] = 0xA6; buf[22] = 0x0E; // err fields - buf[19] = 0; buf[20] = 0; buf[21] = 0; buf[22] = 0; buf[23] = 0; + buf[23] = 0; buf[24] = 0; buf[25] = 0; buf[26] = 0; buf[27] = 0; BatchInput out = {}; - TEST_ASSERT_TRUE(decode_batch(buf, 24, &out)); + TEST_ASSERT_TRUE(decode_batch(buf, 28, &out)); TEST_ASSERT_EQUAL_UINT8(0, out.n); TEST_ASSERT_EQUAL_UINT32(0, out.present_mask); } static void test_decode_batch_n_exceeds_30() { // Forge a header with n=31, which should be rejected. - uint8_t buf[24] = {}; + uint8_t buf[28] = {}; buf[0] = 0xB3; buf[1] = 0xDD; - buf[2] = 3; buf[3] = 0x01; + buf[2] = 4; buf[3] = 0x01; buf[4] = 0x01; buf[5] = 0x00; buf[6] = 0x01; buf[7] = 0x00; - uint32_t t = 1769904000UL; - buf[8] = t & 0xFF; buf[9] = (t >> 8) & 0xFF; - buf[10] = (t >> 16) & 0xFF; buf[11] = (t >> 24) & 0xFF; - buf[12] = 0xFF; buf[13] = 0xFF; buf[14] = 0xFF; buf[15] = 0x3F; // all 30 bits set - buf[16] = 31; // n=31 → must reject - buf[17] = 0xA6; buf[18] = 0x0E; - buf[19] = 0; buf[20] = 0; buf[21] = 0; buf[22] = 0; buf[23] = 0; + uint32_t meter_t = 123456789UL; + buf[8] = meter_t & 0xFF; buf[9] = (meter_t >> 8) & 0xFF; + buf[10] = (meter_t >> 16) & 0xFF; buf[11] = (meter_t >> 24) & 0xFF; + uint32_t utc_t = 1769904000UL; + buf[12] = utc_t & 0xFF; buf[13] = (utc_t >> 8) & 0xFF; + buf[14] = (utc_t >> 16) & 0xFF; buf[15] = (utc_t >> 24) & 0xFF; + buf[16] = 0xFF; buf[17] = 0xFF; buf[18] = 0xFF; buf[19] = 0x3F; // all 30 bits set + buf[20] = 31; // n=31 → must reject + buf[21] = 0xA6; buf[22] = 0x0E; + buf[23] = 0; buf[24] = 0; buf[25] = 0; buf[26] = 0; buf[27] = 0; BatchInput out = {}; - TEST_ASSERT_FALSE(decode_batch(buf, 24, &out)); + TEST_ASSERT_FALSE(decode_batch(buf, 28, &out)); } static void test_decode_batch_present_mask_n_mismatch() { // present_mask has 3 bits but n=5 → must reject. - uint8_t buf[24] = {}; + uint8_t buf[28] = {}; buf[0] = 0xB3; buf[1] = 0xDD; - buf[2] = 3; buf[3] = 0x01; + buf[2] = 4; buf[3] = 0x01; buf[4] = 0x01; buf[5] = 0x00; buf[6] = 0x01; buf[7] = 0x00; - uint32_t t = 1769904000UL; - buf[8] = t & 0xFF; buf[9] = (t >> 8) & 0xFF; - buf[10] = (t >> 16) & 0xFF; buf[11] = (t >> 24) & 0xFF; - buf[12] = 0x07; buf[13] = 0; buf[14] = 0; buf[15] = 0; // 3 bits - buf[16] = 5; // n=5 but only 3 mask bits - buf[17] = 0xA6; buf[18] = 0x0E; - buf[19] = 0; buf[20] = 0; buf[21] = 0; buf[22] = 0; buf[23] = 0; + uint32_t meter_t = 123456789UL; + buf[8] = meter_t & 0xFF; buf[9] = (meter_t >> 8) & 0xFF; + buf[10] = (meter_t >> 16) & 0xFF; buf[11] = (meter_t >> 24) & 0xFF; + uint32_t utc_t = 1769904000UL; + buf[12] = utc_t & 0xFF; buf[13] = (utc_t >> 8) & 0xFF; + buf[14] = (utc_t >> 16) & 0xFF; buf[15] = (utc_t >> 24) & 0xFF; + buf[16] = 0x07; buf[17] = 0; buf[18] = 0; buf[19] = 0; // 3 bits + buf[20] = 5; // n=5 but only 3 mask bits + buf[21] = 0xA6; buf[22] = 0x0E; + buf[23] = 0; buf[24] = 0; buf[25] = 0; buf[26] = 0; buf[27] = 0; BatchInput out = {}; - TEST_ASSERT_FALSE(decode_batch(buf, 24, &out)); + TEST_ASSERT_FALSE(decode_batch(buf, 28, &out)); } static void test_decode_batch_reserved_mask_bits() { // Bit 30 or 31 set → must reject (only bits 0-29 valid). - uint8_t buf[24] = {}; + uint8_t buf[28] = {}; buf[0] = 0xB3; buf[1] = 0xDD; - buf[2] = 3; buf[3] = 0x01; + buf[2] = 4; buf[3] = 0x01; buf[4] = 0x01; buf[5] = 0x00; buf[6] = 0x01; buf[7] = 0x00; - uint32_t t = 1769904000UL; - buf[8] = t & 0xFF; buf[9] = (t >> 8) & 0xFF; - buf[10] = (t >> 16) & 0xFF; buf[11] = (t >> 24) & 0xFF; - buf[12] = 0x01; buf[13] = 0; buf[14] = 0; buf[15] = 0x40; // bit 30 - buf[16] = 1; - buf[17] = 0xA6; buf[18] = 0x0E; - buf[19] = 0; buf[20] = 0; buf[21] = 0; buf[22] = 0; buf[23] = 0; + uint32_t meter_t = 123456789UL; + buf[8] = meter_t & 0xFF; buf[9] = (meter_t >> 8) & 0xFF; + buf[10] = (meter_t >> 16) & 0xFF; buf[11] = (meter_t >> 24) & 0xFF; + uint32_t utc_t = 1769904000UL; + buf[12] = utc_t & 0xFF; buf[13] = (utc_t >> 8) & 0xFF; + buf[14] = (utc_t >> 16) & 0xFF; buf[15] = (utc_t >> 24) & 0xFF; + buf[16] = 0x01; buf[17] = 0; buf[18] = 0; buf[19] = 0x40; // bit 30 + buf[20] = 1; + buf[21] = 0xA6; buf[22] = 0x0E; + buf[23] = 0; buf[24] = 0; buf[25] = 0; buf[26] = 0; buf[27] = 0; BatchInput out = {}; - TEST_ASSERT_FALSE(decode_batch(buf, 24, &out)); + TEST_ASSERT_FALSE(decode_batch(buf, 28, &out)); } // ---- uleb128_decode: negative tests ---- @@ -308,7 +321,8 @@ static void test_decode_batch_byte_flip_fuzz() { BatchInput in = {}; in.sender_id = 1; in.batch_id = 42; - in.t_last = 1769904000UL; + in.meter_t_last = 123456789UL; + in.ts_utc_last = 1769904000UL; in.present_mask = 0x07; // bits 0-2 in.n = 3; in.battery_mV = 3750;