From 2584e237f62064804854117ecf3700d17a553cb2 Mon Sep 17 00:00:00 2001 From: liquidraver <504870+liquidraver@users.noreply.github.com> Date: Fri, 6 Mar 2026 22:15:13 +0100 Subject: [PATCH] led switcher page in ui rx duty cycle + noise floor measurement marriage ui fixes --- zephcore/Kconfig | 23 +-- zephcore/adapters/ble/ZephyrBLE.cpp | 81 +------- .../adapters/datastore/ZephyrDataStore.cpp | 11 +- zephcore/adapters/radio/LoRaRadioBase.cpp | 178 ++++++++++-------- zephcore/helpers/BaseChatMesh.cpp | 1 + zephcore/helpers/NodePrefs.h | 1 + zephcore/helpers/ui/doom_game.c | 17 +- zephcore/helpers/ui/ui_mesh_actions.cpp | 17 ++ zephcore/helpers/ui/ui_mesh_actions.h | 1 + zephcore/helpers/ui/ui_mesh_actions_stubs.c | 1 + zephcore/helpers/ui/ui_pages.c | 16 ++ zephcore/helpers/ui/ui_pages.h | 4 + zephcore/helpers/ui/ui_task.c | 40 ++++ zephcore/helpers/ui/ui_task.h | 10 + zephcore/src/main_companion.cpp | 7 + 15 files changed, 229 insertions(+), 179 deletions(-) diff --git a/zephcore/Kconfig b/zephcore/Kconfig index 0c66115..a07a58a 100644 --- a/zephcore/Kconfig +++ b/zephcore/Kconfig @@ -192,29 +192,14 @@ config ZEPHCORE_BLE_CONN_TIMEOUT Supervision timeout in 10ms units. 500 = 5000ms. Connection drops if no data for this duration. -config ZEPHCORE_BLE_ADV_FAST_INTERVAL - int "BLE fast advertising interval (units: 0.625ms)" - default 32 - range 20 1600 - help - Fast advertising interval in 0.625ms units. - 32 = 20ms (Apple-recommended for quick discovery). - config ZEPHCORE_BLE_ADV_SLOW_INTERVAL - int "BLE slow advertising interval (units: 0.625ms)" + int "BLE advertising interval (units: 0.625ms)" default 338 range 244 3200 help - Slow advertising interval in 0.625ms units. + Advertising interval in 0.625ms units. 338 = 211.25ms (Apple-compliant, balanced discovery/battery). -config ZEPHCORE_BLE_ADV_FAST_TIMEOUT - int "Duration of fast advertising mode (seconds)" - default 30 - range 10 300 - help - How long to advertise at the fast rate before switching to slow. - endmenu # BLE Configuration endif # ZEPHCORE_ROLE_COMPANION @@ -441,9 +426,11 @@ config ZEPHCORE_UI_BUZZER Enable PWM buzzer with RTTTL melody support. Requires a PWM-capable pin connected to a buzzer. +DT_CHOSEN_ZEPHCORE_DISPLAY := zephyr,display + config ZEPHCORE_UI_DISPLAY bool "Enable display (OLED, LCD, e-ink)" - default y if $(dt_chosen_enabled,zephyr/display) + default y if $(dt_chosen_enabled,$(DT_CHOSEN_ZEPHCORE_DISPLAY)) select DISPLAY select CHARACTER_FRAMEBUFFER help diff --git a/zephcore/adapters/ble/ZephyrBLE.cpp b/zephcore/adapters/ble/ZephyrBLE.cpp index 608fb2f..d95ec76 100644 --- a/zephcore/adapters/ble/ZephyrBLE.cpp +++ b/zephcore/adapters/ble/ZephyrBLE.cpp @@ -48,10 +48,8 @@ LOG_MODULE_REGISTER(zephcore_ble, CONFIG_ZEPHCORE_BLE_LOG_LEVEL); * BLE stack when the link is marginal, fast enough to recover quickly. */ #define BLE_TX_OVERFLOW_RETRY_MS 250 -/* Advertising intervals (Apple Accessory Design Guidelines) */ -#define BT_ADV_INTERVAL_FAST CONFIG_ZEPHCORE_BLE_ADV_FAST_INTERVAL -#define BT_ADV_INTERVAL_SLOW CONFIG_ZEPHCORE_BLE_ADV_SLOW_INTERVAL -#define BT_ADV_FAST_TIMEOUT_SEC CONFIG_ZEPHCORE_BLE_ADV_FAST_TIMEOUT +/* Advertising interval (Apple Accessory Design Guidelines) */ +#define BT_ADV_INTERVAL CONFIG_ZEPHCORE_BLE_ADV_SLOW_INTERVAL /* ========== Frame type for queues ========== */ @@ -118,10 +116,6 @@ static enum zephcore_iface active_iface = ZEPHCORE_IFACE_NONE; /* DLE tracking — set after successful DLE request to avoid double-request */ static bool dle_requested; -/* Advertising state */ -static bool adv_switching = false; -static bool adv_is_slow = false; -static bool adv_post_disconnect = false; /* skip fast advert after disconnect */ /* Runtime BLE passkey */ static uint32_t ble_passkey = CONFIG_ZEPHCORE_BLE_PASSKEY; @@ -176,11 +170,9 @@ STRUCT_SECTION_ITERABLE(bt_nus_inst, secure_nus) = { /* ========== Work items ========== */ static void tx_drain_work_fn(struct k_work *work); -static void adv_slow_work_fn(struct k_work *work); static void overflow_retry_work_fn(struct k_work *work); K_WORK_DELAYABLE_DEFINE(tx_drain_work, tx_drain_work_fn); -K_WORK_DELAYABLE_DEFINE(adv_slow_work, adv_slow_work_fn); K_WORK_DELAYABLE_DEFINE(overflow_retry_work, overflow_retry_work_fn); /* ========== TX completion callback ========== */ @@ -270,10 +262,6 @@ static void connected(struct bt_conn *conn, uint8_t err) LOG_INF("connected: %s", addr); current_conn = bt_conn_ref(conn); - /* Cancel slow advertising work - we're connected now */ - k_work_cancel_delayable(&adv_slow_work); - adv_is_slow = false; - /* DLE is NOT requested here — the phone may start a PHY update LL * procedure immediately, and BLE allows only one at a time. * DLE is deferred to le_phy_updated() (after PHY negotiation completes) @@ -341,34 +329,14 @@ static void disconnected(struct bt_conn *conn, uint8_t reason) k_work_cancel_delayable(&tx_drain_work); k_work_cancel_delayable(&overflow_retry_work); - /* Skip fast advertising on reconnect — go straight to slow. - * Prevents tight reconnect flapping loops on flaky links. */ - adv_post_disconnect = true; - /* Notify main of BLE disconnection */ if (ble_cbs && ble_cbs->on_disconnected) { ble_cbs->on_disconnected(); } } -/* Flag to suppress recycled() callback during adv mode switch */ static void recycled(void) { - if (adv_switching || adv_is_slow) { - LOG_DBG("suppressed (switching=%d slow=%d)", - adv_switching, adv_is_slow); - return; - } - - if (adv_post_disconnect) { - /* After disconnect, skip fast advertising — go straight to slow. - * Prevents rapid reconnect flapping on flaky BLE links. */ - adv_post_disconnect = false; - LOG_INF("post-disconnect: starting slow advertising directly"); - adv_slow_work_fn(NULL); - return; - } - LOG_DBG("restart advertising"); start_adv(); } @@ -784,53 +752,18 @@ static ssize_t secure_nus_rx_write(struct bt_conn *conn, const struct bt_gatt_at static void start_adv(void) { - /* Start with fast 20ms advertising for quick discovery */ struct bt_le_adv_param adv_param = { .id = BT_ID_DEFAULT, .options = BT_LE_ADV_OPT_CONN, - .interval_min = BT_ADV_INTERVAL_FAST, - .interval_max = BT_ADV_INTERVAL_FAST, + .interval_min = BT_ADV_INTERVAL, + .interval_max = BT_ADV_INTERVAL, }; - adv_is_slow = false; int err = bt_le_adv_start(&adv_param, ad, ad_len, sd, sd_len); if (err && err != -EALREADY) { LOG_ERR("adv start failed: %d", err); } else { - LOG_INF("BLE advertising: fast mode (20ms) for %ds", BT_ADV_FAST_TIMEOUT_SEC); - /* Schedule switch to slow mode after timeout */ - k_work_schedule(&adv_slow_work, K_SECONDS(BT_ADV_FAST_TIMEOUT_SEC)); - } -} - -static void adv_slow_work_fn(struct k_work *work) -{ - ARG_UNUSED(work); - - /* Only switch if not connected */ - if (current_conn) { - return; - } - - /* Stop current advertising and restart with slow interval. - * Set flag to prevent recycled() from calling start_adv() again. */ - adv_switching = true; - bt_le_adv_stop(); - - struct bt_le_adv_param adv_param = { - .id = BT_ID_DEFAULT, - .options = BT_LE_ADV_OPT_CONN, - .interval_min = BT_ADV_INTERVAL_SLOW, - .interval_max = BT_ADV_INTERVAL_SLOW, - }; - - int err = bt_le_adv_start(&adv_param, ad, ad_len, sd, sd_len); - adv_switching = false; - if (err && err != -EALREADY) { - LOG_ERR("slow adv start failed: %d", err); - } else { - adv_is_slow = true; - LOG_INF("BLE advertising: slow mode (546ms)"); + LOG_INF("BLE advertising: 211ms"); } } @@ -933,11 +866,7 @@ void zephcore_ble_set_enabled(bool enable) BT_HCI_ERR_REMOTE_USER_TERM_CONN); } /* Stop advertising */ - adv_switching = true; bt_le_adv_stop(); - adv_switching = false; - adv_is_slow = false; - k_work_cancel_delayable(&adv_slow_work); LOG_INF("BLE disabled"); } else { /* Re-enable advertising */ diff --git a/zephcore/adapters/datastore/ZephyrDataStore.cpp b/zephcore/adapters/datastore/ZephyrDataStore.cpp index 300ea71..cc78e04 100644 --- a/zephcore/adapters/datastore/ZephyrDataStore.cpp +++ b/zephcore/adapters/datastore/ZephyrDataStore.cpp @@ -405,6 +405,13 @@ void ZephyrDataStore::loadPrefs(NodePrefs &prefs) } else { prefs.rx_boost = 1; /* Default to boosted for better sensitivity */ } + + /* Offset 93: leds_disabled (ZephCore extension) */ + if (off < len) { + prefs.leds_disabled = buf[off++]; + } else { + prefs.leds_disabled = 0; /* Default: LEDs on */ + } } void ZephyrDataStore::savePrefs(const NodePrefs &prefs) @@ -453,7 +460,9 @@ void ZephyrDataStore::savePrefs(const NodePrefs &prefs) buf[off++] = prefs.autoadd_max_hops; /* Offset 92: rx_boost (ZephCore extension — Arduino ignores) */ buf[off++] = prefs.rx_boost; - /* Total: 93 bytes (Arduino reads 92, ZephCore reads 93) */ + /* Offset 93: leds_disabled (ZephCore extension) */ + buf[off++] = prefs.leds_disabled; + /* Total: 94 bytes (Arduino reads 92, ZephCore reads 94) */ bool ok = openWrite(PREFS_FILE, buf, off); LOG_INF("savePrefs: wrote %s, ok=%d (%d bytes), name='%.16s'", diff --git a/zephcore/adapters/radio/LoRaRadioBase.cpp b/zephcore/adapters/radio/LoRaRadioBase.cpp index 541daa8..7699d02 100644 --- a/zephcore/adapters/radio/LoRaRadioBase.cpp +++ b/zephcore/adapters/radio/LoRaRadioBase.cpp @@ -505,97 +505,109 @@ void LoRaRadioBase::triggerNoiseFloorCalibrate(int threshold) return; } - /* Skip when duty cycle is active — the radio alternates between - * short RX windows and sleep. GetRssiInst sent during the sleep - * phase hangs the SPI bus (BUSY stuck high for the full 3 s timeout) - * because the chip cannot process commands while asleep. */ + /* When duty cycle is active the radio alternates between short RX + * windows and sleep. GetRssiInst sent during the sleep phase hangs + * the SPI bus (BUSY stuck high for the full 3 s timeout). Briefly + * switch to continuous RX for the sample, then restore duty cycle. + * Use do{}while(0) so the restore runs from every exit path. */ if (_rx_duty_cycle_enabled) { - return; + hwSetRxDutyCycle(false); + k_sleep(K_MSEC(2)); /* let chip settle into continuous RX */ } - /* Skip if mid-receive — don't want signal energy in the floor. */ - if (isReceiving()) { - return; - } - - /* Random delay 0-500 ms before sampling. Breaks phase-lock with - * periodic interference that might be synchronized with our fixed - * 5-second housekeeping cadence. */ - uint32_t jitter; - sys_rand_get(&jitter, sizeof(jitter)); - k_sleep(K_MSEC(jitter % 500)); - - /* Re-check after the delay — a packet may have arrived. */ - if (isReceiving()) { - return; - } - - /* Median of multiple RSSI reads (~200 us). Rejects up to N/2-1 - * outliers in either direction without the downward bias of min - * or the spike sensitivity of average. Insertion sort is fine - * for N=8 (28 comparisons worst case, all in registers). */ - int16_t samples[NOISE_FLOOR_SAMPLES_PER_TICK]; - for (int i = 0; i < NOISE_FLOOR_SAMPLES_PER_TICK; i++) { - samples[i] = hwGetCurrentRSSI(); - } - /* Insertion sort — tiny array, branch-friendly on Cortex-M */ - for (int i = 1; i < NOISE_FLOOR_SAMPLES_PER_TICK; i++) { - int16_t key = samples[i]; - int j = i - 1; - while (j >= 0 && samples[j] > key) { - samples[j + 1] = samples[j]; - j--; + do { + /* Skip if mid-receive — don't want signal energy in the floor. */ + if (isReceiving()) { + break; } - samples[j + 1] = key; - } - int16_t rssi = (samples[NOISE_FLOOR_SAMPLES_PER_TICK / 2 - 1] + - samples[NOISE_FLOOR_SAMPLES_PER_TICK / 2]) / 2; - /* First sample after reset (DEFAULT_NOISE_FLOOR == 0): seed directly. */ - if (_noise_floor == DEFAULT_NOISE_FLOOR) { - _noise_floor = rssi; + /* Random delay 0-500 ms before sampling. Breaks phase-lock with + * periodic interference that might be synchronized with our fixed + * 5-second housekeeping cadence. Skip when duty cycle is active: + * the chip is already out of its pattern and we want to minimise + * time spent in continuous RX. */ + if (!_rx_duty_cycle_enabled) { + uint32_t jitter; + sys_rand_get(&jitter, sizeof(jitter)); + k_sleep(K_MSEC(jitter % 500)); + + /* Re-check after the delay — a packet may have arrived. */ + if (isReceiving()) { + break; + } + } + + /* Median of multiple RSSI reads (~200 us). Rejects up to N/2-1 + * outliers in either direction without the downward bias of min + * or the spike sensitivity of average. Insertion sort is fine + * for N=8 (28 comparisons worst case, all in registers). */ + int16_t samples[NOISE_FLOOR_SAMPLES_PER_TICK]; + for (int i = 0; i < NOISE_FLOOR_SAMPLES_PER_TICK; i++) { + samples[i] = hwGetCurrentRSSI(); + } + /* Insertion sort — tiny array, branch-friendly on Cortex-M */ + for (int i = 1; i < NOISE_FLOOR_SAMPLES_PER_TICK; i++) { + int16_t key = samples[i]; + int j = i - 1; + while (j >= 0 && samples[j] > key) { + samples[j + 1] = samples[j]; + j--; + } + samples[j + 1] = key; + } + int16_t rssi = (samples[NOISE_FLOOR_SAMPLES_PER_TICK / 2 - 1] + + samples[NOISE_FLOOR_SAMPLES_PER_TICK / 2]) / 2; + + /* First sample after reset (DEFAULT_NOISE_FLOOR == 0): seed directly. */ + if (_noise_floor == DEFAULT_NOISE_FLOOR) { + _noise_floor = rssi; + if (_noise_floor < -120) _noise_floor = -120; + if (_noise_floor > -50) _noise_floor = -50; + _ema_unguarded = 0; + LOG_DBG("noise_floor_cal: seed=%d", _noise_floor); + break; + } + + /* Threshold filter with warmup and periodic bypass. + * + * _ema_unguarded counts up from 0 on every tick. + * Ticks 0..W-1 (warmup): all samples accepted for fast convergence + * after seed/reset — prevents a bad seed from locking out the + * real noise floor via a too-tight threshold. + * Ticks W+: threshold filter active. Every Pth tick one sample + * bypasses the filter so the floor can track sustained upward + * shifts (new interference, antenna change). + * The EMA's 1/8 weight naturally dampens isolated spikes. */ + const int W = (1 << NOISE_FLOOR_EMA_SHIFT); /* 8 — warmup ticks */ + const int P = NOISE_FLOOR_UNGUARDED_INTERVAL; /* 16 — periodic interval */ + bool warmup = (_ema_unguarded < W); + bool periodic = (!warmup && (_ema_unguarded & (P - 1)) == 0); + _ema_unguarded++; /* wraps at 255 — harmless */ + + if (!warmup && !periodic && + rssi >= _noise_floor + NOISE_FLOOR_SAMPLING_THRESHOLD) { + break; + } + + /* EMA: floor += round_nearest((sample - floor) / W). + * Plain >> has downward bias (-1>>3 == -1 but +1>>3 == 0). + * Plain / has a ±7 dead zone (small drifts ignored). + * Round-to-nearest: add half the divisor before dividing, + * with sign-aware bias so both directions are symmetric. */ + int diff = rssi - _noise_floor; + int half = W / 2; /* 4 */ + int step = (diff + (diff > 0 ? half : -half)) / W; + _noise_floor += step; if (_noise_floor < -120) _noise_floor = -120; if (_noise_floor > -50) _noise_floor = -50; - _ema_unguarded = 0; - LOG_DBG("noise_floor_cal: seed=%d", _noise_floor); - return; + + LOG_DBG("noise_floor_cal: rssi=%d, floor=%d, tick=%u", + rssi, _noise_floor, _ema_unguarded - 1); + } while (0); + + if (_rx_duty_cycle_enabled) { + hwSetRxDutyCycle(true); } - - /* Threshold filter with warmup and periodic bypass. - * - * _ema_unguarded counts up from 0 on every tick. - * Ticks 0..W-1 (warmup): all samples accepted for fast convergence - * after seed/reset — prevents a bad seed from locking out the - * real noise floor via a too-tight threshold. - * Ticks W+: threshold filter active. Every Pth tick one sample - * bypasses the filter so the floor can track sustained upward - * shifts (new interference, antenna change). - * The EMA's 1/8 weight naturally dampens isolated spikes. */ - const int W = (1 << NOISE_FLOOR_EMA_SHIFT); /* 8 — warmup ticks */ - const int P = NOISE_FLOOR_UNGUARDED_INTERVAL; /* 16 — periodic interval */ - bool warmup = (_ema_unguarded < W); - bool periodic = (!warmup && (_ema_unguarded & (P - 1)) == 0); - _ema_unguarded++; /* wraps at 255 — harmless */ - - if (!warmup && !periodic && - rssi >= _noise_floor + NOISE_FLOOR_SAMPLING_THRESHOLD) { - return; - } - - /* EMA: floor += round_nearest((sample - floor) / W). - * Plain >> has downward bias (-1>>3 == -1 but +1>>3 == 0). - * Plain / has a ±7 dead zone (small drifts ignored). - * Round-to-nearest: add half the divisor before dividing, - * with sign-aware bias so both directions are symmetric. */ - int diff = rssi - _noise_floor; - int half = W / 2; /* 4 */ - int step = (diff + (diff > 0 ? half : -half)) / W; - _noise_floor += step; - if (_noise_floor < -120) _noise_floor = -120; - if (_noise_floor > -50) _noise_floor = -50; - - LOG_DBG("noise_floor_cal: rssi=%d, floor=%d, tick=%u", - rssi, _noise_floor, _ema_unguarded - 1); } void LoRaRadioBase::resetAGC() diff --git a/zephcore/helpers/BaseChatMesh.cpp b/zephcore/helpers/BaseChatMesh.cpp index 59b9c4c..847b09b 100644 --- a/zephcore/helpers/BaseChatMesh.cpp +++ b/zephcore/helpers/BaseChatMesh.cpp @@ -7,6 +7,7 @@ #include #include #include +#include #include LOG_MODULE_REGISTER(zephcore_basechat, CONFIG_ZEPHCORE_MAIN_LOG_LEVEL); diff --git a/zephcore/helpers/NodePrefs.h b/zephcore/helpers/NodePrefs.h index 1b2c06c..fb4e49b 100644 --- a/zephcore/helpers/NodePrefs.h +++ b/zephcore/helpers/NodePrefs.h @@ -76,6 +76,7 @@ struct NodePrefs { uint8_t path_hash_mode; // which path mode to use when sending (0-2) uint8_t autoadd_max_hops; // 0 = no limit, 1 = direct (0 hops), N = up to N-1 hops (max 64) uint8_t loop_detect; // LOOP_DETECT_OFF/MINIMAL/MODERATE/STRICT + uint8_t leds_disabled; // 1 = LEDs off (heartbeat disabled), 0 = LEDs on }; /* Default prefs — MUST match LoRaConfig.h defaults for radio interop. diff --git a/zephcore/helpers/ui/doom_game.c b/zephcore/helpers/ui/doom_game.c index f127bf2..57b95fe 100644 --- a/zephcore/helpers/ui/doom_game.c +++ b/zephcore/helpers/ui/doom_game.c @@ -664,6 +664,11 @@ static const uint8_t glyph_H[5] = {0x9, 0x9, 0xF, 0x9, 0x9}; static const uint8_t glyph_P[5] = {0xE, 0x9, 0xE, 0x8, 0x8}; static const uint8_t glyph_A[5] = {0x6, 0x9, 0xF, 0x9, 0x9}; static const uint8_t glyph_M[5] = {0x9, 0xF, 0xF, 0x9, 0x9}; +static const uint8_t glyph_G[5] = {0x7, 0x8, 0xB, 0x9, 0x7}; +static const uint8_t glyph_E[5] = {0xF, 0x8, 0xE, 0x8, 0xF}; +static const uint8_t glyph_O[5] = {0x6, 0x9, 0x9, 0x9, 0x6}; +static const uint8_t glyph_V[5] = {0x9, 0x9, 0x9, 0x6, 0x4}; +static const uint8_t glyph_R[5] = {0xE, 0x9, 0xE, 0xC, 0x9}; static void draw_glyph(int dx, int dy, const uint8_t *glyph) { @@ -821,7 +826,17 @@ static void draw_game_over(void) blit_letter(start_x + 20, start_y, letter_A); blit_letter(start_x + 30, start_y, letter_D); - draw_number((SCREEN_W - 12) / 2, start_y + 14, game.score); + /* "game over" on one line: GAME(4 chars) + 7px gap + OVER(4 chars) = 40px */ + int go_x = (SCREEN_W - 40) / 2; + int go_y = start_y + 14; + draw_glyph(go_x, go_y, glyph_G); + draw_glyph(go_x + 5, go_y, glyph_A); + draw_glyph(go_x + 10, go_y, glyph_M); + draw_glyph(go_x + 15, go_y, glyph_E); + draw_glyph(go_x + 22, go_y, glyph_O); + draw_glyph(go_x + 27, go_y, glyph_V); + draw_glyph(go_x + 32, go_y, glyph_E); + draw_glyph(go_x + 37, go_y, glyph_R); } /* ========== DISPLAY ========== */ diff --git a/zephcore/helpers/ui/ui_mesh_actions.cpp b/zephcore/helpers/ui/ui_mesh_actions.cpp index 6166361..fb1cb60 100644 --- a/zephcore/helpers/ui/ui_mesh_actions.cpp +++ b/zephcore/helpers/ui/ui_mesh_actions.cpp @@ -34,6 +34,7 @@ LOG_MODULE_REGISTER(zephcore_ui_actions, CONFIG_ZEPHCORE_UI_ACTIONS_LOG_LEVEL); #define UI_ACTION_BUZZER_TOGGLE BIT(2) #define UI_ACTION_ZEROHOP_ADVERT BIT(3) #define UI_ACTION_OFFGRID_TOGGLE BIT(4) +#define UI_ACTION_LEDS_TOGGLE BIT(5) /* Module-local pointers, set by init */ static CompanionMesh *s_mesh; @@ -53,6 +54,7 @@ static atomic_t pending_ui_actions; static atomic_t pending_gps_enabled; static atomic_t pending_buzzer_quiet; static atomic_t pending_offgrid_enabled; +static atomic_t pending_leds_disabled; extern "C" void ui_mesh_actions_init(struct k_event *mesh_events, uint32_t mesh_event_ui_action, @@ -115,6 +117,14 @@ extern "C" void mesh_set_offgrid_mode(bool enable) k_event_post(s_mesh_events, s_mesh_event_ui_action); } +extern "C" void mesh_set_leds_disabled(bool disabled) +{ + /* Defer the flash write (savePrefs) to mesh thread */ + atomic_set(&pending_leds_disabled, disabled ? 1 : 0); + atomic_or(&pending_ui_actions, UI_ACTION_LEDS_TOGGLE); + k_event_post(s_mesh_events, s_mesh_event_ui_action); +} + /* Disable power regulators for System OFF. * Only touches sensor power and buzzer power-gate regulators. * GPS is handled separately by gps_power_off_for_shutdown(). @@ -200,6 +210,13 @@ extern "C" void mesh_handle_ui_actions(void) need_save = true; } + if (actions & UI_ACTION_LEDS_TOGGLE) { + bool ld = atomic_get(&pending_leds_disabled) != 0; + s_mesh->prefs.leds_disabled = ld ? 1 : 0; + LOG_INF("leds_disabled=%d (button)", ld); + need_save = true; + } + if (need_save) { s_data_store->savePrefs(s_mesh->prefs); LOG_INF("prefs saved (button action)"); diff --git a/zephcore/helpers/ui/ui_mesh_actions.h b/zephcore/helpers/ui/ui_mesh_actions.h index e3592e4..9b6f382 100644 --- a/zephcore/helpers/ui/ui_mesh_actions.h +++ b/zephcore/helpers/ui/ui_mesh_actions.h @@ -57,6 +57,7 @@ void mesh_gps_set_enabled(bool enable); void mesh_ble_set_enabled(bool enable); void mesh_set_buzzer_quiet(bool quiet); void mesh_set_offgrid_mode(bool enable); +void mesh_set_leds_disabled(bool disabled); void mesh_disable_power_regulators(void); void mesh_reboot_to_ota_dfu(void); diff --git a/zephcore/helpers/ui/ui_mesh_actions_stubs.c b/zephcore/helpers/ui/ui_mesh_actions_stubs.c index 6746e20..8b4fb9e 100644 --- a/zephcore/helpers/ui/ui_mesh_actions_stubs.c +++ b/zephcore/helpers/ui/ui_mesh_actions_stubs.c @@ -19,6 +19,7 @@ __attribute__((weak)) void mesh_gps_set_enabled(bool enable) { ARG_UNUSED(enable __attribute__((weak)) void mesh_ble_set_enabled(bool enable) { ARG_UNUSED(enable); } __attribute__((weak)) void mesh_set_buzzer_quiet(bool quiet) { ARG_UNUSED(quiet); } __attribute__((weak)) void mesh_set_offgrid_mode(bool enable) { ARG_UNUSED(enable); } +__attribute__((weak)) void mesh_set_leds_disabled(bool disabled) { ARG_UNUSED(disabled); } __attribute__((weak)) void mesh_disable_power_regulators(void) {} __attribute__((weak)) void mesh_reboot_to_ota_dfu(void) {} __attribute__((weak)) void mesh_handle_ui_actions(void) {} diff --git a/zephcore/helpers/ui/ui_pages.c b/zephcore/helpers/ui/ui_pages.c index 327bf47..0c31dd3 100644 --- a/zephcore/helpers/ui/ui_pages.c +++ b/zephcore/helpers/ui/ui_pages.c @@ -165,6 +165,7 @@ static const enum ui_page active_pages[] = { UI_PAGE_ADVERT, UI_PAGE_GPS, UI_PAGE_BUZZER, + UI_PAGE_LEDS, UI_PAGE_SENSORS, UI_PAGE_OFFGRID, UI_PAGE_DFU, @@ -512,6 +513,20 @@ static void render_buzzer(void) state.buzzer_quiet ? "Press to Enable" : "Press to Disable"); } +static void render_leds(void) +{ + char buf[24]; + int y = CONTENT_Y; + + snprintf(buf, sizeof(buf), "LEDs: %s", + state.leds_disabled ? "off" : "on"); + mc_display_text(0, y, buf, false); + y += LINE_H; + + draw_centered(y + 8, + state.leds_disabled ? "Press to Enable" : "Press to Disable"); +} + static void render_sensors(void) { char buf[24]; @@ -671,6 +686,7 @@ static const page_render_fn renderers[] = { [UI_PAGE_ADVERT] = render_advert, [UI_PAGE_GPS] = render_gps, [UI_PAGE_BUZZER] = render_buzzer, + [UI_PAGE_LEDS] = render_leds, [UI_PAGE_SENSORS] = render_sensors, [UI_PAGE_OFFGRID] = render_offgrid, [UI_PAGE_DFU] = render_dfu, diff --git a/zephcore/helpers/ui/ui_pages.h b/zephcore/helpers/ui/ui_pages.h index 9f3c72b..f8525ed 100644 --- a/zephcore/helpers/ui/ui_pages.h +++ b/zephcore/helpers/ui/ui_pages.h @@ -27,6 +27,7 @@ enum ui_page { UI_PAGE_ADVERT, /* Send broadcast advert */ UI_PAGE_GPS, /* GPS status / position */ UI_PAGE_BUZZER, /* Buzzer mute toggle */ + UI_PAGE_LEDS, /* LED enable/disable toggle */ UI_PAGE_SENSORS, /* Environment sensor data */ UI_PAGE_OFFGRID, /* Offgrid mode (client repeat) toggle */ UI_PAGE_DFU, /* BLE DFU bootloader entry */ @@ -82,6 +83,9 @@ struct ui_state { /* Buzzer page */ bool buzzer_quiet; /* true = muted */ + /* LEDs page */ + bool leds_disabled; /* true = LEDs off */ + /* Sensors page */ int16_t temperature_c10; /* temp in 0.1°C */ uint32_t pressure_pa; /* pressure in Pa */ diff --git a/zephcore/helpers/ui/ui_task.c b/zephcore/helpers/ui/ui_task.c index f511a74..326594b 100644 --- a/zephcore/helpers/ui/ui_task.c +++ b/zephcore/helpers/ui/ui_task.c @@ -277,6 +277,7 @@ static void action_page_prev(void) static void action_flood_advert(void); static void action_gps_toggle(void); static void action_buzzer_toggle(void); +static void action_leds_toggle(void); #ifdef CONFIG_ZEPHCORE_UI_DISPLAY static void action_ble_toggle(void); static void action_enter_dfu(void); @@ -319,6 +320,11 @@ static void action_page_enter(void) action_buzzer_toggle(); break; + case UI_PAGE_LEDS: + /* Toggle LED on/off */ + action_leds_toggle(); + break; + case UI_PAGE_OFFGRID: { /* Double-press confirmation (500ms window) */ struct ui_state *st_og = get_state(); @@ -461,6 +467,18 @@ static void action_buzzer_toggle(void) schedule_render(); } +static void action_leds_toggle(void) +{ + struct ui_state *s = get_state(); + bool new_disabled = !s->leds_disabled; + + s->leds_disabled = new_disabled; + ui_set_heartbeat_led(!new_disabled); + mesh_set_leds_disabled(new_disabled); + LOG_INF("LEDs %s (user toggle)", new_disabled ? "disabled" : "enabled"); + schedule_render(); +} + static void action_gps_toggle(void) { if (!gps_is_available()) { @@ -1032,6 +1050,28 @@ void ui_set_offgrid_mode(bool enabled) s->offgrid_enabled = enabled; } +void ui_set_leds_disabled(bool disabled) +{ + struct ui_state *s = get_state(); + + s->leds_disabled = disabled; +} + +void ui_set_heartbeat_led(bool enabled) +{ +#if HAS_HEARTBEAT_LED + if (enabled) { + if (gpio_is_ready_dt(&heartbeat_led)) { + k_work_reschedule(&led_on_work, K_NO_WAIT); + } + } else { + k_work_cancel_delayable(&led_on_work); + k_work_cancel_delayable(&led_off_work); + gpio_pin_set_dt(&heartbeat_led, 0); + } +#endif +} + void ui_refresh_display(void) { if (!ui_initialized) { diff --git a/zephcore/helpers/ui/ui_task.h b/zephcore/helpers/ui/ui_task.h index 04fd7bc..dd645d7 100644 --- a/zephcore/helpers/ui/ui_task.h +++ b/zephcore/helpers/ui/ui_task.h @@ -148,6 +148,16 @@ void ui_set_ble_enabled(bool enabled); */ void ui_set_buzzer_quiet(bool quiet); +/** + * Set LEDs disabled state (for display page). + */ +void ui_set_leds_disabled(bool disabled); + +/** + * Enable or disable the heartbeat LED. + */ +void ui_set_heartbeat_led(bool enabled); + /** * Set offgrid mode (client repeat) state for display page. */ diff --git a/zephcore/src/main_companion.cpp b/zephcore/src/main_companion.cpp index 6d0877a..b65ae0f 100644 --- a/zephcore/src/main_companion.cpp +++ b/zephcore/src/main_companion.cpp @@ -654,6 +654,13 @@ int main(void) ui_play_startup_chime(); #endif + /* Restore LED enabled/disabled state from persisted prefs. + * If LEDs were disabled, stop the heartbeat LED cycle. */ + bool leds_off = companion_mesh.prefs.leds_disabled != 0; + ui_set_leds_disabled(leds_off); + ui_set_heartbeat_led(!leds_off); + LOG_INF("LEDs: %s (from prefs)", leds_off ? "disabled" : "enabled"); + /* Restore GPS state from persisted prefs. * GPS hardware is powered at boot (bootloader/pull-up). * First, ensure power state matches prefs (powers off if disabled).