diff --git a/idf_zacus/components/game_endpoint/CMakeLists.txt b/idf_zacus/components/game_endpoint/CMakeLists.txt index 42cb33d..8795046 100644 --- a/idf_zacus/components/game_endpoint/CMakeLists.txt +++ b/idf_zacus/components/game_endpoint/CMakeLists.txt @@ -20,4 +20,6 @@ idf_component_register( PRIV_REQUIRES local_puzzles p7_coffre + p5_morse + p6_nfc ) diff --git a/idf_zacus/components/game_endpoint/game_endpoint.c b/idf_zacus/components/game_endpoint/game_endpoint.c index 4bd646f..ffb53d7 100644 --- a/idf_zacus/components/game_endpoint/game_endpoint.c +++ b/idf_zacus/components/game_endpoint/game_endpoint.c @@ -31,6 +31,8 @@ #include "puzzle_binding.h" #include "local_puzzles.h" #include "p7_coffre.h" +#include "p5_morse.h" +#include "p6_nfc.h" static const char *TAG = "game_endpoint"; @@ -39,11 +41,40 @@ static const char *TAG = "game_endpoint"; static puzzle_state_t *s_pstate = NULL; // Last step id armed via POST /game/step. Empty string = none. static char s_current_step_id[64] = {0}; -// Last armed puzzle type: "qr" | "sound" | "none" | "" (empty = nothing armed yet). -static char s_current_armed[8] = {0}; +// Last armed puzzle type: "qr" | "sound" | "morse" | "nfc" | "none" | "" +static char s_current_armed[12] = {0}; // "qr"|"sound"|"morse"|"nfc"|"none" // Display scene metadata of the current step (lenient parse; defaults safe). static scene_binding_t s_current_scene = {0}; +// ─── P5 / P6 solved-callback context ──────────────────────────────────────── +// Mirrors the on_qr_solved / on_sound_solved pattern in local_puzzles.c: +// context is copied at arm time, callback is called on the puzzle task. + +typedef struct { + uint8_t id; + uint8_t frag[PB_MAX_FRAG]; + uint8_t frag_len; +} ep_puzzle_ctx_t; + +static ep_puzzle_ctx_t s_morse_ctx = {0}; +static ep_puzzle_ctx_t s_nfc_ctx = {0}; + +static void on_morse_solved(void) +{ + if (s_pstate) { + puzzle_state_report(s_pstate, s_morse_ctx.id, + s_morse_ctx.frag, s_morse_ctx.frag_len); + } +} + +static void on_nfc_solved(void) +{ + if (s_pstate) { + puzzle_state_report(s_pstate, s_nfc_ctx.id, + s_nfc_ctx.frag, s_nfc_ctx.frag_len); + } +} + // Whitelist mirrored in the 4xx error message so the operator can // recover without grepping the source. Keep aligned with // hints_client_set_group_profile() validation. @@ -333,6 +364,8 @@ static esp_err_t scenario_apply_buffer(const char *body, size_t len, // New scenario supersedes any live puzzle — disarm and clear remembered step. local_puzzles_disarm(); + p5_morse_disarm(); // no-op when CONFIG_ZACUS_P5_MORSE_ENABLE=n + p6_nfc_disarm(); // no-op when CONFIG_ZACUS_P6_NFC_ENABLE=n s_current_step_id[0] = '\0'; memset(&s_current_scene, 0, sizeof(s_current_scene)); // Re-arm the coffre for the new game session. @@ -727,7 +760,7 @@ static esp_err_t handle_espnow_cmd_post(httpd_req_t *req) { // Core step-change logic, shared by the HTTP handler and internal callers // (e.g. shell app launch on the local display). Error contract: -// ESP_OK armed (armed_out = "qr"|"sound"|"none") +// ESP_OK armed (armed_out = "qr"|"sound"|"morse"|"nfc"|"none") // ESP_ERR_INVALID_STATE not ready (no puzzle_state wired) // ESP_ERR_NOT_SUPPORTED no scenario stored / storage unavailable // ESP_ERR_NOT_FOUND unknown step id @@ -758,6 +791,9 @@ esp_err_t game_endpoint_apply_step(const char *step_id, // Disarm current puzzle before re-arming. local_puzzles_disarm(); + // Disarm P5/P6 stubs (no-ops when their CONFIG flags are off). + p5_morse_disarm(); + p6_nfc_disarm(); // Parse binding for this step (+ display scene metadata, lenient). puzzle_binding_t binding; @@ -804,6 +840,26 @@ esp_err_t game_endpoint_apply_step(const char *step_id, if (aerr == ESP_ERR_INVALID_STATE) return ESP_ERR_TIMEOUT; if (aerr != ESP_OK) return aerr; armed = "sound"; + } else if (binding.type == PB_MORSE) { + // P5 morse: copy context then arm. + // p5_morse_arm() is a no-op stub when CONFIG_ZACUS_P5_MORSE_ENABLE=n. + s_morse_ctx.id = binding.id; + s_morse_ctx.frag_len = binding.fragment_len; + if (binding.fragment_len > 0) { + memcpy(s_morse_ctx.frag, binding.fragment, binding.fragment_len); + } + p5_morse_arm(binding.morse_expected, on_morse_solved); + armed = "morse"; + } else if (binding.type == PB_NFC) { + // P6 nfc: copy context then arm. + // p6_nfc_arm() is a no-op stub when CONFIG_ZACUS_P6_NFC_ENABLE=n. + s_nfc_ctx.id = binding.id; + s_nfc_ctx.frag_len = binding.fragment_len; + if (binding.fragment_len > 0) { + memcpy(s_nfc_ctx.frag, binding.fragment, binding.fragment_len); + } + p6_nfc_arm(binding.nfc_uid, on_nfc_solved); + armed = "nfc"; } strncpy(s_current_armed, armed, sizeof(s_current_armed) - 1); diff --git a/idf_zacus/components/game_endpoint/include/puzzle_binding.h b/idf_zacus/components/game_endpoint/include/puzzle_binding.h index 2b16c88..4415a79 100644 --- a/idf_zacus/components/game_endpoint/include/puzzle_binding.h +++ b/idf_zacus/components/game_endpoint/include/puzzle_binding.h @@ -11,7 +11,7 @@ #define PB_MAX_FRAG 4 // = PUZZLE_MAX_FRAG #define PB_MAX_PUZZLE_ID 8 // = PUZZLE_MAX_ID -typedef enum { PB_NONE = 0, PB_QR, PB_SOUND } puzzle_binding_type_t; +typedef enum { PB_NONE = 0, PB_QR, PB_SOUND, PB_MORSE, PB_NFC } puzzle_binding_type_t; typedef struct { puzzle_binding_type_t type; // PB_NONE if the step has no puzzle @@ -23,6 +23,10 @@ typedef struct { int melody[PB_MAX_NOTES]; size_t note_count; int tolerance; // default 1 + // morse (P5): expected word in uppercase, e.g. "ZACUS" + char morse_expected[32]; + // nfc (P6): expected tag UID as colon-hex string, e.g. "A1:B2:C3:D4" + char nfc_uid[20]; // common uint8_t fragment[PB_MAX_FRAG]; uint8_t fragment_len; diff --git a/idf_zacus/components/game_endpoint/puzzle_binding.c b/idf_zacus/components/game_endpoint/puzzle_binding.c index 3a56079..279e093 100644 --- a/idf_zacus/components/game_endpoint/puzzle_binding.c +++ b/idf_zacus/components/game_endpoint/puzzle_binding.c @@ -60,6 +60,10 @@ esp_err_t puzzle_binding_from_ir(const char *ir_json, const char *step_id, ptype = PB_QR; } else if (strcmp(jtype->valuestring, "sound") == 0) { ptype = PB_SOUND; + } else if (strcmp(jtype->valuestring, "morse") == 0) { + ptype = PB_MORSE; + } else if (strcmp(jtype->valuestring, "nfc") == 0) { + ptype = PB_NFC; } else { cJSON_Delete(root); memset(out, 0, sizeof(*out)); @@ -120,7 +124,7 @@ esp_err_t puzzle_binding_from_ir(const char *ir_json, const char *step_id, out->codes[i][PB_MAX_LABEL - 1] = '\0'; } out->code_count = (size_t)n; - } else { // PB_SOUND + } else if (ptype == PB_SOUND) { const cJSON *jmelody = cJSON_GetObjectItemCaseSensitive(puzzle, "melody"); if (!cJSON_IsArray(jmelody)) { cJSON_Delete(root); @@ -156,6 +160,37 @@ esp_err_t puzzle_binding_from_ir(const char *ir_json, const char *step_id, } else { out->tolerance = 1; } + } else if (ptype == PB_MORSE) { + // puzzle.expected: required string, uppercase word, e.g. "ZACUS" + const cJSON *jexp = cJSON_GetObjectItemCaseSensitive(puzzle, "expected"); + if (!cJSON_IsString(jexp) || !jexp->valuestring || !jexp->valuestring[0]) { + cJSON_Delete(root); + memset(out, 0, sizeof(*out)); + return ESP_ERR_INVALID_ARG; + } + if (strlen(jexp->valuestring) >= sizeof(out->morse_expected)) { + cJSON_Delete(root); + memset(out, 0, sizeof(*out)); + return ESP_ERR_INVALID_ARG; + } + strncpy(out->morse_expected, jexp->valuestring, + sizeof(out->morse_expected) - 1); + out->morse_expected[sizeof(out->morse_expected) - 1] = '\0'; + } else if (ptype == PB_NFC) { + // puzzle.uid: required string, colon-hex UID, e.g. "A1:B2:C3:D4" + const cJSON *juid = cJSON_GetObjectItemCaseSensitive(puzzle, "uid"); + if (!cJSON_IsString(juid) || !juid->valuestring || !juid->valuestring[0]) { + cJSON_Delete(root); + memset(out, 0, sizeof(*out)); + return ESP_ERR_INVALID_ARG; + } + if (strlen(juid->valuestring) >= sizeof(out->nfc_uid)) { + cJSON_Delete(root); + memset(out, 0, sizeof(*out)); + return ESP_ERR_INVALID_ARG; + } + strncpy(out->nfc_uid, juid->valuestring, sizeof(out->nfc_uid) - 1); + out->nfc_uid[sizeof(out->nfc_uid) - 1] = '\0'; } // id/type written last — all error exits above leave *out zeroed diff --git a/idf_zacus/components/p5_morse/CMakeLists.txt b/idf_zacus/components/p5_morse/CMakeLists.txt new file mode 100644 index 0000000..09bf850 --- /dev/null +++ b/idf_zacus/components/p5_morse/CMakeLists.txt @@ -0,0 +1,10 @@ +idf_component_register( + SRCS + "p5_morse.c" + INCLUDE_DIRS + "include" + REQUIRES + esp_driver_gpio + log + freertos +) diff --git a/idf_zacus/components/p5_morse/include/p5_morse.h b/idf_zacus/components/p5_morse/include/p5_morse.h new file mode 100644 index 0000000..71e50d6 --- /dev/null +++ b/idf_zacus/components/p5_morse/include/p5_morse.h @@ -0,0 +1,68 @@ +// p5_morse.h — Morse / telegraph-key puzzle driver for Zacus master (P5). +// +// When CONFIG_ZACUS_P5_MORSE_ENABLE=n (default) all functions are empty stubs +// (init returns ESP_OK, arm/disarm are no-ops) so callers need no #ifdef guards. +// +// When enabled: +// 1. Call p5_morse_init() once at boot (after gpio driver is available). +// 2. Call p5_morse_arm(expected, puzzle_id, fragment, frag_len) when the +// gateway POSTs /game/step with a "morse" puzzle. +// 3. A FreeRTOS task decodes dot/dash timing on CONFIG_ZACUS_P5_GPIO and +// calls the solved_cb supplied to p5_morse_arm() when the decoded word +// matches `expected`. +// 4. Call p5_morse_disarm() on step change or game reset. +// +// Timing constants are set via Kconfig (DOT_MS, DASH_MS, GAP_MS). +// The key is active-LOW by default (CONFIG_ZACUS_P5_ACTIVE_LOW=y). + +#pragma once + +#include "esp_err.h" +#include + +#ifdef __cplusplus +extern "C" { +#endif + +// Callback type invoked on the p5_morse task when the sequence is solved. +// Must be ISR-safe in the sense that it runs inside the morse task context; +// do NOT call p5_morse_arm/disarm from it — defer via a task notification. +typedef void (*p5_morse_solved_cb_t)(void); + +/** + * @brief Initialise the P5 Morse puzzle driver. + * + * Configures CONFIG_ZACUS_P5_GPIO as input with pull-up and spawns the + * morse decoding task (blocked until p5_morse_arm() is called). + * Idempotent — safe to call more than once. + * + * When CONFIG_ZACUS_P5_MORSE_ENABLE=n returns ESP_OK immediately. + * + * @return ESP_OK on success, or a driver error code. + */ +esp_err_t p5_morse_init(void); + +/** + * @brief Arm the morse puzzle for a specific expected sequence. + * + * @param expected NUL-terminated Morse code string to match, + * e.g. "...-" (upper-case letters via kMorseTable) + * OR a plain word like "ZACUS" (decoded internally). + * The string is copied — caller buffer may be transient. + * @param solved_cb Callback invoked exactly once when the sequence matches. + * Pass NULL to skip the notification (e.g. during tests). + * + * When CONFIG_ZACUS_P5_MORSE_ENABLE=n this is a no-op. + */ +void p5_morse_arm(const char *expected, p5_morse_solved_cb_t solved_cb); + +/** + * @brief Disarm the puzzle (stop decoding, reset state). + * + * When CONFIG_ZACUS_P5_MORSE_ENABLE=n this is a no-op. + */ +void p5_morse_disarm(void); + +#ifdef __cplusplus +} +#endif diff --git a/idf_zacus/components/p5_morse/p5_morse.c b/idf_zacus/components/p5_morse/p5_morse.c new file mode 100644 index 0000000..d85029b --- /dev/null +++ b/idf_zacus/components/p5_morse/p5_morse.c @@ -0,0 +1,265 @@ +// p5_morse.c — P5 Morse / telegraph-key puzzle driver. +// +// All real implementation is inside #if CONFIG_ZACUS_P5_MORSE_ENABLE so the +// object compiles to pure stubs when the flag is off (default). No #ifdef +// leaks into callers — they include p5_morse.h and call unconditionally. +// +// Logic adapted from puzzles/p5_morse/main/main.c (standalone ESP-NOW +// firmware), repackaged as a library with an arm/disarm API and a solved +// callback instead of the espnow_slave_notify_solved path. + +#include "p5_morse.h" + +#include "sdkconfig.h" +#include "esp_log.h" +#include "esp_err.h" + +static const char *TAG = "p5_morse"; + +#if CONFIG_ZACUS_P5_MORSE_ENABLE + +#include "driver/gpio.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include +#include + +// ─── Morse table A-Z ──────────────────────────────────────────────────────── + +static const char *kMorseTable[26] = { + ".-", "-...", "-.-.", "-..", ".", "..-.", "--.", + "....", "..", ".---", "-.-", ".-..", "--", "-.", + "---", ".--.", "--.-", ".-.", "...", "-", "..-", + "...-", ".--", "-..-", "-.--", "--.." +}; + +// ─── State ────────────────────────────────────────────────────────────────── + +// Expected word (decoded as letters, uppercase, e.g. "ZACUS"). +static char s_expected[32] = {0}; +// Accumulator for letters decoded so far. +static char s_received[32] = {0}; +static uint8_t s_recv_pos = 0; +// Current symbol dot/dash buffer. +static char s_symbol[16] = {0}; +static uint8_t s_sym_pos = 0; + +static volatile bool s_armed = false; +static volatile bool s_solved = false; + +static p5_morse_solved_cb_t s_solved_cb = NULL; + +static bool s_initialised = false; + +static TaskHandle_t s_task_handle = NULL; + +// ─── Helpers ──────────────────────────────────────────────────────────────── + +static uint32_t now_ms(void) +{ + return (uint32_t)(xTaskGetTickCount() * portTICK_PERIOD_MS); +} + +static bool key_pressed(void) +{ +#if CONFIG_ZACUS_P5_ACTIVE_LOW + return (gpio_get_level(CONFIG_ZACUS_P5_GPIO) == 0); +#else + return (gpio_get_level(CONFIG_ZACUS_P5_GPIO) != 0); +#endif +} + +// Decode accumulated symbol (dot/dash string) to a letter and append it. +static void decode_symbol(void) +{ + if (s_sym_pos == 0) return; + s_symbol[s_sym_pos] = '\0'; + + for (int i = 0; i < 26; i++) { + if (strcmp(kMorseTable[i], s_symbol) == 0) { + char letter = (char)('A' + i); + if (s_recv_pos < (uint8_t)(sizeof(s_received) - 1)) { + s_received[s_recv_pos++] = letter; + s_received[s_recv_pos] = '\0'; + } + ESP_LOGD(TAG, "decoded: %s → %c (so far: %s)", + s_symbol, letter, s_received); + break; + } + } + s_sym_pos = 0; + memset(s_symbol, 0, sizeof(s_symbol)); +} + +// Check whether the received word matches; invoke callback on match. +static void check_word(void) +{ + if (strcmp(s_received, s_expected) == 0) { + s_solved = true; + ESP_LOGI(TAG, "SOLVED: \"%s\" decoded correctly", s_expected); + if (s_solved_cb) { + s_solved_cb(); + } + } else { + ESP_LOGW(TAG, "wrong word: got \"%s\", expected \"%s\" — resetting", + s_received, s_expected); + s_recv_pos = 0; + memset(s_received, 0, sizeof(s_received)); + } +} + +// ─── Morse decoding task ───────────────────────────────────────────────────── + +static void morse_task(void *arg) +{ + (void)arg; + + uint32_t press_start = 0; + uint32_t last_release = 0; + bool key_down = false; + + for (;;) { + if (!s_armed || s_solved) { + // Idle: yield and wait for the next arm cycle. + vTaskDelay(pdMS_TO_TICKS(50)); + continue; + } + + uint32_t t = now_ms(); + bool pressed = key_pressed(); + + if (pressed && !key_down) { + // Key press start. + key_down = true; + press_start = t; + + } else if (!pressed && key_down) { + // Key release — classify as dot or dash. + key_down = false; + uint32_t duration = t - press_start; + last_release = t; + + if (duration <= (uint32_t)CONFIG_ZACUS_P5_DOT_MS) { + if (s_sym_pos < (uint8_t)(sizeof(s_symbol) - 1)) + s_symbol[s_sym_pos++] = '.'; + ESP_LOGD(TAG, "dot (%lu ms)", (unsigned long)duration); + } else if (duration >= (uint32_t)CONFIG_ZACUS_P5_DASH_MS) { + if (s_sym_pos < (uint8_t)(sizeof(s_symbol) - 1)) + s_symbol[s_sym_pos++] = '-'; + ESP_LOGD(TAG, "dash (%lu ms)", (unsigned long)duration); + } else { + ESP_LOGD(TAG, "ignored press %lu ms (between dot and dash thresholds)", + (unsigned long)duration); + } + + } else if (!key_down && last_release > 0) { + uint32_t silence = t - last_release; + + // Letter gap: decode accumulated symbol. + if (silence > (uint32_t)CONFIG_ZACUS_P5_GAP_MS && s_sym_pos > 0) { + decode_symbol(); + last_release = t; + } + // Word gap (2x letter gap): check assembled word. + if (silence > (uint32_t)(CONFIG_ZACUS_P5_GAP_MS * 2u) && s_recv_pos > 0) { + check_word(); + last_release = 0; + } + } + + vTaskDelay(pdMS_TO_TICKS(10)); // 100 Hz sampling + } +} + +// ─── Public API ───────────────────────────────────────────────────────────── + +esp_err_t p5_morse_init(void) +{ + if (s_initialised) return ESP_OK; + + gpio_config_t cfg = { + .pin_bit_mask = (1ULL << CONFIG_ZACUS_P5_GPIO), + .mode = GPIO_MODE_INPUT, + .pull_up_en = GPIO_PULLUP_ENABLE, + .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_P5_GPIO, esp_err_to_name(err)); + return err; + } + + BaseType_t rc = xTaskCreate(morse_task, "p5_morse", 3072, NULL, 5, + &s_task_handle); + if (rc != pdPASS) { + ESP_LOGE(TAG, "xTaskCreate(p5_morse) failed"); + return ESP_ERR_NO_MEM; + } + + s_initialised = true; + ESP_LOGI(TAG, "init OK (GPIO %d, dot<=%d ms, dash>=%d ms, gap>%d ms)", + CONFIG_ZACUS_P5_GPIO, + CONFIG_ZACUS_P5_DOT_MS, + CONFIG_ZACUS_P5_DASH_MS, + CONFIG_ZACUS_P5_GAP_MS); + return ESP_OK; +} + +void p5_morse_arm(const char *expected, p5_morse_solved_cb_t solved_cb) +{ + if (!s_initialised) { + ESP_LOGW(TAG, "arm called before init — ignored"); + return; + } + // Reset state. + s_armed = false; // pause task briefly while we reset + s_solved = false; + s_recv_pos = 0; + s_sym_pos = 0; + memset(s_received, 0, sizeof(s_received)); + memset(s_symbol, 0, sizeof(s_symbol)); + + if (expected && expected[0]) { + strncpy(s_expected, expected, sizeof(s_expected) - 1); + s_expected[sizeof(s_expected) - 1] = '\0'; + } else { + s_expected[0] = '\0'; + } + s_solved_cb = solved_cb; + s_armed = true; + ESP_LOGI(TAG, "armed, expected \"%s\"", s_expected); +} + +void p5_morse_disarm(void) +{ + s_armed = false; + s_solved = false; + s_recv_pos = 0; + s_sym_pos = 0; + memset(s_received, 0, sizeof(s_received)); + memset(s_symbol, 0, sizeof(s_symbol)); + ESP_LOGI(TAG, "disarmed"); +} + +#else // CONFIG_ZACUS_P5_MORSE_ENABLE not set — emit stubs only + +esp_err_t p5_morse_init(void) +{ + return ESP_OK; +} + +void p5_morse_arm(const char *expected, p5_morse_solved_cb_t solved_cb) +{ + (void)expected; + (void)solved_cb; + /* stub — CONFIG_ZACUS_P5_MORSE_ENABLE=n */ +} + +void p5_morse_disarm(void) +{ + /* stub — CONFIG_ZACUS_P5_MORSE_ENABLE=n */ +} + +#endif // CONFIG_ZACUS_P5_MORSE_ENABLE diff --git a/idf_zacus/components/p6_nfc/CMakeLists.txt b/idf_zacus/components/p6_nfc/CMakeLists.txt new file mode 100644 index 0000000..0a03f15 --- /dev/null +++ b/idf_zacus/components/p6_nfc/CMakeLists.txt @@ -0,0 +1,12 @@ +idf_component_register( + SRCS + "p6_nfc.c" + INCLUDE_DIRS + "include" + REQUIRES + esp_driver_gpio + esp_driver_i2c + esp_driver_spi + log + freertos +) diff --git a/idf_zacus/components/p6_nfc/include/p6_nfc.h b/idf_zacus/components/p6_nfc/include/p6_nfc.h new file mode 100644 index 0000000..eb1f32c --- /dev/null +++ b/idf_zacus/components/p6_nfc/include/p6_nfc.h @@ -0,0 +1,72 @@ +// p6_nfc.h — NFC / alchemical-symbols puzzle driver for Zacus master (P6). +// +// When CONFIG_ZACUS_P6_NFC_ENABLE=n (default) all functions are empty stubs +// (init returns ESP_OK, arm/disarm are no-ops) so callers need no #ifdef guards. +// +// When enabled: +// 1. Call p6_nfc_init() once at boot. +// 2. Call p6_nfc_arm(expected_uid, solved_cb) when the gateway POSTs +// /game/step with a "nfc" puzzle. `expected_uid` is a hex UID string +// like "A1:B2:C3:D4" (4-byte NTAG213) or a space-separated sequence +// of UIDs for ordered multi-tag placement. +// 3. A FreeRTOS task polls the NFC reader at ~5 Hz and calls solved_cb +// when the presented tag UID matches (or the full sequence is placed). +// 4. Call p6_nfc_disarm() on step change or game reset. +// +// Hardware backend is selected via Kconfig: +// PN532 over I2C or SPI → CONFIG_ZACUS_P6_READER_PN532 +// PN7150 over I2C → CONFIG_ZACUS_P6_READER_PN7150 +// +// The hardware HAL is STUBBED in this scaffold revision: the read function +// compiles, links, and logs "NFC read (stub)" but returns no UIDs until the +// real HAL is wired in. This is the same pattern as media_manager stubs. + +#pragma once + +#include "esp_err.h" +#include + +#ifdef __cplusplus +extern "C" { +#endif + +// Callback type invoked on the p6_nfc task when the tag / sequence is solved. +// Must not call p6_nfc_arm/disarm from within — defer via task notification. +typedef void (*p6_nfc_solved_cb_t)(void); + +/** + * @brief Initialise the P6 NFC puzzle driver. + * + * Initialises the configured bus (I2C or SPI) and the NFC reader IC. + * Spawns the NFC polling task (blocked until p6_nfc_arm() is called). + * Idempotent — safe to call more than once. + * + * When CONFIG_ZACUS_P6_NFC_ENABLE=n returns ESP_OK immediately. + * + * @return ESP_OK on success, or a driver error code. + */ +esp_err_t p6_nfc_init(void); + +/** + * @brief Arm the NFC puzzle for a specific expected tag UID or sequence. + * + * @param expected_uid NUL-terminated UID string to match. Format: + * single tag → "A1:B2:C3:D4" + * The string is copied — caller buffer may be transient. + * @param solved_cb Callback invoked exactly once on a match. + * Pass NULL to skip (e.g. during tests). + * + * When CONFIG_ZACUS_P6_NFC_ENABLE=n this is a no-op. + */ +void p6_nfc_arm(const char *expected_uid, p6_nfc_solved_cb_t solved_cb); + +/** + * @brief Disarm the puzzle (stop polling, reset state). + * + * When CONFIG_ZACUS_P6_NFC_ENABLE=n this is a no-op. + */ +void p6_nfc_disarm(void); + +#ifdef __cplusplus +} +#endif diff --git a/idf_zacus/components/p6_nfc/p6_nfc.c b/idf_zacus/components/p6_nfc/p6_nfc.c new file mode 100644 index 0000000..9309132 --- /dev/null +++ b/idf_zacus/components/p6_nfc/p6_nfc.c @@ -0,0 +1,290 @@ +// p6_nfc.c — P6 NFC / alchemical-symbols puzzle driver. +// +// All real implementation is inside #if CONFIG_ZACUS_P6_NFC_ENABLE so the +// object compiles to pure stubs when the flag is off (default). No #ifdef +// leaks into callers. +// +// Hardware HAL for the NFC reader is STUBBED: nfc_hal_read_uid() returns +// false (no card present) and logs once per arm cycle. The real HAL will be +// implemented when the PN532/PN7150 module is wired (replace the stub block +// marked with "STUB" below). +// +// Logic adapted from puzzles/p6_symboles_nfc/main/main.c, repackaged as a +// library with arm/disarm API and a solved callback. + +#include "p6_nfc.h" + +#include "sdkconfig.h" +#include "esp_log.h" +#include "esp_err.h" + +static const char *TAG = "p6_nfc"; + +#if CONFIG_ZACUS_P6_NFC_ENABLE + +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include +#include +#include + +// ─── I2C / SPI bus init (HAL layer) ───────────────────────────────────────── +// +// The bus init and NFC read are separated into nfc_hal_init() / +// nfc_hal_read_uid() so the stub is easy to swap for real silicon code. + +#if CONFIG_ZACUS_P6_BUS_I2C +#include "driver/i2c_master.h" + +static i2c_master_bus_handle_t s_i2c_bus = NULL; +static i2c_master_dev_handle_t s_i2c_dev = NULL; + +static esp_err_t nfc_hal_init(void) +{ + i2c_master_bus_config_t bus_cfg = { + .i2c_port = I2C_NUM_0, + .sda_io_num = CONFIG_ZACUS_P6_I2C_SDA, + .scl_io_num = CONFIG_ZACUS_P6_I2C_SCL, + .clk_source = I2C_CLK_SRC_DEFAULT, + .glitch_ignore_cnt = 7, + .flags.enable_internal_pullup = true, + }; + esp_err_t err = i2c_new_master_bus(&bus_cfg, &s_i2c_bus); + if (err != ESP_OK) { + ESP_LOGE(TAG, "i2c_new_master_bus: %s", esp_err_to_name(err)); + return err; + } + + i2c_device_config_t dev_cfg = { + .dev_addr_length = I2C_ADDR_BIT_LEN_7, + .device_address = (uint16_t)CONFIG_ZACUS_P6_I2C_ADDR, + .scl_speed_hz = 100000, + }; + err = i2c_master_bus_add_device(s_i2c_bus, &dev_cfg, &s_i2c_dev); + if (err != ESP_OK) { + ESP_LOGE(TAG, "i2c_master_bus_add_device(addr=0x%02X): %s", + CONFIG_ZACUS_P6_I2C_ADDR, esp_err_to_name(err)); + return err; + } + + ESP_LOGI(TAG, "I2C bus init OK (SDA=%d SCL=%d addr=0x%02X)", + CONFIG_ZACUS_P6_I2C_SDA, CONFIG_ZACUS_P6_I2C_SCL, + CONFIG_ZACUS_P6_I2C_ADDR); + return ESP_OK; +} + +// STUB: real I2C read of PN532/PN7150 comes here. +// Returns true and fills uid_out[4] when a card is present. +static bool nfc_hal_read_uid(uint8_t uid_out[4]) +{ + (void)uid_out; + // HAL stub — hardware not yet wired. Replace with PN532/PN7150 I2C + // read sequence once the module is connected. + ESP_LOGD(TAG, "NFC read (stub, I2C 0x%02X)", CONFIG_ZACUS_P6_I2C_ADDR); + return false; +} + +#elif CONFIG_ZACUS_P6_BUS_SPI +#include "driver/spi_master.h" + +static spi_device_handle_t s_spi = NULL; + +static esp_err_t nfc_hal_init(void) +{ + spi_bus_config_t buscfg = { + .miso_io_num = 13, // default SPI2 pins — override via sdkconfig if needed + .mosi_io_num = 11, + .sclk_io_num = 12, + .quadwp_io_num = -1, + .quadhd_io_num = -1, + .max_transfer_sz = 64, + }; + // Use SPI3_HOST to avoid conflict with possible SD card on SPI2. + esp_err_t err = spi_bus_initialize(SPI3_HOST, &buscfg, SPI_DMA_CH_AUTO); + if (err != ESP_OK && err != ESP_ERR_INVALID_STATE) { + ESP_LOGE(TAG, "spi_bus_initialize(SPI3): %s", esp_err_to_name(err)); + return err; + } + + spi_device_interface_config_t devcfg = { + .clock_speed_hz = 1000000, // 1 MHz (PN532 max SPI is 5 MHz) + .mode = 0, + .spics_io_num = 10, // CS pin — adjust via Kconfig when available + .queue_size = 4, + }; + err = spi_bus_add_device(SPI3_HOST, &devcfg, &s_spi); + if (err != ESP_OK) { + ESP_LOGE(TAG, "spi_bus_add_device: %s", esp_err_to_name(err)); + return err; + } + + ESP_LOGI(TAG, "SPI bus init OK (SPI3, CS=10)"); + return ESP_OK; +} + +// STUB: real SPI read of PN532 comes here. +static bool nfc_hal_read_uid(uint8_t uid_out[4]) +{ + (void)uid_out; + ESP_LOGD(TAG, "NFC read (stub, SPI)"); + return false; +} + +#endif // bus type + +// ─── UID comparison helper ─────────────────────────────────────────────────── + +// Parse a colon-separated hex UID string "A1:B2:C3:D4" into uid[4]. +// Returns true on success. +static bool parse_uid(const char *str, uint8_t uid[4]) +{ + unsigned int b[4]; + if (sscanf(str, "%x:%x:%x:%x", &b[0], &b[1], &b[2], &b[3]) != 4) + return false; + for (int i = 0; i < 4; i++) uid[i] = (uint8_t)b[i]; + return true; +} + +// ─── State ────────────────────────────────────────────────────────────────── + +static char s_expected_uid[20] = {0}; // "A1:B2:C3:D4\0" +static uint8_t s_expected_raw[4] = {0}; +static bool s_expected_valid = false; + +static volatile bool s_armed = false; +static volatile bool s_solved = false; + +static p6_nfc_solved_cb_t s_solved_cb = NULL; + +static bool s_initialised = false; + +// ─── NFC polling task ──────────────────────────────────────────────────────── + +static void nfc_task(void *arg) +{ + (void)arg; + + uint8_t uid[4] = {0}; + uint8_t last_uid[4] = {0}; + uint32_t last_read_ms = 0; + + for (;;) { + if (!s_armed || s_solved) { + vTaskDelay(pdMS_TO_TICKS(200)); + continue; + } + + uint32_t t = (uint32_t)(xTaskGetTickCount() * portTICK_PERIOD_MS); + + if (nfc_hal_read_uid(uid)) { + // Debounce: ignore same tag within 1 s. + bool same = (memcmp(uid, last_uid, 4) == 0); + if (!same || (t - last_read_ms) > 1000) { + memcpy(last_uid, uid, 4); + last_read_ms = t; + + ESP_LOGI(TAG, "tag read: %02X:%02X:%02X:%02X", + uid[0], uid[1], uid[2], uid[3]); + + if (s_expected_valid && + memcmp(uid, s_expected_raw, 4) == 0) { + s_solved = true; + ESP_LOGI(TAG, "SOLVED: tag %s matched", s_expected_uid); + if (s_solved_cb) { + s_solved_cb(); + } + } else { + ESP_LOGW(TAG, "tag %02X:%02X:%02X:%02X not the expected %s", + uid[0], uid[1], uid[2], uid[3], s_expected_uid); + } + } + } + + vTaskDelay(pdMS_TO_TICKS(200)); // 5 Hz polling + } +} + +// ─── Public API ───────────────────────────────────────────────────────────── + +esp_err_t p6_nfc_init(void) +{ + if (s_initialised) return ESP_OK; + + esp_err_t err = nfc_hal_init(); + if (err != ESP_OK) { + ESP_LOGE(TAG, "nfc_hal_init failed: %s", esp_err_to_name(err)); + return err; + } + + BaseType_t rc = xTaskCreate(nfc_task, "p6_nfc", 3072, NULL, 5, NULL); + if (rc != pdPASS) { + ESP_LOGE(TAG, "xTaskCreate(p6_nfc) failed"); + return ESP_ERR_NO_MEM; + } + + s_initialised = true; +#if CONFIG_ZACUS_P6_READER_PN532 && CONFIG_ZACUS_P6_BUS_I2C + ESP_LOGI(TAG, "init OK (PN532, I2C, stub HAL)"); +#elif CONFIG_ZACUS_P6_READER_PN532 + ESP_LOGI(TAG, "init OK (PN532, SPI, stub HAL)"); +#else + ESP_LOGI(TAG, "init OK (PN7150, I2C, stub HAL)"); +#endif + return ESP_OK; +} + +void p6_nfc_arm(const char *expected_uid, p6_nfc_solved_cb_t solved_cb) +{ + if (!s_initialised) { + ESP_LOGW(TAG, "arm called before init — ignored"); + return; + } + + s_armed = false; + s_solved = false; + s_expected_valid = false; + memset(s_expected_uid, 0, sizeof(s_expected_uid)); + memset(s_expected_raw, 0, sizeof(s_expected_raw)); + + if (expected_uid && expected_uid[0]) { + strncpy(s_expected_uid, expected_uid, sizeof(s_expected_uid) - 1); + s_expected_uid[sizeof(s_expected_uid) - 1] = '\0'; + s_expected_valid = parse_uid(s_expected_uid, s_expected_raw); + if (!s_expected_valid) { + ESP_LOGW(TAG, "arm: could not parse UID \"%s\" — tag match disabled", + expected_uid); + } + } + + s_solved_cb = solved_cb; + s_armed = true; + ESP_LOGI(TAG, "armed, expected UID \"%s\"", s_expected_uid); +} + +void p6_nfc_disarm(void) +{ + s_armed = false; + s_solved = false; + ESP_LOGI(TAG, "disarmed"); +} + +#else // CONFIG_ZACUS_P6_NFC_ENABLE not set — emit stubs only + +esp_err_t p6_nfc_init(void) +{ + return ESP_OK; +} + +void p6_nfc_arm(const char *expected_uid, p6_nfc_solved_cb_t solved_cb) +{ + (void)expected_uid; + (void)solved_cb; + /* stub — CONFIG_ZACUS_P6_NFC_ENABLE=n */ +} + +void p6_nfc_disarm(void) +{ + /* stub — CONFIG_ZACUS_P6_NFC_ENABLE=n */ +} + +#endif // CONFIG_ZACUS_P6_NFC_ENABLE diff --git a/idf_zacus/main/CMakeLists.txt b/idf_zacus/main/CMakeLists.txt index beadb81..742b336 100644 --- a/idf_zacus/main/CMakeLists.txt +++ b/idf_zacus/main/CMakeLists.txt @@ -25,4 +25,6 @@ idf_component_register( display_ui puzzle_state p7_coffre + p5_morse + p6_nfc ) diff --git a/idf_zacus/main/Kconfig.projbuild b/idf_zacus/main/Kconfig.projbuild index 0f88eab..9dd6533 100644 --- a/idf_zacus/main/Kconfig.projbuild +++ b/idf_zacus/main/Kconfig.projbuild @@ -72,4 +72,141 @@ menu "Zacus Game Hardware" endif # ZACUS_P7_COFFRE_ENABLE + # ─── P5 Morse / telegraph key ─────────────────────────────────────────────── + + config ZACUS_P5_MORSE_ENABLE + bool "Enable P5 Morse puzzle (telegraph key input)" + default n + help + Drives the P5 code-Morse puzzle: player taps a telegraph key and + the firmware decodes dot/dash sequences. When disabled all + p5_morse_*() functions are empty stubs so callers compile without + #ifdef guards. + Enable once the telegraph key (or push button) is wired up. + + if ZACUS_P5_MORSE_ENABLE + + config ZACUS_P5_GPIO + int "GPIO pin for the telegraph key / button" + default 14 + range 0 48 + help + Input GPIO. The key connects this pin to GND when pressed. + An internal pull-up is enabled by the driver. + + config ZACUS_P5_ACTIVE_LOW + bool "Key active-LOW (pressed = GPIO reads 0)" + default y + help + Set y when using a standard normally-open button to GND + (internal pull-up enabled). Set n for active-high sensors. + + config ZACUS_P5_DOT_MS + int "Maximum dot press duration (ms)" + default 200 + range 10 2000 + help + A key press shorter than or equal to this value is decoded + as a dot. Presses above this threshold become dashes. + + config ZACUS_P5_DASH_MS + int "Minimum dash press duration (ms)" + default 600 + range 50 5000 + help + A key press must last at least this long to be decoded as a + dash. Presses between DOT_MS+1 and DASH_MS-1 are discarded + as noise or accidental touches. + + config ZACUS_P5_GAP_MS + int "Letter-gap silence duration (ms)" + default 800 + range 100 5000 + help + Silence between key presses longer than this value triggers + decoding of the accumulated symbol (letter boundary). + + endif # ZACUS_P5_MORSE_ENABLE + + # ─── P6 NFC / alchemical symbols ──────────────────────────────────────────── + + config ZACUS_P6_NFC_ENABLE + bool "Enable P6 NFC puzzle (alchemical symbol tags)" + default n + help + Drives the P6 alchemical-symbols puzzle: the player places NFC + tags (NTAG213) on a wooden tablet; the firmware reads the UIDs + and checks the placement order. When disabled all p6_nfc_*() + functions are empty stubs so callers compile without #ifdef guards. + Enable once the NFC reader module is wired up. + + if ZACUS_P6_NFC_ENABLE + + choice ZACUS_P6_READER_TYPE + prompt "NFC reader IC" + default ZACUS_P6_READER_PN532 + help + Select the NFC reader module on the board. + PN532 – NXP PN532 (common breakout, SPI or I2C). + PN7150 – NXP PN7150 (NCI-based, I2C only). + + config ZACUS_P6_READER_PN532 + bool "PN532 (NXP)" + config ZACUS_P6_READER_PN7150 + bool "PN7150 (NXP NCI)" + endchoice + + choice ZACUS_P6_BUS_TYPE + prompt "Communication bus" + default ZACUS_P6_BUS_I2C + help + Select the bus connecting the ESP32 to the NFC reader. + I2C – two-wire, uses SDA/SCL. PN7150 only supports I2C. + SPI – four-wire, faster. PN532 only. + + config ZACUS_P6_BUS_I2C + bool "I2C" + config ZACUS_P6_BUS_SPI + bool "SPI (PN532 only)" + endchoice + + config ZACUS_P6_I2C_SDA + int "I2C SDA GPIO" + default 8 + range 0 48 + depends on ZACUS_P6_BUS_I2C + help + GPIO number for the I2C data line (SDA). + Default 8 is free on the Freenove Media Kit board. + + config ZACUS_P6_I2C_SCL + int "I2C SCL GPIO" + default 9 + range 0 48 + depends on ZACUS_P6_BUS_I2C + help + GPIO number for the I2C clock line (SCL). + Default 9 is free on the Freenove Media Kit board. + + config ZACUS_P6_I2C_ADDR + hex "NFC reader I2C address" + default 0x24 + range 0x08 0x77 + depends on ZACUS_P6_BUS_I2C + help + 7-bit I2C address of the NFC reader. + PN532 default: 0x24. PN7150 default: 0x28. + + config ZACUS_P6_IRQ_GPIO + int "IRQ / data-ready GPIO (-1 = polling mode)" + default -1 + range -1 48 + help + Optional interrupt / data-ready pin from the NFC reader. + Set -1 to use polling mode (5 Hz by default). + When wired, the driver waits for a falling edge before + reading, which reduces SPI/I2C bus traffic. + + endif # ZACUS_P6_NFC_ENABLE + endmenu diff --git a/idf_zacus/main/main.c b/idf_zacus/main/main.c index 5e62331..6f71c72 100644 --- a/idf_zacus/main/main.c +++ b/idf_zacus/main/main.c @@ -56,6 +56,8 @@ #include "board_pins_mediakit.h" #include "display_ui.h" #include "p7_coffre.h" +#include "p5_morse.h" +#include "p6_nfc.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. @@ -597,6 +599,20 @@ void app_main(void) { ESP_LOGW(TAG, "p7_coffre_init: %s", esp_err_to_name(coffre_err)); } + // P5 Morse puzzle: configure telegraph-key GPIO and start the + // decoding task. No-op stub when CONFIG_ZACUS_P5_MORSE_ENABLE=n. + esp_err_t morse_err = p5_morse_init(); + if (morse_err != ESP_OK) { + ESP_LOGW(TAG, "p5_morse_init: %s", esp_err_to_name(morse_err)); + } + + // P6 NFC puzzle: initialise bus and NFC polling task. + // No-op stub when CONFIG_ZACUS_P6_NFC_ENABLE=n. + esp_err_t nfc_err = p6_nfc_init(); + if (nfc_err != ESP_OK) { + ESP_LOGW(TAG, "p6_nfc_init: %s", esp_err_to_name(nfc_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