feat(power): 1Hz chunked light-sleep; meter backoff; log throttling

- Replace delay() with light_sleep_chunked_ms() in sender idle path
  (100ms chunks preserve UART FIFO safety at 9600 baud)
- Add ENABLE_LIGHT_SLEEP_IDLE build flag (default: on, fallback: =0)
- Meter reader task: exponential backoff on consecutive poll failures
  (METER_FAIL_BACKOFF_BASE_MS..MAX_MS) to reduce idle Core-0 wakeups
- Configurable SENDER_DIAG_LOG_INTERVAL_MS (5s debug / 30s prod)
- Configurable METER_FRAME_TIMEOUT_CFG_MS, SENDER_CPU_MHZ
- New PlatformIO envs: lowpower, 868-lowpower, lowpower-debug
- Add docs/POWER_OPTIMIZATION.md with measurement plan and Go/No-Go
This commit is contained in:
2026-03-16 16:32:49 +01:00
parent 99aae76404
commit b9591ce9bb
7 changed files with 489 additions and 20 deletions

View File

@@ -6,7 +6,7 @@
// Dedicated reader task pumps UART continuously; keep timeout short so parser can
// recover quickly from broken frames.
static constexpr uint32_t METER_FRAME_TIMEOUT_MS = 3000;
static constexpr uint32_t METER_FRAME_TIMEOUT_MS = METER_FRAME_TIMEOUT_CFG_MS;
static constexpr size_t METER_FRAME_MAX = 512;
enum class MeterRxState : uint8_t {

View File

@@ -9,7 +9,7 @@ static constexpr float BATTERY_DIVIDER = 2.0f;
static constexpr float ADC_REF_V = 3.3f;
void power_sender_init() {
setCpuFrequencyMhz(80);
setCpuFrequencyMhz(SENDER_CPU_MHZ);
WiFi.mode(WIFI_OFF);
esp_wifi_stop();
esp_wifi_deinit();
@@ -117,6 +117,33 @@ void light_sleep_ms(uint32_t ms) {
esp_light_sleep_start();
}
void light_sleep_chunked_ms(uint32_t total_ms, uint32_t chunk_ms) {
if (total_ms == 0) {
return;
}
if (chunk_ms == 0) {
chunk_ms = total_ms;
}
uint32_t start = millis();
for (;;) {
uint32_t elapsed = millis() - start;
if (elapsed >= total_ms) {
break;
}
uint32_t remaining = total_ms - elapsed;
uint32_t this_chunk = remaining > chunk_ms ? chunk_ms : remaining;
if (this_chunk < 10) {
// Light-sleep overhead (~1 ms save/restore) not worthwhile for tiny slices.
delay(this_chunk);
break;
}
light_sleep_ms(this_chunk);
// After wake the FreeRTOS scheduler runs higher-priority tasks (e.g. the
// meter_reader_task on Core 0) before returning here, so the UART HW FIFO
// is drained automatically between chunks.
}
}
void go_to_deep_sleep(uint32_t seconds) {
esp_sleep_enable_timer_wakeup(static_cast<uint64_t>(seconds) * 1000000ULL);
esp_deep_sleep_start();

View File

@@ -203,7 +203,7 @@ static void sender_log_diagnostics(uint32_t now_ms) {
if (!SERIAL_DEBUG_MODE) {
return;
}
if (now_ms - g_last_debug_log_ms < 5000) {
if (now_ms - g_last_debug_log_ms < SENDER_DIAG_LOG_INTERVAL_MS) {
return;
}
g_last_debug_log_ms = now_ms;
@@ -246,6 +246,18 @@ static void sender_log_diagnostics(uint32_t now_ms) {
static_cast<unsigned long>(meter_age_ms),
static_cast<unsigned long>(g_sender_rx_window_ms),
static_cast<unsigned long>(g_sender_sleep_ms));
#ifdef DEBUG_METER_DIAG
serial_debug_printf(
"meter_diag: err_m=%lu err_d=%lu err_tx=%lu build_att=%lu build_ok=%lu build_fail=%lu stale_s=%lu",
static_cast<unsigned long>(g_sender_faults.meter_read_fail),
static_cast<unsigned long>(g_sender_faults.decode_fail),
static_cast<unsigned long>(g_sender_faults.lora_tx_fail),
static_cast<unsigned long>(g_build_attempts),
static_cast<unsigned long>(g_build_valid),
static_cast<unsigned long>(g_build_invalid),
static_cast<unsigned long>(g_meter_stale_seconds));
#endif
}
static void invalidate_inflight_encode_cache() {
@@ -398,6 +410,7 @@ static void meter_queue_push_latest(const MeterSampleEvent &event) {
static void meter_reader_task_entry(void *arg) {
(void)arg;
uint32_t consecutive_fails = 0;
for (;;) {
#ifdef ENABLE_TEST_MODE
MeterData test_sample = {};
@@ -410,16 +423,33 @@ static void meter_reader_task_entry(void *arg) {
event.data = test_sample;
event.rx_ms = now_ms;
meter_queue_push_latest(event);
consecutive_fails = 0;
continue;
#endif
const char *frame = nullptr;
size_t frame_len = 0;
if (!meter_poll_frame(frame, frame_len)) {
vTaskDelay(pdMS_TO_TICKS(5));
// Exponential backoff: 5→10→20→…→METER_FAIL_BACKOFF_MAX_MS on consecutive
// poll misses. Reduces CPU wake-ups when the meter is unresponsive.
uint32_t backoff_ms = METER_FAIL_BACKOFF_BASE_MS;
if (consecutive_fails < 16) {
backoff_ms = METER_FAIL_BACKOFF_BASE_MS << consecutive_fails;
}
if (backoff_ms < 5) {
backoff_ms = 5;
}
if (backoff_ms > METER_FAIL_BACKOFF_MAX_MS) {
backoff_ms = METER_FAIL_BACKOFF_MAX_MS;
}
vTaskDelay(pdMS_TO_TICKS(backoff_ms));
if (consecutive_fails < UINT32_MAX) {
consecutive_fails++;
}
continue;
}
consecutive_fails = 0;
MeterData parsed = {};
if (parse_meter_frame_sample(frame, frame_len, parsed)) {
MeterSampleEvent event = {};
@@ -1194,6 +1224,42 @@ static void sender_loop() {
if (g_time_acquired) {
sender_reset_fault_stats_on_hour_boundary();
// Evaluate meter staleness once per loop iteration, not per catch-up tick.
// This prevents LoRa TX blocking (seconds) from inflating the fault counter
// by N missed ticks when the same stale-data condition persists throughout.
uint32_t meter_age_ms = g_last_meter_valid ? (now_ms - g_last_meter_rx_ms) : UINT32_MAX;
bool has_snapshot = g_last_meter_valid;
bool meter_ok = has_snapshot && meter_age_ms <= METER_SAMPLE_MAX_AGE_MS;
bool meter_fault_noted = false;
// Count one time-jump fault per event, outside the catch-up loop.
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);
meter_fault_noted = true;
}
// Count one stale-meter fault per contiguous stale period, not per tick.
if (!meter_ok && !meter_fault_noted) {
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);
}
#ifdef DEBUG_METER_DIAG
{
uint32_t pending_ticks = (now_ms - g_last_sample_ms) / METER_SAMPLE_INTERVAL_MS;
if (pending_ticks > 1) {
serial_debug_printf("meter_diag: catchup ticks=%lu age_ms=%lu ok=%u snap=%u",
static_cast<unsigned long>(pending_ticks),
static_cast<unsigned long>(meter_age_ms),
meter_ok ? 1U : 0U,
has_snapshot ? 1U : 0U);
}
}
#endif
while (now_ms - g_last_sample_ms >= METER_SAMPLE_INTERVAL_MS) {
g_last_sample_ms += METER_SAMPLE_INTERVAL_MS;
MeterData data = {};
@@ -1206,10 +1272,6 @@ static void sender_loop() {
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;
@@ -1222,15 +1284,6 @@ static void sender_loop() {
} 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();
}
@@ -1458,15 +1511,20 @@ static void sender_loop() {
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) {
if (now_ms - g_sender_power_log_ms >= SENDER_DIAG_LOG_INTERVAL_MS) {
g_sender_power_log_ms = now_ms;
serial_debug_printf("power: rx_ms=%lu sleep_ms=%lu", static_cast<unsigned long>(g_sender_rx_window_ms),
static_cast<unsigned long>(g_sender_sleep_ms));
}
}
lora_sleep();
if (g_time_acquired) {
// Keep the meter reader task running while metering is active.
if (LIGHT_SLEEP_IDLE) {
// Chunked light-sleep: wake every LIGHT_SLEEP_CHUNK_MS so the
// meter_reader_task (Core 0, prio 2) can drain the 128-byte UART HW FIFO
// before it overflows (~133 ms at 9600 baud). Saves ~25 mA vs delay().
light_sleep_chunked_ms(idle_ms, LIGHT_SLEEP_CHUNK_MS);
} else if (g_time_acquired) {
// Fallback: keep meter reader task alive with an active wait.
delay(idle_ms);
} else {
light_sleep_ms(idle_ms);