Compare commits

...

2 Commits

Author SHA1 Message Date
electron b31388dd86 Merge pull request 'feat(p5,p6): scaffold morse + nfc puzzles behind CONFIG flags (default off)' (#16) from feat/p5-p6-scaffold into main
CI / platformio (push) Failing after 11m37s
2026-06-14 15:00:31 +00:00
clemsail d5b9297cee feat(p5,p6): scaffold morse + nfc puzzles behind CONFIG flags, default off
CI / platformio (pull_request) Failing after 3m49s
2026-06-14 16:58:42 +02:00
13 changed files with 974 additions and 5 deletions
@@ -20,4 +20,6 @@ idf_component_register(
PRIV_REQUIRES
local_puzzles
p7_coffre
p5_morse
p6_nfc
)
@@ -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);
@@ -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;
@@ -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
@@ -0,0 +1,10 @@
idf_component_register(
SRCS
"p5_morse.c"
INCLUDE_DIRS
"include"
REQUIRES
esp_driver_gpio
log
freertos
)
@@ -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 <stdint.h>
#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
+265
View File
@@ -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 <string.h>
#include <stdbool.h>
// ─── 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
@@ -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
)
@@ -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 <stdint.h>
#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
+290
View File
@@ -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 <string.h>
#include <stdbool.h>
#include <stdio.h>
// ─── 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
+2
View File
@@ -25,4 +25,6 @@ idf_component_register(
display_ui
puzzle_state
p7_coffre
p5_morse
p6_nfc
)
+137
View File
@@ -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
+16
View File
@@ -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