feat(p7): scaffold coffre actuator behind CONFIG_ZACUS_P7_COFFRE_ENABLE (default off) #15

Merged
electron merged 1 commits from feat/p7-coffre-scaffold into main 2026-06-14 14:15:27 +00:00
8 changed files with 400 additions and 0 deletions
@@ -19,4 +19,5 @@ idf_component_register(
sd_storage
PRIV_REQUIRES
local_puzzles
p7_coffre
)
@@ -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];
@@ -0,0 +1,11 @@
idf_component_register(
SRCS
"p7_coffre.c"
INCLUDE_DIRS
"include"
REQUIRES
esp_driver_ledc
esp_driver_gpio
log
freertos
)
@@ -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
+232
View File
@@ -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
+1
View File
@@ -24,4 +24,5 @@ idf_component_register(
espressif__mdns
display_ui
puzzle_state
p7_coffre
)
+75
View File
@@ -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
+10
View File
@@ -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