diff --git a/idf_zacus/components/game_endpoint/CMakeLists.txt b/idf_zacus/components/game_endpoint/CMakeLists.txt index 7b95817..42cb33d 100644 --- a/idf_zacus/components/game_endpoint/CMakeLists.txt +++ b/idf_zacus/components/game_endpoint/CMakeLists.txt @@ -19,4 +19,5 @@ idf_component_register( sd_storage PRIV_REQUIRES local_puzzles + p7_coffre ) diff --git a/idf_zacus/components/game_endpoint/game_endpoint.c b/idf_zacus/components/game_endpoint/game_endpoint.c index 85b4e6e..4bd646f 100644 --- a/idf_zacus/components/game_endpoint/game_endpoint.c +++ b/idf_zacus/components/game_endpoint/game_endpoint.c @@ -30,6 +30,7 @@ #include "scenario_mesh.h" #include "puzzle_binding.h" #include "local_puzzles.h" +#include "p7_coffre.h" static const char *TAG = "game_endpoint"; @@ -334,6 +335,9 @@ static esp_err_t scenario_apply_buffer(const char *body, size_t len, local_puzzles_disarm(); s_current_step_id[0] = '\0'; memset(&s_current_scene, 0, sizeof(s_current_scene)); + // Re-arm the coffre for the new game session. + // p7_coffre_lock() is a no-op stub when CONFIG_ZACUS_P7_COFFRE_ENABLE=n. + p7_coffre_lock(); // Hot-reload-via-reboot until scenario_engine_reload() lands (Phase 3). schedule_restart(); @@ -767,6 +771,12 @@ esp_err_t game_endpoint_apply_step(const char *step_id, strncpy(s_current_step_id, step_id, sizeof(s_current_step_id) - 1); s_current_step_id[sizeof(s_current_step_id) - 1] = '\0'; + // P7 coffre: fire the actuator when the final win step is reached. + // p7_coffre_unlock() is a no-op stub when CONFIG_ZACUS_P7_COFFRE_ENABLE=n. + if (strcmp(step_id, "STEP_FINAL_WIN") == 0) { + p7_coffre_unlock(); + } + const char *armed = "none"; if (binding.type == PB_QR) { const char *ptrs[PB_MAX_CODES]; diff --git a/idf_zacus/components/p7_coffre/CMakeLists.txt b/idf_zacus/components/p7_coffre/CMakeLists.txt new file mode 100644 index 0000000..b0bbd92 --- /dev/null +++ b/idf_zacus/components/p7_coffre/CMakeLists.txt @@ -0,0 +1,11 @@ +idf_component_register( + SRCS + "p7_coffre.c" + INCLUDE_DIRS + "include" + REQUIRES + esp_driver_ledc + esp_driver_gpio + log + freertos +) diff --git a/idf_zacus/components/p7_coffre/include/p7_coffre.h b/idf_zacus/components/p7_coffre/include/p7_coffre.h new file mode 100644 index 0000000..56ec43d --- /dev/null +++ b/idf_zacus/components/p7_coffre/include/p7_coffre.h @@ -0,0 +1,60 @@ +// p7_coffre — unlocking actuator for the Zacus final chest (P7). +// +// When CONFIG_ZACUS_P7_COFFRE_ENABLE=n (default) all three functions are +// empty stubs (init returns ESP_OK, unlock/lock are no-ops) so callers do +// not need #ifdef guards. +// +// When enabled, call p7_coffre_init() once at boot, then p7_coffre_unlock() +// when STEP_FINAL_WIN is reached. p7_coffre_lock() re-arms between games. +// +// Actuator selection and GPIO are configured via Kconfig (menuconfig or +// sdkconfig): +// SERVO – LEDC TIMER_2 / CHANNEL_2, 50 Hz, 13-bit duty cycle. +// RELAY – plain GPIO output with optional one-shot pulse. + +#pragma once + +#include "esp_err.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * @brief Initialise the P7 coffre actuator. + * + * Servo: configures LEDC timer + channel and moves to the locked position. + * Relay: configures the GPIO as output and asserts the rest (inactive) state. + * Idempotent — safe to call more than once. + * + * When CONFIG_ZACUS_P7_COFFRE_ENABLE=n this is a stub that returns ESP_OK. + * + * @return ESP_OK on success, or a driver error code. + */ +esp_err_t p7_coffre_init(void); + +/** + * @brief Unlock the coffre (STEP_FINAL_WIN reached). + * + * Servo: moves to CONFIG_ZACUS_P7_SERVO_UNLOCK_DEG. + * Relay: energises the coil; if CONFIG_ZACUS_P7_RELAY_PULSE_MS > 0 the coil + * is de-energised after that many milliseconds (blocking call on the + * caller's task — keep short; 800 ms default). + * + * When CONFIG_ZACUS_P7_COFFRE_ENABLE=n this is a no-op. + */ +void p7_coffre_unlock(void); + +/** + * @brief Re-arm the coffre to the locked position (between games). + * + * Servo: moves back to CONFIG_ZACUS_P7_SERVO_LOCK_DEG. + * Relay: de-energises the coil (if not already de-energised by the pulse). + * + * When CONFIG_ZACUS_P7_COFFRE_ENABLE=n this is a no-op. + */ +void p7_coffre_lock(void); + +#ifdef __cplusplus +} +#endif diff --git a/idf_zacus/components/p7_coffre/p7_coffre.c b/idf_zacus/components/p7_coffre/p7_coffre.c new file mode 100644 index 0000000..712666e --- /dev/null +++ b/idf_zacus/components/p7_coffre/p7_coffre.c @@ -0,0 +1,232 @@ +// p7_coffre — P7 coffre unlocking actuator driver. +// +// All real implementation is inside #if CONFIG_ZACUS_P7_COFFRE_ENABLE so the +// object compiles to pure stubs when the flag is off (default). No #ifdef +// leaks into callers — they include p7_coffre.h and call the three functions +// unconditionally. + +#include "p7_coffre.h" + +#include "sdkconfig.h" +#include "esp_log.h" +#include "esp_err.h" + +static const char *TAG = "p7_coffre"; + +// ─── Servo duty helper ────────────────────────────────────────────────────── +// +// Maps an angle in degrees [0, 180] to a LEDC duty value. +// +// Timer parameters (compile-time constants): +// Period : 20 ms (50 Hz) +// Pulse range : 500 µs (0°) … 2500 µs (180°) — standard SG90 / MG90S +// Resolution : 13 bits → 8192 counts per 20 ms +// +// counts_per_us = 8192 / 20000 = 0.4096 +// duty(0°) = 500 * 0.4096 = 205 +// duty(90°) = 1500 * 0.4096 = 614 +// duty(180°) = 2500 * 0.4096 = 1024 + +#if CONFIG_ZACUS_P7_COFFRE_ENABLE + +#include "driver/ledc.h" +#include "driver/gpio.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" + +// LEDC resources — chosen to avoid conflicts with existing assignments: +// TIMER_0 / CHANNEL_0 = camera XCLK (qr_puzzle) +// TIMER_1 / CHANNEL_1 = display backlight (display_ui) +// TIMER_2 / CHANNEL_2 = P7 servo (this file) +#define P7_LEDC_TIMER LEDC_TIMER_2 +#define P7_LEDC_CHANNEL LEDC_CHANNEL_2 +#define P7_LEDC_MODE LEDC_LOW_SPEED_MODE +#define P7_LEDC_RES LEDC_TIMER_13_BIT // 8192 counts per period +#define P7_LEDC_FREQ_HZ 50u // 20 ms period + +// counts = pulse_us * (2^13 / 20000) +// Use integer arithmetic: counts = pulse_us * 8192 / 20000 +#define P7_SERVO_COUNTS(pulse_us) ((uint32_t)(pulse_us) * 8192u / 20000u) + +// Pulse width at each extreme (SG90-compatible). +#define P7_SERVO_PULSE_MIN_US 500u // 0° +#define P7_SERVO_PULSE_MAX_US 2500u // 180° + +// Convert angle [0, 180] to duty count. +static uint32_t angle_to_duty(int deg) { + if (deg < 0) deg = 0; + if (deg > 180) deg = 180; + uint32_t pulse_us = P7_SERVO_PULSE_MIN_US + + (uint32_t) deg * + (P7_SERVO_PULSE_MAX_US - P7_SERVO_PULSE_MIN_US) / 180u; + return P7_SERVO_COUNTS(pulse_us); +} + +static bool s_initialised = false; + +// ─── Servo path ───────────────────────────────────────────────────────────── + +#if CONFIG_ZACUS_P7_ACTUATOR_SERVO + +static esp_err_t servo_set_angle(int deg) { + uint32_t duty = angle_to_duty(deg); + esp_err_t err = ledc_set_duty(P7_LEDC_MODE, P7_LEDC_CHANNEL, duty); + if (err != ESP_OK) return err; + return ledc_update_duty(P7_LEDC_MODE, P7_LEDC_CHANNEL); +} + +esp_err_t p7_coffre_init(void) { + if (s_initialised) return ESP_OK; + + // Configure the 50 Hz timer. + ledc_timer_config_t timer_cfg = { + .speed_mode = P7_LEDC_MODE, + .duty_resolution = P7_LEDC_RES, + .timer_num = P7_LEDC_TIMER, + .freq_hz = P7_LEDC_FREQ_HZ, + .clk_cfg = LEDC_AUTO_CLK, + }; + esp_err_t err = ledc_timer_config(&timer_cfg); + if (err != ESP_OK) { + ESP_LOGE(TAG, "ledc_timer_config: %s", esp_err_to_name(err)); + return err; + } + + // Bind the channel to the GPIO at the lock angle. + uint32_t lock_duty = angle_to_duty(CONFIG_ZACUS_P7_SERVO_LOCK_DEG); + ledc_channel_config_t ch_cfg = { + .gpio_num = CONFIG_ZACUS_P7_GPIO, + .speed_mode = P7_LEDC_MODE, + .channel = P7_LEDC_CHANNEL, + .intr_type = LEDC_INTR_DISABLE, + .timer_sel = P7_LEDC_TIMER, + .duty = lock_duty, + .hpoint = 0, + }; + err = ledc_channel_config(&ch_cfg); + if (err != ESP_OK) { + ESP_LOGE(TAG, "ledc_channel_config: %s", esp_err_to_name(err)); + return err; + } + + s_initialised = true; + ESP_LOGI(TAG, "servo init OK (GPIO %d, lock=%d°, unlock=%d°, " + "LEDC TIMER_%d/CH_%d 50 Hz 13-bit)", + CONFIG_ZACUS_P7_GPIO, + CONFIG_ZACUS_P7_SERVO_LOCK_DEG, + CONFIG_ZACUS_P7_SERVO_UNLOCK_DEG, + (int) P7_LEDC_TIMER, (int) P7_LEDC_CHANNEL); + return ESP_OK; +} + +void p7_coffre_unlock(void) { + if (!s_initialised) { + ESP_LOGW(TAG, "unlock called before init — ignored"); + return; + } + esp_err_t err = servo_set_angle(CONFIG_ZACUS_P7_SERVO_UNLOCK_DEG); + if (err != ESP_OK) { + ESP_LOGE(TAG, "servo_set_angle(%d): %s", + CONFIG_ZACUS_P7_SERVO_UNLOCK_DEG, esp_err_to_name(err)); + } else { + ESP_LOGI(TAG, "coffre UNLOCKED (servo -> %d deg)", + CONFIG_ZACUS_P7_SERVO_UNLOCK_DEG); + } +} + +void p7_coffre_lock(void) { + if (!s_initialised) return; + esp_err_t err = servo_set_angle(CONFIG_ZACUS_P7_SERVO_LOCK_DEG); + if (err != ESP_OK) { + ESP_LOGE(TAG, "servo_set_angle(%d): %s", + CONFIG_ZACUS_P7_SERVO_LOCK_DEG, esp_err_to_name(err)); + } else { + ESP_LOGI(TAG, "coffre re-LOCKED (servo -> %d deg)", + CONFIG_ZACUS_P7_SERVO_LOCK_DEG); + } +} + +#endif // CONFIG_ZACUS_P7_ACTUATOR_SERVO + +// ─── Relay path ───────────────────────────────────────────────────────────── + +#if CONFIG_ZACUS_P7_ACTUATOR_RELAY + +// Helper: set the relay coil to active (energised) or rest. +// Handles active-high vs active-low inversion. +static void relay_set(bool active) { + int level; +#if CONFIG_ZACUS_P7_RELAY_ACTIVE_HIGH + level = active ? 1 : 0; +#else + level = active ? 0 : 1; +#endif + gpio_set_level(CONFIG_ZACUS_P7_GPIO, level); +} + +esp_err_t p7_coffre_init(void) { + if (s_initialised) return ESP_OK; + + gpio_config_t cfg = { + .pin_bit_mask = (1ULL << CONFIG_ZACUS_P7_GPIO), + .mode = GPIO_MODE_OUTPUT, + .pull_up_en = GPIO_PULLUP_DISABLE, + .pull_down_en = GPIO_PULLDOWN_DISABLE, + .intr_type = GPIO_INTR_DISABLE, + }; + esp_err_t err = gpio_config(&cfg); + if (err != ESP_OK) { + ESP_LOGE(TAG, "gpio_config(GPIO %d): %s", + CONFIG_ZACUS_P7_GPIO, esp_err_to_name(err)); + return err; + } + + // Start in rest (de-energised) state. + relay_set(false); + s_initialised = true; + ESP_LOGI(TAG, "relay init OK (GPIO %d, active-%s, pulse=%d ms)", + CONFIG_ZACUS_P7_GPIO, + CONFIG_ZACUS_P7_RELAY_ACTIVE_HIGH ? "HIGH" : "LOW", + CONFIG_ZACUS_P7_RELAY_PULSE_MS); + return ESP_OK; +} + +void p7_coffre_unlock(void) { + if (!s_initialised) { + ESP_LOGW(TAG, "unlock called before init — ignored"); + return; + } + relay_set(true); + ESP_LOGI(TAG, "coffre UNLOCKED (relay energised)"); + +#if CONFIG_ZACUS_P7_RELAY_PULSE_MS > 0 + vTaskDelay(pdMS_TO_TICKS(CONFIG_ZACUS_P7_RELAY_PULSE_MS)); + relay_set(false); + ESP_LOGI(TAG, "coffre relay de-energised after %d ms pulse", + CONFIG_ZACUS_P7_RELAY_PULSE_MS); +#endif +} + +void p7_coffre_lock(void) { + if (!s_initialised) return; + relay_set(false); + ESP_LOGI(TAG, "coffre relay de-energised (lock/re-arm)"); +} + +#endif // CONFIG_ZACUS_P7_ACTUATOR_RELAY + +#else // CONFIG_ZACUS_P7_COFFRE_ENABLE not set — emit stubs only + +esp_err_t p7_coffre_init(void) { + return ESP_OK; +} + +void p7_coffre_unlock(void) { + /* stub — CONFIG_ZACUS_P7_COFFRE_ENABLE=n */ +} + +void p7_coffre_lock(void) { + /* stub — CONFIG_ZACUS_P7_COFFRE_ENABLE=n */ +} + +#endif // CONFIG_ZACUS_P7_COFFRE_ENABLE diff --git a/idf_zacus/main/CMakeLists.txt b/idf_zacus/main/CMakeLists.txt index b4758cf..beadb81 100644 --- a/idf_zacus/main/CMakeLists.txt +++ b/idf_zacus/main/CMakeLists.txt @@ -24,4 +24,5 @@ idf_component_register( espressif__mdns display_ui puzzle_state + p7_coffre ) diff --git a/idf_zacus/main/Kconfig.projbuild b/idf_zacus/main/Kconfig.projbuild new file mode 100644 index 0000000..0f88eab --- /dev/null +++ b/idf_zacus/main/Kconfig.projbuild @@ -0,0 +1,75 @@ +menu "Zacus Game Hardware" + + config ZACUS_P7_COFFRE_ENABLE + bool "Enable P7 coffre actuator (servo or relay)" + default n + help + Drives the unlocking actuator for the final chest (P7) when + STEP_FINAL_WIN is reached. When disabled all p7_coffre_*() + functions are empty stubs so callers compile without #ifdef. + Enable once the actuator (9g servo or 5V relay) is wired up. + + if ZACUS_P7_COFFRE_ENABLE + + choice ZACUS_P7_ACTUATOR_TYPE + prompt "Actuator type" + default ZACUS_P7_ACTUATOR_SERVO + help + Select the hardware wired to CONFIG_ZACUS_P7_GPIO. + SERVO – 50 Hz PWM via LEDC (TIMER_2 / CHANNEL_2). + RELAY – logic-level GPIO output (active-high or active-low). + + config ZACUS_P7_ACTUATOR_SERVO + bool "Servo PWM (9g, 50 Hz LEDC)" + config ZACUS_P7_ACTUATOR_RELAY + bool "Relay / solid-state switch (GPIO)" + endchoice + + config ZACUS_P7_GPIO + int "GPIO pin for the actuator" + default 13 + range 0 48 + help + GPIO number for the servo signal wire or the relay control + input. Default 13 is free on the Freenove Media Kit board. + + if ZACUS_P7_ACTUATOR_SERVO + config ZACUS_P7_SERVO_LOCK_DEG + int "Servo lock angle (degrees, 0-180)" + default 0 + range 0 180 + help + Angle at which the servo holds the coffre locked. + Corresponds to a 500-2500 µs pulse on a 20 ms period. + + config ZACUS_P7_SERVO_UNLOCK_DEG + int "Servo unlock angle (degrees, 0-180)" + default 90 + range 0 180 + help + Angle driven on STEP_FINAL_WIN to open the coffre. + endif + + if ZACUS_P7_ACTUATOR_RELAY + config ZACUS_P7_RELAY_ACTIVE_HIGH + bool "Relay active-HIGH (coil energised when GPIO=1)" + default y + help + Set y for most 5V relay modules with active-high inputs. + Set n for active-low modules (e.g. opto-coupled boards + where the relay fires when the pin is pulled LOW). + + config ZACUS_P7_RELAY_PULSE_MS + int "Relay pulse duration (ms, 0 = hold permanently)" + default 800 + range 0 10000 + help + If > 0 the relay is energised for this many milliseconds + then returned to rest (one-shot electronic latch). + Set 0 to keep the relay energised until p7_coffre_lock() + is called (e.g. when a magnetic solenoid holds the bolt). + endif + + endif # ZACUS_P7_COFFRE_ENABLE + +endmenu diff --git a/idf_zacus/main/main.c b/idf_zacus/main/main.c index 3b72fd1..5e62331 100644 --- a/idf_zacus/main/main.c +++ b/idf_zacus/main/main.c @@ -55,6 +55,7 @@ #include "mic_broker.h" #include "board_pins_mediakit.h" #include "display_ui.h" +#include "p7_coffre.h" // Hints engine endpoint (slice 5). Hardcoded for now — slice 7 will move // this to NVS so the field operator can repoint the firmware without a flash. @@ -587,6 +588,15 @@ void app_main(void) { local_puzzles_init(&s_pstate); ESP_LOGI(TAG, "puzzle_state + local_puzzles initialised"); + // P7 coffre actuator: configure GPIO/LEDC at boot so the + // first call to p7_coffre_unlock() (on STEP_FINAL_WIN) has + // zero warm-up latency. A no-op stub when + // CONFIG_ZACUS_P7_COFFRE_ENABLE=n (default). + esp_err_t coffre_err = p7_coffre_init(); + if (coffre_err != ESP_OK) { + ESP_LOGW(TAG, "p7_coffre_init: %s", esp_err_to_name(coffre_err)); + } + // Task 7: mic_broker takes ownership of the Media Kit I2S IN // pins (3/14/46 per board_pins_mediakit.h) BEFORE // voice_pipeline_init — the pipeline's own init call then