diff --git a/src/main.cpp b/src/main.cpp index bb9bd64..b3cc989 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -27,6 +27,7 @@ static SenderStatus g_sender_statuses[NUM_SENDERS]; static bool g_ap_mode = false; static WifiMqttConfig g_cfg; static uint32_t g_boot_ms = 0; +static bool g_debug_forced_reboot_done = false; static FaultCounters g_sender_faults = {}; static FaultCounters g_receiver_faults = {}; static FaultCounters g_receiver_faults_published = {}; @@ -105,8 +106,17 @@ 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 DEBUG_FORCED_REBOOT_INTERVAL_MS = 3UL * 60UL * 60UL * 1000UL; + +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 serial_debug_printf(const char *fmt, ...) { if (!SERIAL_DEBUG_MODE) { @@ -391,6 +401,23 @@ static bool float_to_i16_w(float value, int16_t &out) { 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; @@ -536,6 +563,7 @@ static bool prepare_inflight_from_queue() { } static bool send_inflight_batch(uint32_t ts_for_display) { + g_last_tx_build_error = TxBuildError::None; if (!g_inflight_active) { return false; } @@ -554,19 +582,44 @@ static bool send_inflight_batch(uint32_t ts_for_display) { 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; for (uint8_t i = 0; i < input.n; ++i) { input.energy_wh[i] = kwh_to_wh_from_float(g_inflight_samples[i].energy_total_kwh); - if (!float_to_i16_w(g_inflight_samples[i].phase_power_w[0], input.p1_w[i]) || - !float_to_i16_w(g_inflight_samples[i].phase_power_w[1], input.p2_w[i]) || - !float_to_i16_w(g_inflight_samples[i].phase_power_w[2], input.p3_w[i])) { - return false; + 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++; + } } + bool c1 = false; + bool c2 = false; + bool c3 = false; + input.p1_w[i] = float_to_i16_w_clamped(g_inflight_samples[i].phase_power_w[0], c1); + input.p2_w[i] = float_to_i16_w_clamped(g_inflight_samples[i].phase_power_w[1], c2); + input.p3_w[i] = float_to_i16_w_clamped(g_inflight_samples[i].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++; + } + } + if (SERIAL_DEBUG_MODE && (energy_regressions > 0 || phase_clamps > 0)) { + serial_debug_printf("tx: sanitize batch_id=%u energy_regress=%u phase_clamps=%u", + g_inflight_batch_id, + static_cast(energy_regressions), + static_cast(phase_clamps)); } 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; @@ -612,6 +665,13 @@ static bool send_meter_batch(uint32_t ts_for_display) { 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; @@ -1193,6 +1253,16 @@ receiver_loop_done: } void loop() { + if (SERIAL_DEBUG_MODE && !g_debug_forced_reboot_done && + (millis() - g_boot_ms >= DEBUG_FORCED_REBOOT_INTERVAL_MS)) { + g_debug_forced_reboot_done = true; + serial_debug_printf("debug: force reboot after %lu ms uptime", + static_cast(millis() - g_boot_ms)); +#ifdef ARDUINO_ARCH_ESP32 + delay(50); + esp_restart(); +#endif + } #ifdef ENABLE_TEST_MODE if (g_role == DeviceRole::Sender) { test_sender_loop(g_short_id, g_device_id); diff --git a/src/web_server.cpp b/src/web_server.cpp index 8e37401..d702a47 100644 --- a/src/web_server.cpp +++ b/src/web_server.cpp @@ -57,6 +57,32 @@ static HistoryJob g_history = {}; static constexpr size_t SD_LIST_MAX_FILES = 200; static constexpr size_t SD_DOWNLOAD_MAX_PATH = 160; +static String format_utc_timestamp(uint32_t ts_utc) { + if (ts_utc == 0) { + return "n/a"; + } + time_t t = static_cast(ts_utc); + struct tm tm_utc; + gmtime_r(&t, &tm_utc); + char buf[32]; + snprintf(buf, sizeof(buf), "%04d-%02d-%02d %02d:%02d:%02d UTC", + tm_utc.tm_year + 1900, + tm_utc.tm_mon + 1, + tm_utc.tm_mday, + tm_utc.tm_hour, + tm_utc.tm_min, + tm_utc.tm_sec); + return String(buf); +} + +static uint32_t timestamp_age_seconds(uint32_t ts_utc) { + uint32_t now_utc = time_get_utc(); + if (ts_utc == 0 || now_utc < ts_utc) { + return 0; + } + return now_utc - ts_utc; +} + static int32_t round_power_w(float value) { if (isnan(value)) { return 0; @@ -374,6 +400,11 @@ static String render_sender_block(const SenderStatus &status) { if (!status.has_data) { s += "No data"; } else { + s += "Last update: " + format_utc_timestamp(status.last_update_ts_utc); + if (time_is_synced()) { + s += " (" + String(timestamp_age_seconds(status.last_update_ts_utc)) + "s ago)"; + } + s += "
"; s += "Energy: " + String(status.last_data.energy_total_kwh, 2) + " kWh
"; s += "Power: " + String(round_power_w(status.last_data.total_power_w)) + " W
"; s += "P1/P2/P3: " + String(round_power_w(status.last_data.phase_power_w[0])) + " / " +