From f7a2503d7a1a587c9131433dbfc37390359ca55a Mon Sep 17 00:00:00 2001 From: acidburns Date: Tue, 3 Feb 2026 23:40:11 +0100 Subject: [PATCH] Add timesync burst handling and sender-only timeout - Add TimeSync fault code and labels in UI/SD/web docs - Trigger receiver beacon bursts on sender drift, but keep errors sender-local - Sender flags TimeSync only after TIME_SYNC_ERROR_TIMEOUT_MS --- README.md | 9 +++-- include/config.h | 4 +++ include/data_model.h | 3 +- src/display_ui.cpp | 2 ++ src/main.cpp | 80 ++++++++++++++++++++++++++++++++++++++++---- src/sd_logger.cpp | 2 ++ src/web_server.cpp | 2 +- 7 files changed, 92 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 0ecf656..b5fed95 100644 --- a/README.md +++ b/README.md @@ -220,7 +220,7 @@ Fixed header (little-endian): - `err_m` u8 (meter read failures, sender-side counter) - `err_d` u8 (decode failures, sender-side counter) - `err_tx` u8 (LoRa TX failures, sender-side counter) -- `err_last` u8 (last error code: 0=None, 1=MeterRead, 2=Decode, 3=LoraTx) +- `err_last` u8 (last error code: 0=None, 1=MeterRead, 2=Decode, 3=LoraTx, 4=TimeSync) Body: - `E0` u32 (absolute energy in Wh) @@ -303,6 +303,8 @@ inline constexpr uint16_t EXPECTED_SENDER_IDS[NUM_SENDERS] = { 0xF19C }; - Receiver time sync packets set the RTC. - On boot, if no LoRa time sync has arrived yet, the sender uses the RTC time as the initial `ts_utc`. - Receiver keeps sending time sync every 60 seconds. +- If a sender’s timestamps drift from receiver time by more than `TIME_SYNC_DRIFT_THRESHOLD_SEC`, the receiver enters a burst mode (every `TIME_SYNC_BURST_INTERVAL_MS` for `TIME_SYNC_BURST_DURATION_MS`). +- Sender raises a local `TimeSync` error if it has not received a time beacon for `TIME_SYNC_ERROR_TIMEOUT_MS` (default 2 days). This is shown on the sender OLED only and is not sent over LoRa. ## Build Environments - `lilygo-t3-v1-6-1`: production build (debug on) @@ -321,7 +323,7 @@ Key timing settings in `include/config.h`: - `BATTERY_SAMPLE_INTERVAL_MS` - `BATTERY_CAL` - `BATCH_ACK_TIMEOUT_MS` -- `BATCH_MAX_RETRIES` + - `BATCH_MAX_RETRIES` - `BATCH_QUEUE_DEPTH` - `BATCH_RETRY_POLICY` (keep or drop on retry exhaustion) - `SERIAL_DEBUG_MODE_FLAG` (build flag) / `SERIAL_DEBUG_DUMP_JSON` @@ -329,6 +331,9 @@ Key timing settings in `include/config.h`: - `ENABLE_SD_LOGGING` / `PIN_SD_CS` - `SENDER_TIMESYNC_WINDOW_MS` - `SENDER_TIMESYNC_CHECK_SEC_FAST` / `SENDER_TIMESYNC_CHECK_SEC_SLOW` + - `TIME_SYNC_DRIFT_THRESHOLD_SEC` + - `TIME_SYNC_BURST_INTERVAL_MS` / `TIME_SYNC_BURST_DURATION_MS` + - `TIME_SYNC_ERROR_TIMEOUT_MS` - `SD_HISTORY_MAX_DAYS` / `SD_HISTORY_MIN_RES_MIN` - `SD_HISTORY_MAX_BINS` / `SD_HISTORY_TIME_BUDGET_MS` - `WEB_AUTH_REQUIRE_STA` / `WEB_AUTH_REQUIRE_AP` / `WEB_AUTH_DEFAULT_USER` / `WEB_AUTH_DEFAULT_PASS` diff --git a/include/config.h b/include/config.h index 76cb9cd..bdaefcc 100644 --- a/include/config.h +++ b/include/config.h @@ -63,6 +63,10 @@ constexpr uint32_t TIME_SYNC_FAST_WINDOW_MS = 10UL * 60UL * 1000UL; constexpr uint32_t SENDER_TIMESYNC_WINDOW_MS = 300; constexpr uint32_t SENDER_TIMESYNC_CHECK_SEC_FAST = 60; constexpr uint32_t SENDER_TIMESYNC_CHECK_SEC_SLOW = 3600; +constexpr uint32_t TIME_SYNC_DRIFT_THRESHOLD_SEC = 10; +constexpr uint32_t TIME_SYNC_BURST_INTERVAL_MS = 10000; +constexpr uint32_t TIME_SYNC_BURST_DURATION_MS = 10UL * 60UL * 1000UL; +constexpr uint32_t TIME_SYNC_ERROR_TIMEOUT_MS = 2UL * 24UL * 60UL * 60UL * 1000UL; constexpr bool ENABLE_DS3231 = true; constexpr uint32_t OLED_PAGE_INTERVAL_MS = 4000; constexpr uint32_t OLED_AUTO_OFF_MS = 10UL * 60UL * 1000UL; diff --git a/include/data_model.h b/include/data_model.h index 94ed471..1871d67 100644 --- a/include/data_model.h +++ b/include/data_model.h @@ -6,7 +6,8 @@ enum class FaultType : uint8_t { None = 0, MeterRead = 1, Decode = 2, - LoraTx = 3 + LoraTx = 3, + TimeSync = 4 }; struct FaultCounters { diff --git a/src/display_ui.cpp b/src/display_ui.cpp index 4f1032a..c276d46 100644 --- a/src/display_ui.cpp +++ b/src/display_ui.cpp @@ -172,6 +172,8 @@ static bool render_last_error_line(uint8_t y) { label = "decode"; } else if (g_last_error == FaultType::LoraTx) { label = "lora"; + } else if (g_last_error == FaultType::TimeSync) { + label = "timesync"; } display.setCursor(0, y); display.printf("Err: %s %lus", label, static_cast(age_seconds(g_last_error_ts, g_last_error_ms))); diff --git a/src/main.cpp b/src/main.cpp index 34c4ac6..991fa9e 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -50,6 +50,18 @@ 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; +struct TimeSyncBurstState { + bool active; + uint32_t start_ms; + uint32_t last_send_ms; + uint32_t last_drift_check_ms; + bool last_drift_ok; +}; + +static TimeSyncBurstState g_timesync_burst[NUM_SENDERS] = {}; +static uint32_t g_sender_last_timesync_rx_ms = 0; +static bool g_sender_timesync_error = 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; @@ -190,6 +202,27 @@ static bool batch_queue_drop_oldest() { return dropped_inflight; } +static void receiver_note_timesync_drift(uint8_t sender_idx, uint32_t sender_ts_utc) { + if (sender_idx >= NUM_SENDERS) { + return; + } + if (!time_is_synced() || sender_ts_utc == 0) { + return; + } + uint32_t now_utc = time_get_utc(); + uint32_t diff = now_utc > sender_ts_utc ? now_utc - sender_ts_utc : sender_ts_utc - now_utc; + TimeSyncBurstState &state = g_timesync_burst[sender_idx]; + state.last_drift_check_ms = millis(); + state.last_drift_ok = diff <= TIME_SYNC_DRIFT_THRESHOLD_SEC; + if (!state.last_drift_ok) { + if (!state.active) { + state.active = true; + state.start_ms = millis(); + state.last_send_ms = 0; + } + } +} + static BatchBuffer *batch_queue_peek() { if (g_batch_count == 0) { return nullptr; @@ -802,9 +835,21 @@ static void sender_loop() { g_sender_rx_window_ms += rx_elapsed; } if (got && rx.payload_type == PayloadType::TimeSync) { - time_handle_timesync_payload(rx.payload, rx.payload_len); + if (time_handle_timesync_payload(rx.payload, rx.payload_len)) { + g_sender_last_timesync_rx_ms = now_ms; + if (g_sender_timesync_error) { + g_sender_timesync_error = false; + display_set_last_error(FaultType::None, 0, 0); + } + } } } + uint32_t timesync_age_ms = (g_sender_last_timesync_rx_ms > 0) ? (now_ms - g_sender_last_timesync_rx_ms) + : (now_ms - g_boot_ms); + if (!g_sender_timesync_error && timesync_age_ms > TIME_SYNC_ERROR_TIMEOUT_MS) { + g_sender_timesync_error = true; + display_set_last_error(FaultType::TimeSync, time_get_utc(), now_ms); + } if (!g_batch_ack_pending) { lora_sleep(); } @@ -885,6 +930,7 @@ static void receiver_loop() { g_sender_statuses[i].has_data = true; g_sender_faults_remote[i].meter_read_fail = data.err_meter_read; g_sender_faults_remote[i].lora_tx_fail = data.err_lora_tx; + receiver_note_timesync_drift(i, data.ts_utc); g_sender_last_error_remote[i] = data.last_error; g_sender_last_error_remote_utc[i] = time_get_utc(); g_sender_last_error_remote_ms[i] = millis(); @@ -983,6 +1029,7 @@ static void receiver_loop() { 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; + receiver_note_timesync_drift(static_cast(sender_idx), samples[count - 1].ts_utc); 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(); @@ -1005,11 +1052,32 @@ static void receiver_loop() { } uint32_t interval_sec = TIME_SYNC_INTERVAL_SEC; - if (!g_ap_mode && millis() - g_last_timesync_ms > interval_sec * 1000UL) { - g_last_timesync_ms = millis(); - if (!time_send_timesync(g_short_id)) { - note_fault(g_receiver_faults, g_receiver_last_error, g_receiver_last_error_utc, g_receiver_last_error_ms, FaultType::LoraTx); - display_set_last_error(g_receiver_last_error, g_receiver_last_error_utc, g_receiver_last_error_ms); + uint32_t now_ms = millis(); + if (!g_ap_mode) { + bool burst_sent = false; + for (uint8_t i = 0; i < NUM_SENDERS; ++i) { + TimeSyncBurstState &state = g_timesync_burst[i]; + if (state.active) { + if (now_ms - state.start_ms >= TIME_SYNC_BURST_DURATION_MS) { + state.active = false; + } else if (state.last_send_ms == 0 || now_ms - state.last_send_ms >= TIME_SYNC_BURST_INTERVAL_MS) { + state.last_send_ms = now_ms; + burst_sent = true; + } + } + } + if (burst_sent) { + if (!time_send_timesync(g_short_id)) { + note_fault(g_receiver_faults, g_receiver_last_error, g_receiver_last_error_utc, g_receiver_last_error_ms, FaultType::LoraTx); + display_set_last_error(g_receiver_last_error, g_receiver_last_error_utc, g_receiver_last_error_ms); + } + g_last_timesync_ms = now_ms; + } else if (now_ms - g_last_timesync_ms > interval_sec * 1000UL) { + g_last_timesync_ms = now_ms; + if (!time_send_timesync(g_short_id)) { + note_fault(g_receiver_faults, g_receiver_last_error, g_receiver_last_error_utc, g_receiver_last_error_ms, FaultType::LoraTx); + display_set_last_error(g_receiver_last_error, g_receiver_last_error_utc, g_receiver_last_error_ms); + } } } diff --git a/src/sd_logger.cpp b/src/sd_logger.cpp index 085e2b4..915744a 100644 --- a/src/sd_logger.cpp +++ b/src/sd_logger.cpp @@ -15,6 +15,8 @@ static const char *fault_text(FaultType fault) { return "decode"; case FaultType::LoraTx: return "loratx"; + case FaultType::TimeSync: + return "timesync"; default: return ""; } diff --git a/src/web_server.cpp b/src/web_server.cpp index e457a4e..efc3217 100644 --- a/src/web_server.cpp +++ b/src/web_server.cpp @@ -613,7 +613,7 @@ static void handle_manual() { html += "
  • Battery: percent with voltage in V.
  • "; html += "
  • RSSI/SNR: LoRa link quality from last packet.
  • "; html += "
  • err_tx: sender-side LoRa TX error counter.
  • "; - html += "
  • err_last: last error code (0=None, 1=MeterRead, 2=Decode, 3=LoraTx).
  • "; + html += "
  • err_last: last error code (0=None, 1=MeterRead, 2=Decode, 3=LoraTx, 4=TimeSync).
  • "; html += "
  • faults m/d/tx: receiver-side counters (meter read fails, decode fails, LoRa TX fails).
  • "; html += "
  • faults last: last receiver-side error code (same mapping as err_last).
  • "; html += "";