#include "config.h" #include "state.h" #include "game.h" #include "draw.h" #include "ble.h" #include "events.h" #include "menus.h" #include "driver/gpio.h" #include "driver/i2c.h" #include "driver/uart.h" #include "esp_vfs_dev.h" #include "nvs_flash.h" #include "nvs.h" #include "esp_err.h" #include "esp_random.h" #include "esp_sleep.h" #include "esp_timer.h" #include "driver/rtc_io.h" #include "esp_adc/adc_oneshot.h" #include "esp_adc/adc_cali.h" #include "esp_adc/adc_cali_scheme.h" #include "freertos/FreeRTOS.h" #include "freertos/task.h" #include "freertos/queue.h" #include #include #include #include // ── Global state ────────────────────────────────────────────────────────────── int g_battery_pct = -1; int g_charging = 0; int g_batt_full = 0; int g_sleep_timeout_min = SLEEP_TIMEOUT_DEF; static adc_oneshot_unit_handle_t s_adc; static adc_cali_handle_t s_adc_cali; static bool s_adc_cali_ok; // Game int g_life; int g_cmdr_damage[MAX_OPPONENTS]; int g_counters[NUM_COUNTERS]; int g_eliminated; // UI int g_active_menu; int g_active_opponent; int g_active_counter; int g_active_player; int g_active_setting; int g_name_cursor; int g_life_select; // Settings int g_brightness_pct = 10; int g_start_life_index = 2; int g_num_opponents = 3; char g_player_name[PLAYER_NAME_LEN + 1] = "PLAYER 1"; int g_ble_enabled = 0; uint8_t g_led_max = 26; int g_game_id_cursor = 0; uint8_t g_game_id[2] = {BLE_GAME_ID_0, BLE_GAME_ID_1}; int g_menu_hold_ms = 500; int g_lr_hold_ms = 500; int g_display_flip = 0; // Peers ble_peer_t g_peers[MAX_BLE_PEERS]; // Battery blink state toggled by batt timer bool g_batt_blink; // Life delta overlay int g_life_delta = 0; int g_delta_timeout_ms = 1000; // Dice int g_dice_num = 1; int g_dice_sides = 5; int g_dice_item = 0; char g_dice_csv[DICE_CSV_LEN]; int g_dice_rolled = 0; int g_dice_sum = 0; // ── Event queue + timer handles ─────────────────────────────────────────────── static QueueHandle_t g_evt_queue; static esp_timer_handle_t s_hold_timer[4]; static esp_timer_handle_t s_combo_all4_timer; static esp_timer_handle_t s_combo_fb_timer; static esp_timer_handle_t s_batt_timer; static esp_timer_handle_t s_peer_timer; static esp_timer_handle_t s_autosave_timer; static esp_timer_handle_t s_life_delta_timer; static esp_timer_handle_t s_reset_cmd_timer; static esp_timer_handle_t s_idle_timer; static esp_timer_handle_t s_led_breathe_timer; // ── Button ISR state ────────────────────────────────────────────────────────── static const int BTN_GPIOS[4] = { BTN_FORWARD_GPIO, BTN_LEFT_GPIO, BTN_RIGHT_GPIO, BTN_BACK_GPIO }; static int64_t s_press_time_us[4]; static int64_t s_last_event_us[4]; static uint8_t s_pressed_mask; static bool s_btn_in_combo[4]; // ── LED breathing state ─────────────────────────────────────────────────────── static float s_breath_phase; static bool s_breathe_active; // ── Misc ───────────────────────────────────────────────────────────────────── static bool s_settings_dirty; static int s_pre_settings_menu = 0; // ── RTC memory (survives deep sleep) ───────────────────────────────────────── RTC_DATA_ATTR static bool s_rtc_valid; RTC_DATA_ATTR static int s_rtc_life, s_rtc_eliminated; RTC_DATA_ATTR static int s_rtc_cmdr_damage[MAX_OPPONENTS]; RTC_DATA_ATTR static int s_rtc_counters[NUM_COUNTERS]; RTC_DATA_ATTR static int s_rtc_active_menu, s_rtc_active_opponent; RTC_DATA_ATTR static int s_rtc_active_counter, s_rtc_active_player; RTC_DATA_ATTR static int s_rtc_active_setting, s_rtc_name_cursor; RTC_DATA_ATTR static int s_rtc_life_select; RTC_DATA_ATTR static int s_rtc_sleep_mode; RTC_DATA_ATTR static int s_rtc_ble_pre_sleep; #define MARK_DIRTY() do { \ s_settings_dirty = true; \ esp_timer_stop(s_autosave_timer); \ esp_timer_start_once(s_autosave_timer, (int64_t)SAVE_DELAY * 10000LL); \ } while(0) #define RESTART_LIFE_DELTA() do { \ esp_timer_stop(s_life_delta_timer); \ esp_timer_start_once(s_life_delta_timer, (int64_t)g_delta_timeout_ms * 1000LL); \ } while(0) void mark_dirty(void) { s_settings_dirty = true; esp_timer_stop(s_autosave_timer); esp_timer_start_once(s_autosave_timer, (int64_t)SAVE_DELAY * 10000LL); } void restart_life_delta_timer(void) { esp_timer_stop(s_life_delta_timer); esp_timer_start_once(s_life_delta_timer, (int64_t)g_delta_timeout_ms * 1000LL); } void start_reset_cmd_timer(void) { esp_timer_stop(s_reset_cmd_timer); esp_timer_start_once(s_reset_cmd_timer, 15000000LL); } void restart_idle_timer(void) { if (g_sleep_timeout_min > 0) { esp_timer_stop(s_idle_timer); esp_timer_start_once(s_idle_timer, (int64_t)g_sleep_timeout_min * 60LL * 1000000LL); } } // ── ADC battery helpers ─────────────────────────────────────────────────────── static void sample_battery(void) { static int raw_buf[100]; static int raw_idx = 0; static int raw_count = 0; int raw; adc_oneshot_read(s_adc, ADC_CHANNEL_6, &raw); raw_buf[raw_idx] = raw; raw_idx = (raw_idx + 1) % 100; if (raw_count < 100) raw_count++; int sum = 0; for (int i = 0; i < raw_count; i++) sum += raw_buf[i]; int avg_raw = sum / raw_count; int voltage_mv; if (s_adc_cali_ok) adc_cali_raw_to_voltage(s_adc_cali, avg_raw, &voltage_mv); else voltage_mv = avg_raw * 3100 / 4095; int comp_mv = g_ble_enabled ? BATT_LOAD_COMP_BLE_MV : BATT_LOAD_COMP_MV; int cell_mv = voltage_mv * 2 + comp_mv; static const int lipo_mv[] = {3000,3300,3400,3500,3600,3700,3800,3900,4000,4100,4200}; static const int lipo_pct[] = { 0, 3, 8, 18, 34, 50, 62, 72, 81, 92, 100}; static const int N = sizeof(lipo_mv) / sizeof(lipo_mv[0]); int pct; if (cell_mv <= lipo_mv[0]) pct = 0; else if (cell_mv >= lipo_mv[N-1]) pct = 100; else { int i = 0; while (i < N-2 && cell_mv >= lipo_mv[i+1]) i++; pct = lipo_pct[i] + (cell_mv - lipo_mv[i]) * (lipo_pct[i+1] - lipo_pct[i]) / (lipo_mv[i+1] - lipo_mv[i]); } g_battery_pct = pct; oled_draw_header(); } // ── LED breathing sync ──────────────────────────────────────────────────────── void led_sync(bool changed) { int bthresh = start_life_opts[g_start_life_index] / 4; bool should = (g_life > 0 && g_life < bthresh); if (should && !s_breathe_active) { s_breathe_active = true; esp_timer_start_periodic(s_led_breathe_timer, 10000); } else if (!should && s_breathe_active) { s_breathe_active = false; s_breath_phase = 0.0f; esp_timer_stop(s_led_breathe_timer); if (g_life == 0) led_update_for_count(0, 255); else if (changed) led_update_for_count(g_life, 255); } else if (!should && changed) { led_update_for_count(g_life, 255); } } // ── Button action helpers ───────────────────────────────────────────────────── static void nav_short(int d) { menus_nav_short(d); } static void nav_long(int d) { int last = NUM_MENU_SLOTS - 1; if (g_active_menu == last) return; int next = g_active_menu; do { next = (next + d + last) % last; } while (menus[next] == NULL); g_active_menu = next; esp_timer_stop(s_hold_timer[BTN_IDX_LEFT]); esp_timer_stop(s_hold_timer[BTN_IDX_RIGHT]); oled_draw_header(); menus_draw(); } static void lr_delta(int d) { menus_lr_delta(d); } // ── Deep sleep ──────────────────────────────────────────────────────────────── static void enter_deep_sleep(int manual, int ble_pre) { s_rtc_valid = true; s_rtc_life = g_life; s_rtc_eliminated = g_eliminated; memcpy(s_rtc_cmdr_damage, g_cmdr_damage, sizeof(g_cmdr_damage)); memcpy(s_rtc_counters, g_counters, sizeof(g_counters)); s_rtc_active_menu = g_active_menu; s_rtc_active_opponent = g_active_opponent; s_rtc_active_counter = g_active_counter; s_rtc_active_player = g_active_player; s_rtc_active_setting = g_active_setting; s_rtc_name_cursor = g_name_cursor; s_rtc_life_select = g_life_select; s_rtc_sleep_mode = manual; s_rtc_ble_pre_sleep = ble_pre; if (s_settings_dirty) settings_save(); for (int i = 0; i < 4; i++) esp_timer_stop(s_hold_timer[i]); esp_timer_stop(s_combo_all4_timer); esp_timer_stop(s_combo_fb_timer); esp_timer_stop(s_batt_timer); esp_timer_stop(s_peer_timer); esp_timer_stop(s_autosave_timer); esp_timer_stop(s_life_delta_timer); esp_timer_stop(s_reset_cmd_timer); esp_timer_stop(s_idle_timer); esp_timer_stop(s_led_breathe_timer); g_ble_enabled = 0; ble_adv_update(); if (manual) { oled_draw_sleep(); oled_set_contrast(SLEEP_CONTRAST); } else { oled_set_contrast(0); } led_off(); if (manual) { // Wait for all buttons to release before sleeping, otherwise EXT1 ALL_LOW // triggers immediately (all 4 are still held from the wakeup combo). while (!gpio_get_level(BTN_FORWARD_GPIO) || !gpio_get_level(BTN_LEFT_GPIO) || !gpio_get_level(BTN_RIGHT_GPIO) || !gpio_get_level(BTN_BACK_GPIO)) vTaskDelay(pdMS_TO_TICKS(10)); vTaskDelay(pdMS_TO_TICKS(50)); // Manual sleep: wake when all 4 pressed simultaneously (deliberate gesture). uint64_t wake_mask = (1ULL << BTN_FORWARD_GPIO) | (1ULL << BTN_LEFT_GPIO) | (1ULL << BTN_RIGHT_GPIO) | (1ULL << BTN_BACK_GPIO); for (int i = 0; i < 4; i++) { rtc_gpio_pullup_en(BTN_GPIOS[i]); rtc_gpio_hold_en(BTN_GPIOS[i]); } esp_sleep_enable_ext1_wakeup(wake_mask, ESP_EXT1_WAKEUP_ALL_LOW); } else { // Auto sleep: wake on LEFT button press (GPIO2 is the only RTC GPIO // among our 4 buttons that supports EXT0 single-pin wakeup). rtc_gpio_pullup_en(BTN_LEFT_GPIO); rtc_gpio_hold_en(BTN_LEFT_GPIO); esp_sleep_enable_ext0_wakeup(BTN_LEFT_GPIO, 0); } esp_deep_sleep_start(); } // ── Timer callbacks ─────────────────────────────────────────────────────────── static void cb_hold(void *arg) { int bi = (int)arg; app_event_t e = {.type = EVT_HOLD_EXPIRE, .btn_idx = bi}; xQueueSend(g_evt_queue, &e, 0); } static void cb_combo_all4(void *arg) { (void)arg; app_event_t e = {.type = EVT_COMBO_SLEEP}; xQueueSend(g_evt_queue, &e, 0); } static void cb_combo_fb(void *arg) { (void)arg; app_event_t e = {.type = EVT_COMBO_SETTINGS}; xQueueSend(g_evt_queue, &e, 0); } static void cb_batt(void *arg) { (void)arg; app_event_t e = {.type = EVT_BATT_TICK}; xQueueSend(g_evt_queue, &e, 0); } static void cb_peer(void *arg) { (void)arg; app_event_t e = {.type = EVT_PEER_TICK}; xQueueSend(g_evt_queue, &e, 0); } static void cb_autosave(void *arg) { (void)arg; app_event_t e = {.type = EVT_AUTOSAVE}; xQueueSend(g_evt_queue, &e, 0); } static void cb_life_delta(void *arg) { (void)arg; app_event_t e = {.type = EVT_LIFE_DELTA_EXP}; xQueueSend(g_evt_queue, &e, 0); } static void cb_reset_cmd(void *arg) { (void)arg; app_event_t e = {.type = EVT_RESET_CMD_EXP}; xQueueSend(g_evt_queue, &e, 0); } static void cb_idle(void *arg) { (void)arg; app_event_t e = {.type = EVT_IDLE_SLEEP}; xQueueSend(g_evt_queue, &e, 0); } static void cb_led_breathe(void *arg) { (void)arg; app_event_t e = {.type = EVT_LED_BREATHE}; xQueueSend(g_evt_queue, &e, 0); } // ── GPIO ISR ────────────────────────────────────────────────────────────────── static void IRAM_ATTR btn_isr(void *arg) { int gpio = (int)arg; app_event_t e = { .type = EVT_BUTTON, .gpio = gpio, .level = gpio_get_level(gpio), .ts_us = esp_timer_get_time(), }; BaseType_t woken = pdFALSE; xQueueSendFromISR(g_evt_queue, &e, &woken); portYIELD_FROM_ISR(woken); } // ── Button event handler ────────────────────────────────────────────────────── static int gpio_to_btn(int gpio) { for (int i = 0; i < 4; i++) if (BTN_GPIOS[i] == gpio) return i; return -1; } static void handle_button(const app_event_t *e) { int bi = gpio_to_btn(e->gpio); if (bi < 0) return; int64_t ts = e->ts_us; // Debounce: ignore events within 20ms of last event on same GPIO if (ts - s_last_event_us[bi] < 20000) return; s_last_event_us[bi] = ts; if (e->level == 0) { // ── FALLING edge (button pressed) ──────────────────────────────────── s_pressed_mask |= (uint8_t)(1u << bi); s_press_time_us[bi] = ts; restart_idle_timer(); // Check all-4 combo if (s_pressed_mask == 0xF) { for (int i = 0; i < 4; i++) { s_btn_in_combo[i] = true; esp_timer_stop(s_hold_timer[i]); } esp_timer_stop(s_combo_fb_timer); esp_timer_stop(s_combo_all4_timer); esp_timer_start_once(s_combo_all4_timer, (int64_t)COMBO_HOLD_SLEEP * 10000LL); return; } // Check fwd+back combo if ((s_pressed_mask & 0x9) == 0x9) { // bits 0 (fwd) and 3 (back) s_btn_in_combo[BTN_IDX_FWD] = true; s_btn_in_combo[BTN_IDX_BACK] = true; esp_timer_stop(s_hold_timer[BTN_IDX_FWD]); esp_timer_stop(s_hold_timer[BTN_IDX_BACK]); esp_timer_stop(s_combo_fb_timer); esp_timer_start_once(s_combo_fb_timer, (int64_t)COMBO_HOLD_SETTINGS * 10000LL); return; } // Check left+right simultaneous: don't fire either if ((s_pressed_mask & 0x6) == 0x6) { // bits 1 (left) and 2 (right) esp_timer_stop(s_hold_timer[BTN_IDX_LEFT]); esp_timer_stop(s_hold_timer[BTN_IDX_RIGHT]); return; } if (s_btn_in_combo[bi]) return; if (bi == BTN_IDX_LEFT || bi == BTN_IDX_RIGHT) { // LR: fire delta immediately on press lr_delta(bi == BTN_IDX_RIGHT ? 1 : -1); // Start hold timer for repeat esp_timer_stop(s_hold_timer[bi]); esp_timer_start_once(s_hold_timer[bi], (int64_t)g_lr_hold_ms * 1000LL); } else { // FWD/BACK: start hold timer; action dispatched on expire or release esp_timer_stop(s_hold_timer[bi]); esp_timer_start_once(s_hold_timer[bi], (int64_t)g_menu_hold_ms * 1000LL); } } else { // ── RISING edge (button released) ──────────────────────────────────── int64_t hold_ms = (ts - s_press_time_us[bi]) / 1000; esp_timer_stop(s_hold_timer[bi]); s_pressed_mask &= (uint8_t)~(1u << bi); // Cancel combo timers if conditions no longer met if (s_pressed_mask != 0xF) esp_timer_stop(s_combo_all4_timer); if ((s_pressed_mask & 0x9) != 0x9) esp_timer_stop(s_combo_fb_timer); bool was_in_combo = s_btn_in_combo[bi]; s_btn_in_combo[bi] = false; if (was_in_combo) return; if ((bi == BTN_IDX_FWD || bi == BTN_IDX_BACK) && hold_ms < g_menu_hold_ms) nav_short(bi == BTN_IDX_FWD ? 1 : -1); // LR: no action on release (action fires on press and hold-repeat) } } // ── Hold expire handler ─────────────────────────────────────────────────────── static void handle_hold_expire(int bi) { if (!(s_pressed_mask & (1u << bi))) return; // released before timer fired if (bi == BTN_IDX_LEFT || bi == BTN_IDX_RIGHT) { lr_delta(bi == BTN_IDX_RIGHT ? 1 : -1); // Repeat at HOLD_REPEAT interval esp_timer_start_once(s_hold_timer[bi], (int64_t)HOLD_REPEAT * 10000LL); } else { // FWD or BACK long press: cycle menu nav_long(bi == BTN_IDX_FWD ? 1 : -1); esp_timer_start_once(s_hold_timer[bi], (int64_t)g_menu_hold_ms * 1000LL); } } // ── Serial command task ─────────────────────────────────────────────────────── #ifdef DEBUG static void serial_print_state(void) { printf("DBG STATE life=%d poison=%u cmdr=[%d,%d,%d,%d] counters=[%d,%d,%d] menu=%d ble=%d eliminated=%d\n", g_life, (unsigned)g_counters[0], g_cmdr_damage[0], g_cmdr_damage[1], g_cmdr_damage[2], g_cmdr_damage[3], g_counters[0], g_counters[1], g_counters[2], g_active_menu, g_ble_enabled, g_eliminated); fflush(stdout); } static void serial_cmd_task(void *arg) { (void)arg; char buf[64]; int pos = 0; for (;;) { int c = fgetc(stdin); if (c < 0) { vTaskDelay(pdMS_TO_TICKS(10)); continue; } if (c == '\n' || c == '\r') { if (pos > 0) { buf[pos] = '\0'; pos = 0; int val = 0; if (strncmp(buf, "SET ", 4) == 0) { const char *kv = buf + 4; if (sscanf(kv, "life=%d", &val) == 1) { g_life = val; check_elimination(); } if (sscanf(kv, "ble=%d", &val) == 1) { g_ble_enabled = val ? 1 : 0; } { char fmt[14]; for (int i = 0; i < MAX_OPPONENTS; i++) { snprintf(fmt, sizeof(fmt), "cmdr%d=%%d", i); if (sscanf(kv, fmt, &val) == 1) { g_cmdr_damage[i] = val < 0 ? 0 : val; check_elimination(); break; } } for (int i = 0; i < NUM_COUNTERS; i++) { snprintf(fmt, sizeof(fmt), "counter%d=%%d", i); if (sscanf(kv, fmt, &val) == 1) { g_counters[i] = val < 0 ? 0 : val; check_elimination(); break; } } } ble_adv_update(); serial_print_state(); } else if (strcmp(buf, "RESET") == 0) { game_reset(); ble_adv_update(); serial_print_state(); } else if (strcmp(buf, "CLEARNVS") == 0) { nvs_handle_t nvs; if (nvs_open(NVS_NS, NVS_READWRITE, &nvs) == ESP_OK) { nvs_erase_all(nvs); nvs_commit(nvs); nvs_close(nvs); } settings_reset_defaults(); game_reset(); ble_adv_update(); serial_print_state(); } else if (strcmp(buf, "STATE") == 0) { serial_print_state(); } } } else if (pos < (int)sizeof(buf) - 1) { buf[pos++] = (char)c; } } } #endif // DEBUG // ── app_main ────────────────────────────────────────────────────────────────── void app_main(void) { led_init(); i2c_config_t i2c_cfg = { .mode = I2C_MODE_MASTER, .sda_io_num = I2C_SDA_GPIO, .scl_io_num = I2C_SCL_GPIO, .sda_pullup_en = GPIO_PULLUP_ENABLE, .scl_pullup_en = GPIO_PULLUP_ENABLE, .master.clk_speed = I2C_FREQ_HZ, }; ESP_ERROR_CHECK(i2c_param_config(I2C_PORT, &i2c_cfg)); ESP_ERROR_CHECK(i2c_driver_install(I2C_PORT, I2C_MODE_MASTER, 0, 0, 0)); esp_err_t nvs_err = nvs_flash_init(); if (nvs_err == ESP_ERR_NVS_NO_FREE_PAGES || nvs_err == ESP_ERR_NVS_NEW_VERSION_FOUND) { nvs_flash_erase(); nvs_flash_init(); } g_life = start_life_opts[g_start_life_index]; settings_load(); menus_init(); check_elimination(); g_led_max = (uint8_t)(255 * g_brightness_pct / 100); // ADC for battery voltage (GPIO34, ADC1 CH6, 100k/100k divider) adc_oneshot_unit_init_cfg_t adc_cfg = {.unit_id = ADC_UNIT_1}; adc_oneshot_new_unit(&adc_cfg, &s_adc); adc_oneshot_chan_cfg_t ch_cfg = { .atten = ADC_ATTEN_DB_11, .bitwidth = ADC_BITWIDTH_DEFAULT, }; adc_oneshot_config_channel(s_adc, ADC_CHANNEL_6, &ch_cfg); adc_cali_line_fitting_config_t cali_cfg = { .unit_id = ADC_UNIT_1, .atten = ADC_ATTEN_DB_11, .bitwidth = ADC_BITWIDTH_DEFAULT, }; s_adc_cali_ok = (adc_cali_create_scheme_line_fitting(&cali_cfg, &s_adc_cali) == ESP_OK); oled_init(); oled_set_flip(g_display_flip); oled_clear(); // Button GPIO config: ANYEDGE ISRs gpio_config_t gpio_cfg = { .pin_bit_mask = (1ULL<= NUM_MENU_SLOTS || (menus[g_active_menu] == NULL && g_active_menu != NUM_MENU_SLOTS - 1)) g_active_menu = 0; if (g_ble_enabled) ble_adv_update(); } oled_draw_header(); menus_draw(); led_sync(true); #ifdef DEBUG serial_print_state(); #endif // ── Main event loop ─────────────────────────────────────────────────────── app_event_t evt; for (;;) { xQueueReceive(g_evt_queue, &evt, portMAX_DELAY); switch (evt.type) { // ── Button edge ─────────────────────────────────────────────────────── case EVT_BUTTON: handle_button(&evt); break; // ── Hold timer expired ──────────────────────────────────────────────── case EVT_HOLD_EXPIRE: handle_hold_expire(evt.btn_idx); break; // ── All-4 sleep combo ───────────────────────────────────────────────── case EVT_COMBO_SLEEP: if (s_pressed_mask != 0xF) break; enter_deep_sleep(1, g_ble_enabled); break; // not reached // ── Fwd+back settings combo ─────────────────────────────────────────── case EVT_COMBO_SETTINGS: if ((s_pressed_mask & 0x9) != 0x9) break; esp_timer_stop(s_hold_timer[BTN_IDX_LEFT]); esp_timer_stop(s_hold_timer[BTN_IDX_RIGHT]); { int last = NUM_MENU_SLOTS - 1; if (g_active_menu == last) { g_active_menu = s_pre_settings_menu; } else { s_pre_settings_menu = g_active_menu; g_active_menu = last; g_active_setting = 0; g_name_cursor = 0; g_game_id_cursor = 0; } } oled_draw_header(); menus_draw(); break; // ── Battery tick ────────────────────────────────────────────────────── case EVT_BATT_TICK: { // Charging/full GPIO state int charging = !gpio_get_level(BATT_CHARGE_GPIO); int batt_full = !gpio_get_level(BATT_FULL_GPIO); if (charging != g_charging || batt_full != g_batt_full) { g_charging = charging; g_batt_full = batt_full; oled_draw_header(); } // Low battery blink if (g_battery_pct >= 0 && g_battery_pct < BATT_LOW_PCT) { g_batt_blink = !g_batt_blink; oled_draw_header(); } // ADC sample sample_battery(); break; } // ── Peer expiry + players refresh ───────────────────────────────────── case EVT_PEER_TICK: for (int i = 0; i < MAX_BLE_PEERS; i++) { if (g_peers[i].active && esp_timer_get_time() - g_peers[i].last_seen > (int64_t)BLE_PEER_TIMEOUT * 1000LL) { #ifdef DEBUG printf("DBG PEER_EXPIRE slot=%d name=%-8.8s\n", i, g_peers[i].name); fflush(stdout); #endif if (g_active_menu == MENU_LIFE && g_life_select >= 3 && g_ble_enabled) { int slot = 0; for (int j = 0; j < i; j++) if (g_peers[j].active) slot++; if (g_life_select - 3 == slot) { g_life_select = 0; menus_draw(); } } g_peers[i].active = 0; } } if (g_active_menu == MENU_CMDR && g_ble_enabled) oled_draw_players(); // Handle pending remote reset if (g_reset_requested) { g_reset_requested = 0; game_reset(); MARK_DIRTY(); ble_adv_update(); oled_draw_header(); menus_draw(); led_sync(true); } break; // ── Autosave ────────────────────────────────────────────────────────── case EVT_AUTOSAVE: if (s_settings_dirty) { settings_save(); s_settings_dirty = false; } break; // ── Life delta timeout ──────────────────────────────────────────────── case EVT_LIFE_DELTA_EXP: if (menus[MENU_LIFE] && menus[MENU_LIFE]->on_tick) menus[MENU_LIFE]->on_tick(&evt); break; // ── Reset cmd broadcast end ─────────────────────────────────────────── case EVT_RESET_CMD_EXP: g_reset_cmd_ticks = 0; ble_adv_update(); break; // ── Idle sleep ──────────────────────────────────────────────────────── case EVT_IDLE_SLEEP: enter_deep_sleep(0, g_ble_enabled); break; // not reached // ── LED breathing frame ─────────────────────────────────────────────── case EVT_LED_BREATHE: { int bthresh = start_life_opts[g_start_life_index] / 4; if (g_life > 0 && g_life < bthresh) { float speed = 1.0f + ((float)bthresh - g_life) / (float)(bthresh - 1) * 2.0f; s_breath_phase += 2.0f * 3.14159265f * speed / 200.0f; uint8_t sc = (uint8_t)(127.5f + 127.5f * sinf(s_breath_phase)); led_update_for_count(g_life, sc); } break; } } // switch } // for(;;) }