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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user