feat(firmware): niveau A — parsing + arming des puzzles led/radio/morse/nfc/keypad

puzzle_binding: enum étendu (PB_LED/RADIO/MORSE/NFC/KEYPAD) + champs struct
(pattern[], frequency, message[]+mode, tags[]+ordered, length) + parse cJSON
avec limites alignées sur le gateway (runtime3_common._validate_step_puzzle).
game_endpoint: arming des 5 types (résolution physique stubbée → état PENDING
propre, pas de driver LED/SDR/morse/NFC/keypad encore).

Validé sur master Freenove réel : pushScenario(morse+nfc) accepté, armStep
STEP_MORSE → {"armed":"morse"} (avant: type rejeté). Build idf.py vert.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-13 02:35:06 +02:00
parent 7893c32bf6
commit cfbbc70255
3 changed files with 195 additions and 4 deletions
@@ -792,6 +792,27 @@ 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_LED || binding.type == PB_RADIO ||
binding.type == PB_MORSE || binding.type == PB_NFC ||
binding.type == PB_KEYPAD) {
// ── Level-A puzzles: parsed + validated + armed, but physical solve
// is STUBBED. There is no hardware driver yet (LED matrix / SDR /
// morse key / NFC reader / keypad), so we do NOT call any
// local_puzzles_arm_* and never report into puzzle_state — the puzzle
// is accepted and remains "pending" (unsolved). The step change still
// succeeds and s_current_step_id / s_current_armed reflect it so the
// GM dashboard and display can show the active puzzle type.
switch (binding.type) {
case PB_LED: armed = "led"; break;
case PB_RADIO: armed = "radio"; break;
case PB_MORSE: armed = "morse"; break;
case PB_NFC: armed = "nfc"; break;
case PB_KEYPAD: armed = "keypad"; break;
default: armed = "none"; break;
}
ESP_LOGW(TAG, "step %s: puzzle type '%s' (id %u) armed PENDING — "
"physical solve stubbed (no driver)",
step_id, armed, (unsigned) binding.id);
}
strncpy(s_current_armed, armed, sizeof(s_current_armed) - 1);
@@ -11,7 +11,24 @@
#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;
// ─── Level-A puzzle limits (mirror tools/scenario/runtime3_common.py
// _validate_step_puzzle EXACTLY) ────────────────────────────────────────
#define PB_MAX_PATTERN 32 // led: 1..32 pattern strings
#define PB_MAX_PATSTR 32 // led: each pattern string < 32 chars
#define PB_MAX_MESSAGE 64 // morse: message string <= 63 chars (+ NUL)
#define PB_MAX_TAGS 32 // nfc: 1..32 tag strings
#define PB_MAX_TAGSTR 32 // nfc: each tag string < 32 chars
#define PB_KEYPAD_MIN 1 // keypad length 1..16
#define PB_KEYPAD_MAX 16
typedef enum {
PB_NONE = 0, PB_QR, PB_SOUND,
// Level-A puzzle types (parsed + armed, physical solve stubbed):
PB_LED, PB_RADIO, PB_MORSE, PB_NFC, PB_KEYPAD,
} puzzle_binding_type_t;
// morse mode (mirrors gateway "normal"|"light")
typedef enum { PB_MORSE_NORMAL = 0, PB_MORSE_LIGHT } puzzle_morse_mode_t;
typedef struct {
puzzle_binding_type_t type; // PB_NONE if the step has no puzzle
@@ -22,7 +39,21 @@ typedef struct {
// sound
int melody[PB_MAX_NOTES];
size_t note_count;
int tolerance; // default 1
int tolerance; // sound: default 1 / radio: required >=0
// led
char pattern[PB_MAX_PATTERN][PB_MAX_PATSTR];
size_t pattern_count;
// radio
int frequency; // >= 0
// morse
char message[PB_MAX_MESSAGE];
puzzle_morse_mode_t mode;
// nfc
char tags[PB_MAX_TAGS][PB_MAX_TAGSTR];
size_t tag_count;
bool ordered;
// keypad
int length; // 1..16
// common
uint8_t fragment[PB_MAX_FRAG];
uint8_t fragment_len;
@@ -36,7 +67,10 @@ typedef struct {
// long, fragment missing/empty/too long/non-digit values, negative tolerance).
// NOTE: Parsing a full 64KB IR allocates transient heap via cJSON — call
// sparingly (e.g. per step change, not per frame).
// Tolerance defaults to 1 when absent (sound only).
// Tolerance defaults to 1 when absent (sound only); for radio it is required.
// Level-A types (led/radio/morse/nfc/keypad) are parsed + validated against the
// same constraints as the gateway; their PHYSICAL solve is stubbed (they arm
// and stay pending — see game_endpoint_apply_step).
esp_err_t puzzle_binding_from_ir(const char *ir_json, const char *step_id,
puzzle_binding_t *out);
@@ -1,8 +1,11 @@
// puzzle_binding.c — parse a step's optional puzzle binding from Runtime 3 IR.
#include "puzzle_binding.h"
#include "cJSON.h"
#include "esp_log.h"
#include <string.h>
static const char *TAG = "puzzle_binding";
esp_err_t puzzle_binding_from_ir(const char *ir_json, const char *step_id,
puzzle_binding_t *out)
{
@@ -60,7 +63,19 @@ 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, "led") == 0) {
ptype = PB_LED;
} else if (strcmp(jtype->valuestring, "radio") == 0) {
ptype = PB_RADIO;
} else if (strcmp(jtype->valuestring, "morse") == 0) {
ptype = PB_MORSE;
} else if (strcmp(jtype->valuestring, "nfc") == 0) {
ptype = PB_NFC;
} else if (strcmp(jtype->valuestring, "keypad") == 0) {
ptype = PB_KEYPAD;
} else {
ESP_LOGW(TAG, "unknown puzzle type \"%s\" for step \"%s\"",
jtype->valuestring, step_id);
cJSON_Delete(root);
memset(out, 0, sizeof(*out));
return ESP_ERR_INVALID_ARG;
@@ -120,7 +135,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 +171,127 @@ esp_err_t puzzle_binding_from_ir(const char *ir_json, const char *step_id,
} else {
out->tolerance = 1;
}
} else if (ptype == PB_LED) {
// led: pattern = list of 1..32 strings, each < 32 chars.
const cJSON *jpat = cJSON_GetObjectItemCaseSensitive(puzzle, "pattern");
if (!cJSON_IsArray(jpat)) {
cJSON_Delete(root);
memset(out, 0, sizeof(*out));
return ESP_ERR_INVALID_ARG;
}
int n = cJSON_GetArraySize(jpat);
if (n < 1 || n > PB_MAX_PATTERN) {
ESP_LOGW(TAG, "led pattern count %d out of range [1,%d] (step %s)",
n, PB_MAX_PATTERN, step_id);
cJSON_Delete(root);
memset(out, 0, sizeof(*out));
return ESP_ERR_INVALID_ARG;
}
for (int i = 0; i < n; i++) {
const cJSON *pv = cJSON_GetArrayItem(jpat, i);
if (!cJSON_IsString(pv) || !pv->valuestring || pv->valuestring[0] == '\0'
|| strlen(pv->valuestring) >= PB_MAX_PATSTR) {
ESP_LOGW(TAG, "led pattern[%d] invalid/too long (step %s)", i, step_id);
cJSON_Delete(root);
memset(out, 0, sizeof(*out));
return ESP_ERR_INVALID_ARG;
}
strncpy(out->pattern[i], pv->valuestring, PB_MAX_PATSTR - 1);
out->pattern[i][PB_MAX_PATSTR - 1] = '\0';
}
out->pattern_count = (size_t)n;
} else if (ptype == PB_RADIO) {
// radio: frequency (int>=0) + tolerance (int>=0), both required.
const cJSON *jfreq = cJSON_GetObjectItemCaseSensitive(puzzle, "frequency");
const cJSON *jtol = cJSON_GetObjectItemCaseSensitive(puzzle, "tolerance");
if (!cJSON_IsNumber(jfreq) || jfreq->valueint < 0
|| !cJSON_IsNumber(jtol) || jtol->valueint < 0) {
ESP_LOGW(TAG, "radio frequency/tolerance missing or negative (step %s)",
step_id);
cJSON_Delete(root);
memset(out, 0, sizeof(*out));
return ESP_ERR_INVALID_ARG;
}
out->frequency = jfreq->valueint;
out->tolerance = jtol->valueint;
} else if (ptype == PB_MORSE) {
// morse: message (string <=63) + mode ("normal"|"light").
const cJSON *jmsg = cJSON_GetObjectItemCaseSensitive(puzzle, "message");
if (!cJSON_IsString(jmsg) || !jmsg->valuestring || jmsg->valuestring[0] == '\0'
|| strlen(jmsg->valuestring) > PB_MAX_MESSAGE - 1) {
ESP_LOGW(TAG, "morse message missing/empty/too long (step %s)", step_id);
cJSON_Delete(root);
memset(out, 0, sizeof(*out));
return ESP_ERR_INVALID_ARG;
}
const cJSON *jmode = cJSON_GetObjectItemCaseSensitive(puzzle, "mode");
if (!cJSON_IsString(jmode) || !jmode->valuestring) {
cJSON_Delete(root);
memset(out, 0, sizeof(*out));
return ESP_ERR_INVALID_ARG;
}
if (strcmp(jmode->valuestring, "normal") == 0) {
out->mode = PB_MORSE_NORMAL;
} else if (strcmp(jmode->valuestring, "light") == 0) {
out->mode = PB_MORSE_LIGHT;
} else {
ESP_LOGW(TAG, "morse mode \"%s\" not normal|light (step %s)",
jmode->valuestring, step_id);
cJSON_Delete(root);
memset(out, 0, sizeof(*out));
return ESP_ERR_INVALID_ARG;
}
strncpy(out->message, jmsg->valuestring, PB_MAX_MESSAGE - 1);
out->message[PB_MAX_MESSAGE - 1] = '\0';
} else if (ptype == PB_NFC) {
// nfc: tags = list of 1..32 strings (<32 chars) + ordered (bool).
const cJSON *jtags = cJSON_GetObjectItemCaseSensitive(puzzle, "tags");
if (!cJSON_IsArray(jtags)) {
cJSON_Delete(root);
memset(out, 0, sizeof(*out));
return ESP_ERR_INVALID_ARG;
}
int n = cJSON_GetArraySize(jtags);
if (n < 1 || n > PB_MAX_TAGS) {
ESP_LOGW(TAG, "nfc tags count %d out of range [1,%d] (step %s)",
n, PB_MAX_TAGS, step_id);
cJSON_Delete(root);
memset(out, 0, sizeof(*out));
return ESP_ERR_INVALID_ARG;
}
for (int i = 0; i < n; i++) {
const cJSON *tv = cJSON_GetArrayItem(jtags, i);
if (!cJSON_IsString(tv) || !tv->valuestring || tv->valuestring[0] == '\0'
|| strlen(tv->valuestring) >= PB_MAX_TAGSTR) {
ESP_LOGW(TAG, "nfc tags[%d] invalid/too long (step %s)", i, step_id);
cJSON_Delete(root);
memset(out, 0, sizeof(*out));
return ESP_ERR_INVALID_ARG;
}
strncpy(out->tags[i], tv->valuestring, PB_MAX_TAGSTR - 1);
out->tags[i][PB_MAX_TAGSTR - 1] = '\0';
}
out->tag_count = (size_t)n;
const cJSON *jord = cJSON_GetObjectItemCaseSensitive(puzzle, "ordered");
if (!cJSON_IsBool(jord)) {
ESP_LOGW(TAG, "nfc ordered must be a bool (step %s)", step_id);
cJSON_Delete(root);
memset(out, 0, sizeof(*out));
return ESP_ERR_INVALID_ARG;
}
out->ordered = cJSON_IsTrue(jord);
} else { // PB_KEYPAD
// keypad: length (int 1..16).
const cJSON *jlen = cJSON_GetObjectItemCaseSensitive(puzzle, "length");
if (!cJSON_IsNumber(jlen)
|| jlen->valueint < PB_KEYPAD_MIN || jlen->valueint > PB_KEYPAD_MAX) {
ESP_LOGW(TAG, "keypad length out of range [%d,%d] (step %s)",
PB_KEYPAD_MIN, PB_KEYPAD_MAX, step_id);
cJSON_Delete(root);
memset(out, 0, sizeof(*out));
return ESP_ERR_INVALID_ARG;
}
out->length = jlen->valueint;
}
// id/type written last — all error exits above leave *out zeroed