diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ee64a21..4bbe367 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,6 +27,8 @@ jobs: - 'firmware/**' - 'tools/gen_db_header.py' - 'material_database.json' + # the native contract test validates firmware output against the spec + - 'spec/v2/schemas/**' web: - 'web/**' - 'tools/build_web.py' @@ -84,6 +86,20 @@ jobs: cp firmware/include/secrets.h.example firmware/include/secrets.h - name: Build run: pio run -d firmware + - name: Wire-contract test (host) + # Runs the pure apicore::shape layer natively and dumps every reply + # shape to spec/v2/fixtures/generated/ for schema validation below. + run: pio test -e native -d firmware + - uses: actions/setup-node@v6 + with: + node-version: "26" + - uses: pnpm/action-setup@v4 + with: + version: 11 + - name: Validate firmware output against spec schemas + run: | + pnpm --dir spec/ts install --frozen-lockfile + pnpm --dir spec/ts validate web: needs: changes diff --git a/README.md b/README.md index 58a93b0..3859a9c 100644 --- a/README.md +++ b/README.md @@ -91,26 +91,23 @@ device is also reachable at `http://spoolid.local` (mDNS hostname, configurable) from the repo root into `desktop/frontend/` at build time (`copy-db` pnpm script) and bundled into the frontend. -## Serial protocol (USB CDC, line-JSON) +## Wire protocol -Responses are single-line JSON; log lines start with `[`. Commands: +The full contract — operations, JSON shapes, HTTP + serial bindings, error codes, +compatibility rules — lives in **[`spec/v2/PROTOCOL.md`](spec/v2/PROTOCOL.md)** +(JSON Schemas in [`spec/v2/schemas/`](spec/v2/schemas/), validated in CI against +fixtures produced by the firmware's own reply builders). Quick taste, over USB +serial (single-line JSON; log lines start with `[`): ```json +{"cmd":"getspec"} // UI lists + firmware version + protocol generation {"cmd":"write","materialId":"01001","color":"1200F6","weight":"1KG"} -{"cmd":"status"} -{"cmd":"read"} // decrypt a tapped tag -> materialId/color/weight -{"cmd":"dump"} // raw (encrypted) blocks 4-6 of a tapped tag -{"cmd":"beep"} // fire the buzzer once (test) -{"cmd":"getspec"} // firmware-owned UI lists + firmware version -{"cmd":"getconfig"} / {"cmd":"setconfig", ...} -{"cmd":"dbbegin","size":N} / {"cmd":"dbdata","b":""} / {"cmd":"dbend"} // upload a gzipped DB -{"cmd":"otabegin","size":N,"target":"app","md5":"..."} // start an OTA session -{"cmd":"otadata","b":""} // stream image bytes (* N) -{"cmd":"otaend"} // verify + reboot into the new image +{"cmd":"status"} // poll for the staged-write outcome ``` -`getspec` also returns `version` (firmware semver); clients gate compatibility on -the major.minor pair. The web transport mirrors OTA at `POST /api/ota?target=app|fs&md5=`. +Every reply carries `ok`; errors add a stable `code` (`no_tag`, `invalid_params`, …). +Clients gate compatibility on the `protocol` field; older 1.x firmware (documented in +[`spec/v1/PROTOCOL.md`](spec/v1/PROTOCOL.md)) is recognized via major.minor fallback. ## Releases & over-the-air updates diff --git a/firmware/platformio.ini b/firmware/platformio.ini index 8cf27c9..de3ddfa 100644 --- a/firmware/platformio.ini +++ b/firmware/platformio.ini @@ -30,3 +30,15 @@ lib_deps = esp32async/ESPAsyncWebServer@^3.11.2 esp32async/AsyncTCP@^3.4.10 bblanchon/ArduinoJson@^7.4.3 + +; Host-run wire-contract test: compiles the pure apicore::shape layer on the +; host, dumps every reply shape to spec/v2/fixtures/generated/, and CI +; validates those against spec/v2/schemas (see test/test_contract). +; Run: pio test -e native -d firmware +[env:native] +platform = native +test_framework = unity +test_build_src = yes +build_flags = -std=c++17 +build_src_filter = -<*> + + +lib_deps = bblanchon/ArduinoJson@^7.4.3 diff --git a/firmware/src/api_core.cpp b/firmware/src/api_core.cpp new file mode 100644 index 0000000..37f8175 --- /dev/null +++ b/firmware/src/api_core.cpp @@ -0,0 +1,80 @@ +#include "api_core.h" + +#include "ui_spec.h" +#include "version.h" + +// Pure shape layer only — keep this file free of Arduino/rfid/WiFi includes so +// it compiles in the native test env (platformio.ini [env:native]). +namespace apicore { +namespace shape { + +void spec(JsonDocument &doc) { + doc["ok"] = true; + doc["protocol"] = PROTOCOL_VERSION; + uispec::fill(doc); // version + colorSwatches + weightLabels + baudRates + logLevels +} + +void read(JsonDocument &doc, const ReadData &d) { + doc["ok"] = true; + doc["uid"] = d.uid; + doc["encrypted"] = d.encrypted; + doc["materialId"] = d.materialId; + doc["color"] = d.color; + doc["weight"] = d.weight; + doc["serial"] = d.serial; +} + +void dump(JsonDocument &doc, const DumpData &d) { + doc["ok"] = true; + doc["uid"] = d.uid; + doc["encrypted"] = d.encrypted; + doc["data"] = d.data; +} + +void status(JsonDocument &doc, const StatusData &d) { + doc["ok"] = true; + doc["pending"] = d.pending; + doc["mode"] = d.mode; + doc["ip"] = d.ip; + JsonObject last = doc["last"].to(); + last["done"] = d.last.done; + last["ok"] = d.last.ok; + last["encrypted"] = d.last.encrypted; + last["uid"] = d.last.uid; + last["error"] = d.last.error; +} + +void configGet(JsonDocument &doc, const ConfigData &d) { + doc["ok"] = true; + doc["wifiSsid"] = d.wifiSsid; + doc["apSsid"] = d.apSsid; + doc["hostname"] = d.hostname; + doc["logLevel"] = d.logLevel; + doc["baud"] = d.baud; +} + +void writeStaged(JsonDocument &doc) { + doc["ok"] = true; + doc["staged"] = true; +} + +void ack(JsonDocument &doc) { doc["ok"] = true; } + +void ackReboot(JsonDocument &doc) { + doc["ok"] = true; + doc["reboot"] = true; +} + +void written(JsonDocument &doc, uint32_t bytes) { + doc["ok"] = true; + doc["written"] = bytes; +} + +void error(JsonDocument &doc, const char *code, const char *msg) { + doc["ok"] = false; + doc["error"] = msg; + doc["code"] = code; +} + +} // namespace shape +} // namespace apicore diff --git a/firmware/src/api_core.h b/firmware/src/api_core.h new file mode 100644 index 0000000..fb5eb70 --- /dev/null +++ b/firmware/src/api_core.h @@ -0,0 +1,71 @@ +#pragma once +#include + +#include + +// Single owner of every wire reply shape (spec/v2/PROTOCOL.md). Two layers: +// +// shape::* — pure fillers over plain structs. No Arduino / rfid / WiFi +// headers, so they compile on the host: the native contract test +// (test/test_contract) runs these exact functions and validates +// their output against spec/v2/schemas in CI. +// fill* — device-side gatherers (api_core_device.cpp): collect data from +// rfid:: / config:: / net::, fill the struct, delegate to shape::*. +// +// Transport handlers (web_server.cpp, serial_proto.cpp) call only this API and +// contain no JSON keys. Any change here must update spec/v2/schemas + fixtures +// in the same PR. +namespace apicore { + +struct ReadData { + const char *uid; + bool encrypted; + const char *materialId, *color, *weight, *serial; +}; + +struct DumpData { + const char *uid; + bool encrypted; + const char *data; // raw tag blocks as hex, uninterpreted +}; + +struct LastWrite { + bool done, ok, encrypted; + const char *uid, *error; +}; + +struct StatusData { + bool pending; + const char *mode, *ip; // mode: "ap" | "sta" + LastWrite last; +}; + +struct ConfigData { + const char *wifiSsid, *apSsid, *hostname; // passwords never leave the device + uint8_t logLevel; + uint32_t baud; +}; + +namespace shape { +void spec(JsonDocument &doc); // ok + protocol + uispec lists/version +void read(JsonDocument &doc, const ReadData &d); +void dump(JsonDocument &doc, const DumpData &d); +void status(JsonDocument &doc, const StatusData &d); +void configGet(JsonDocument &doc, const ConfigData &d); +void writeStaged(JsonDocument &doc); // {ok, staged} +void ack(JsonDocument &doc); // {ok} +void ackReboot(JsonDocument &doc); // {ok, reboot} +void written(JsonDocument &doc, uint32_t bytes); // {ok, written} +// code is one of the stable ErrorCode values from spec/v2/schemas/common. +void error(JsonDocument &doc, const char *code, const char *msg); +} // namespace shape + +// Device layer (api_core_device.cpp — not built in the native test env). +// The bool gatherers return false with errCode set (caller wraps via shape::error). +bool fillRead(JsonDocument &doc, const char *&errCode); +bool fillDump(JsonDocument &doc, const char *&errCode); +void fillStatus(JsonDocument &doc); +void fillConfigGet(JsonDocument &doc); +void applyConfigSet(JsonDocument &in); // set fields, persist, apply log level + +} // namespace apicore diff --git a/firmware/src/api_core_device.cpp b/firmware/src/api_core_device.cpp new file mode 100644 index 0000000..e9b8dae --- /dev/null +++ b/firmware/src/api_core_device.cpp @@ -0,0 +1,67 @@ +#include + +#include "api_core.h" +#include "config.h" +#include "logger.h" +#include "rfid_writer.h" +#include "spool_data.h" +#include "wifi_net.h" + +// Device-side gatherers: collect data from the hardware/config modules and +// delegate to the pure shape layer. Excluded from the native test env. +namespace apicore { + +bool fillRead(JsonDocument &doc, const char *&errCode) { + String data, uid; + bool enc; + if (!rfid::readTag(data, uid, enc)) { + errCode = "no_tag"; + return false; + } + spool::Parsed p = spool::parse(data); + shape::read(doc, {uid.c_str(), enc, p.materialId.c_str(), p.color.c_str(), + p.weight.c_str(), p.serial.c_str()}); + return true; +} + +bool fillDump(JsonDocument &doc, const char *&errCode) { + String hex, uid; + bool enc; + if (!rfid::dumpTag(hex, uid, enc)) { + errCode = "no_tag"; + return false; + } + shape::dump(doc, {uid.c_str(), enc, hex.c_str()}); + return true; +} + +void fillStatus(JsonDocument &doc) { + const rfid::WriteResult &r = rfid::last(); + String ip = net::ip(); + shape::status(doc, {rfid::hasPending(), + net::isAp() ? "ap" : "sta", + ip.c_str(), + {r.done, r.ok, r.encrypted, r.uid.c_str(), r.error.c_str()}}); +} + +void fillConfigGet(JsonDocument &doc) { + Config &c = config::get(); + // passwords intentionally omitted + shape::configGet(doc, {c.wifiSsid.c_str(), c.apSsid.c_str(), c.hostname.c_str(), + c.logLevel, c.baud}); +} + +void applyConfigSet(JsonDocument &in) { + Config &c = config::get(); + if (in["wifiSsid"].is()) c.wifiSsid = in["wifiSsid"].as(); + if (in["wifiPass"].is()) c.wifiPass = in["wifiPass"].as(); + if (in["apSsid"].is()) c.apSsid = in["apSsid"].as(); + if (in["apPass"].is()) c.apPass = in["apPass"].as(); + if (in["hostname"].is()) c.hostname = in["hostname"].as(); + if (in["logLevel"].is()) c.logLevel = in["logLevel"]; + if (in["baud"].is()) c.baud = in["baud"]; + config::save(); + logger::setLevel(static_cast(c.logLevel)); +} + +} // namespace apicore diff --git a/firmware/src/serial_proto.cpp b/firmware/src/serial_proto.cpp index 3bc0ae7..51b8ce2 100644 --- a/firmware/src/serial_proto.cpp +++ b/firmware/src/serial_proto.cpp @@ -4,14 +4,11 @@ #include #include -#include "config.h" -#include "logger.h" +#include "api_core.h" #include "material_db.h" #include "ota.h" #include "rfid_writer.h" #include "spool_data.h" -#include "ui_spec.h" -#include "wifi_net.h" // Max raw bytes per OTA chunk; the base64 of this fits a serial line (see poll). static constexpr size_t OTA_CHUNK = 4096; @@ -24,110 +21,47 @@ void reply(const JsonDocument &doc) { Serial.println(); } -void replyErr(const char *msg) { +void replyErr(const char *code, const char *msg) { JsonDocument d; - d["ok"] = false; - d["error"] = msg; + apicore::shape::error(d, code, msg); reply(d); } -void cmdWrite(JsonDocument &in) { - String payload = spool::build(in["materialId"] | "", in["color"] | "", in["weight"] | ""); - if (payload.isEmpty()) return replyErr("invalid spool params"); - rfid::stage(payload); +void replyOk(void (*fill)(JsonDocument &)) { JsonDocument d; - d["ok"] = true; - d["staged"] = true; + fill(d); reply(d); } -void cmdRead() { - String data, uid; - bool enc; - if (!rfid::readTag(data, uid, enc)) return replyErr("no tag / auth failed"); - spool::Parsed p = spool::parse(data); - JsonDocument d; - d["ok"] = true; - d["uid"] = uid; - d["encrypted"] = enc; - d["materialId"] = p.materialId; - d["color"] = p.color; - d["weight"] = p.weight; - reply(d); +void cmdWrite(JsonDocument &in) { + String payload = spool::build(in["materialId"] | "", in["color"] | "", in["weight"] | ""); + if (payload.isEmpty()) return replyErr("invalid_params", "invalid spool params"); + rfid::stage(payload); + replyOk(apicore::shape::writeStaged); } -void cmdStatus() { - const rfid::WriteResult &r = rfid::last(); +void cmdRead() { JsonDocument d; - d["ok"] = true; - d["pending"] = rfid::hasPending(); - d["mode"] = net::isAp() ? "ap" : "sta"; - d["ip"] = net::ip(); - JsonObject last = d["last"].to(); - last["done"] = r.done; - last["ok"] = r.ok; - last["encrypted"] = r.encrypted; - last["uid"] = r.uid; - last["error"] = r.error; + const char *code; + if (!apicore::fillRead(d, code)) return replyErr(code, "no tag / auth failed"); reply(d); } void cmdDump() { - String hex, uid; - bool enc; - if (!rfid::dumpTag(hex, uid, enc)) return replyErr("no tag / auth failed"); - JsonDocument d; - d["ok"] = true; - d["uid"] = uid; - d["encrypted"] = enc; - d["data"] = hex; - reply(d); -} - -void cmdGetConfig() { - Config &c = config::get(); JsonDocument d; - d["ok"] = true; - d["wifiSsid"] = c.wifiSsid; - d["apSsid"] = c.apSsid; - d["hostname"] = c.hostname; - d["logLevel"] = c.logLevel; - d["baud"] = c.baud; - reply(d); -} - -void cmdSetConfig(JsonDocument &in) { - Config &c = config::get(); - if (in["wifiSsid"].is()) c.wifiSsid = in["wifiSsid"].as(); - if (in["wifiPass"].is()) c.wifiPass = in["wifiPass"].as(); - if (in["apSsid"].is()) c.apSsid = in["apSsid"].as(); - if (in["apPass"].is()) c.apPass = in["apPass"].as(); - if (in["hostname"].is()) c.hostname = in["hostname"].as(); - if (in["logLevel"].is()) c.logLevel = in["logLevel"]; - if (in["baud"].is()) c.baud = in["baud"]; - config::save(); - logger::setLevel(static_cast(c.logLevel)); - JsonDocument d; - d["ok"] = true; - d["reboot"] = true; - reply(d); -} - -void cmdGetSpec() { - JsonDocument d; - d["ok"] = true; - uispec::fill(d); + const char *code; + if (!apicore::fillDump(d, code)) return replyErr(code, "no tag / auth failed"); reply(d); } // ---- material DB upload over serial (chunked base64, pre-gzipped) ---- // dbbegin {size} -> dbdata {b:""} * N -> dbend. The client gzips the // JSON (the ESP32 has no runtime compressor) and streams it to LittleFS. -void cmdDbBegin() { - if (!matdb::uploadBegin()) return replyErr("db open failed"); - JsonDocument d; - d["ok"] = true; - reply(d); +void cmdDbBegin(JsonDocument &in) { + size_t size = in["size"] | 0; + if (!size) return replyErr("size_required", "size required"); + if (!matdb::uploadBegin()) return replyErr("db_failed", "db open failed"); + replyOk(apicore::shape::ack); } void cmdDbData(JsonDocument &in) { @@ -136,19 +70,17 @@ void cmdDbData(JsonDocument &in) { size_t outLen = 0; if (mbedtls_base64_decode(buf, sizeof(buf), &outLen, reinterpret_cast(b), strlen(b)) != 0) { - return replyErr("bad base64 / chunk too big"); + return replyErr("bad_chunk", "bad base64 / chunk too big"); } - if (!matdb::uploadChunk(buf, outLen)) return replyErr("db write failed"); + if (!matdb::uploadChunk(buf, outLen)) return replyErr("db_failed", "db write failed"); JsonDocument d; - d["ok"] = true; + apicore::shape::written(d, static_cast(outLen)); reply(d); } void cmdDbEnd() { - if (!matdb::uploadEnd()) return replyErr("db finalize failed"); - JsonDocument d; - d["ok"] = true; - reply(d); + if (!matdb::uploadEnd()) return replyErr("db_failed", "db finalize failed"); + replyOk(apicore::shape::ack); } // ---- OTA over serial (chunked base64) ---- @@ -158,11 +90,9 @@ void cmdOtaBegin(JsonDocument &in) { size_t size = in["size"] | 0; bool fs = String(in["target"] | "app") == "fs"; String md5 = in["md5"] | ""; - if (!size) return replyErr("size required"); - if (!ota::begin(size, fs, md5)) return replyErr("ota begin failed"); - JsonDocument d; - d["ok"] = true; - reply(d); + if (!size) return replyErr("size_required", "size required"); + if (!ota::begin(size, fs, md5)) return replyErr("ota_failed", "ota begin failed"); + replyOk(apicore::shape::ack); } void cmdOtaData(JsonDocument &in) { @@ -173,45 +103,41 @@ void cmdOtaData(JsonDocument &in) { reinterpret_cast(b), strlen(b)); if (rc != 0) { ota::abort(); - return replyErr("bad base64 / chunk too big"); + return replyErr("bad_chunk", "bad base64 / chunk too big"); } - if (!ota::write(buf, outLen)) return replyErr("ota write failed"); + if (!ota::write(buf, outLen)) return replyErr("ota_failed", "ota write failed"); JsonDocument d; - d["ok"] = true; - d["written"] = static_cast(outLen); + apicore::shape::written(d, static_cast(outLen)); reply(d); } void cmdOtaEnd() { String err; - if (!ota::end(err)) return replyErr(err.c_str()); - JsonDocument d; - d["ok"] = true; - d["reboot"] = true; - reply(d); + if (!ota::end(err)) return replyErr("ota_failed", err.c_str()); + replyOk(apicore::shape::ackReboot); ota::scheduleReboot(); } void dispatch(const String &line) { JsonDocument in; - if (deserializeJson(in, line)) return replyErr("bad json"); + if (deserializeJson(in, line)) return replyErr("bad_json", "bad json"); String cmd = in["cmd"] | ""; if (cmd == "write") cmdWrite(in); - else if (cmd == "status") cmdStatus(); + else if (cmd == "status") replyOk(apicore::fillStatus); else if (cmd == "dump") cmdDump(); else if (cmd == "read") cmdRead(); - else if (cmd == "beep") { rfid::testBeep(); JsonDocument d; d["ok"] = true; reply(d); } - else if (cmd == "getconfig") cmdGetConfig(); - else if (cmd == "setconfig") cmdSetConfig(in); - else if (cmd == "getspec") cmdGetSpec(); - else if (cmd == "dbbegin") cmdDbBegin(); + else if (cmd == "beep") { rfid::testBeep(); replyOk(apicore::shape::ack); } + else if (cmd == "getconfig") replyOk(apicore::fillConfigGet); + else if (cmd == "setconfig") { apicore::applyConfigSet(in); replyOk(apicore::shape::ackReboot); } + else if (cmd == "getspec") replyOk(apicore::shape::spec); + else if (cmd == "dbbegin") cmdDbBegin(in); else if (cmd == "dbdata") cmdDbData(in); else if (cmd == "dbend") cmdDbEnd(); else if (cmd == "otabegin") cmdOtaBegin(in); else if (cmd == "otadata") cmdOtaData(in); else if (cmd == "otaend") cmdOtaEnd(); - else if (cmd == "otaabort") { ota::abort(); JsonDocument d; d["ok"] = true; reply(d); } - else replyErr("unknown cmd"); + else if (cmd == "otaabort") { ota::abort(); replyOk(apicore::shape::ack); } + else replyErr("unknown_cmd", "unknown cmd"); } } // namespace diff --git a/firmware/src/version.h b/firmware/src/version.h index 9b3a194..0295020 100644 --- a/firmware/src/version.h +++ b/firmware/src/version.h @@ -7,3 +7,7 @@ #ifndef FW_VERSION #define FW_VERSION "0.0.0-dev" #endif + +// Wire-protocol generation, returned as `protocol` by getspec / /api/spec. +// Clients require an exact match when present; see spec/v2/PROTOCOL.md. +#define PROTOCOL_VERSION 2 diff --git a/firmware/src/web_server.cpp b/firmware/src/web_server.cpp index 08350a0..bfa866a 100644 --- a/firmware/src/web_server.cpp +++ b/firmware/src/web_server.cpp @@ -4,13 +4,12 @@ #include #include -#include "config.h" +#include "api_core.h" #include "logger.h" #include "material_db.h" #include "ota.h" #include "rfid_writer.h" #include "spool_data.h" -#include "ui_spec.h" #include "wifi_net.h" namespace web { @@ -28,21 +27,22 @@ void sendGz(AsyncWebServerRequest *req, const char *path, const char *type) { req->send(resp); } -String statusJson() { - const rfid::WriteResult &r = rfid::last(); - JsonDocument doc; - doc["pending"] = rfid::hasPending(); - doc["mode"] = net::isAp() ? "ap" : "sta"; - doc["ip"] = net::ip(); - JsonObject last = doc["last"].to(); - last["done"] = r.done; - last["ok"] = r.ok; - last["encrypted"] = r.encrypted; - last["uid"] = r.uid; - last["error"] = r.error; +void sendJson(AsyncWebServerRequest *r, int status, const JsonDocument &doc) { String out; serializeJson(doc, out); - return out; + r->send(status, "application/json", out); +} + +void sendErr(AsyncWebServerRequest *r, int status, const char *code, const char *msg) { + JsonDocument d; + apicore::shape::error(d, code, msg); + sendJson(r, status, d); +} + +void sendOk(AsyncWebServerRequest *r, void (*fill)(JsonDocument &)) { + JsonDocument d; + fill(d); + sendJson(r, 200, d); } // Accumulate a binary request body across chunks into a buffer (small payloads: @@ -61,13 +61,8 @@ void registerRoutes() { server.on("/style.css", HTTP_GET, [](AsyncWebServerRequest *r) { sendGz(r, "/style.css.gz", "text/css"); }); // ---- UI spec (firmware-owned constants for the clients) ---- - server.on("/api/spec", HTTP_GET, [](AsyncWebServerRequest *r) { - JsonDocument doc; - uispec::fill(doc); - String out; - serializeJson(doc, out); - r->send(200, "application/json", out); - }); + server.on("/api/spec", HTTP_GET, + [](AsyncWebServerRequest *r) { sendOk(r, apicore::shape::spec); }); // ---- material DB ---- server.on("/api/db", HTTP_GET, [](AsyncWebServerRequest *r) { matdb::serveTo(r); }); @@ -75,7 +70,7 @@ void registerRoutes() { // Upload pre-gzipped DB: stream body chunks directly to LittleFS. server.on( "/api/db", HTTP_POST, - [](AsyncWebServerRequest *r) { r->send(200, "application/json", "{\"ok\":true}"); }, + [](AsyncWebServerRequest *r) { sendOk(r, apicore::shape::ack); }, nullptr, [](AsyncWebServerRequest *r, uint8_t *data, size_t len, size_t index, size_t total) { static File f; @@ -96,64 +91,54 @@ void registerRoutes() { [](AsyncWebServerRequest *r, uint8_t *data, size_t len, size_t index, size_t total) { JsonDocument doc; if (deserializeJson(doc, data, len)) { - r->send(400, "application/json", "{\"ok\":false,\"error\":\"bad json\"}"); + sendErr(r, 400, "bad_json", "bad json"); return; } String payload = spool::build(doc["materialId"] | "", doc["color"] | "", doc["weight"] | ""); if (payload.isEmpty()) { - r->send(400, "application/json", "{\"ok\":false,\"error\":\"invalid spool params\"}"); + sendErr(r, 400, "invalid_params", "invalid spool params"); return; } rfid::stage(payload); - r->send(200, "application/json", "{\"ok\":true,\"staged\":true}"); + sendOk(r, apicore::shape::writeStaged); }); // ---- read (decrypt a tapped tag: for inspection / cloning) ---- server.on("/api/read", HTTP_GET, [](AsyncWebServerRequest *r) { - String data, uid; - bool enc; - if (!rfid::readTag(data, uid, enc)) { - r->send(404, "application/json", "{\"ok\":false,\"error\":\"no tag / auth failed\"}"); + JsonDocument doc; + const char *code; + if (!apicore::fillRead(doc, code)) { + sendErr(r, 404, code, "no tag / auth failed"); return; } - spool::Parsed p = spool::parse(data); + sendJson(r, 200, doc); + }); + + // ---- dump (raw tag blocks as hex, uninterpreted) ---- + server.on("/api/dump", HTTP_GET, [](AsyncWebServerRequest *r) { JsonDocument doc; - doc["ok"] = true; - doc["uid"] = uid; - doc["encrypted"] = enc; - doc["materialId"] = p.materialId; - doc["color"] = p.color; - doc["weight"] = p.weight; - String out; - serializeJson(doc, out); - r->send(200, "application/json", out); + const char *code; + if (!apicore::fillDump(doc, code)) { + sendErr(r, 404, code, "no tag / auth failed"); + return; + } + sendJson(r, 200, doc); }); // ---- buzzer test ---- server.on("/api/beep", HTTP_GET, [](AsyncWebServerRequest *r) { rfid::testBeep(); - r->send(200, "application/json", "{\"ok\":true}"); + sendOk(r, apicore::shape::ack); }); // ---- status ---- server.on("/api/status", HTTP_GET, - [](AsyncWebServerRequest *r) { r->send(200, "application/json", statusJson()); }); + [](AsyncWebServerRequest *r) { sendOk(r, apicore::fillStatus); }); // ---- config ---- - server.on("/api/config", HTTP_GET, [](AsyncWebServerRequest *r) { - Config &c = config::get(); - JsonDocument doc; - doc["wifiSsid"] = c.wifiSsid; - doc["apSsid"] = c.apSsid; - doc["hostname"] = c.hostname; - doc["logLevel"] = c.logLevel; - doc["baud"] = c.baud; - // passwords intentionally omitted - String out; - serializeJson(doc, out); - r->send(200, "application/json", out); - }); + server.on("/api/config", HTTP_GET, + [](AsyncWebServerRequest *r) { sendOk(r, apicore::fillConfigGet); }); server.on( "/api/config", HTTP_POST, @@ -162,20 +147,11 @@ void registerRoutes() { [](AsyncWebServerRequest *r, uint8_t *data, size_t len, size_t index, size_t total) { JsonDocument doc; if (deserializeJson(doc, data, len)) { - r->send(400, "application/json", "{\"ok\":false,\"error\":\"bad json\"}"); + sendErr(r, 400, "bad_json", "bad json"); return; } - Config &c = config::get(); - if (doc["wifiSsid"].is()) c.wifiSsid = doc["wifiSsid"].as(); - if (doc["wifiPass"].is()) c.wifiPass = doc["wifiPass"].as(); - if (doc["apSsid"].is()) c.apSsid = doc["apSsid"].as(); - if (doc["apPass"].is()) c.apPass = doc["apPass"].as(); - if (doc["hostname"].is()) c.hostname = doc["hostname"].as(); - if (doc["logLevel"].is()) c.logLevel = doc["logLevel"]; - if (doc["baud"].is()) c.baud = doc["baud"]; - config::save(); - logger::setLevel(static_cast(c.logLevel)); - r->send(200, "application/json", "{\"ok\":true,\"reboot\":true}"); + apicore::applyConfigSet(doc); + sendOk(r, apicore::shape::ackReboot); }); // ---- OTA firmware/filesystem update ---- @@ -189,13 +165,12 @@ void registerRoutes() { server.on( "/api/ota", HTTP_POST, [](AsyncWebServerRequest *r) { - JsonDocument d; - d["ok"] = s_otaOk; - if (!s_otaOk) d["error"] = s_otaErr; - String out; - serializeJson(d, out); - r->send(s_otaOk ? 200 : 500, "application/json", out); - if (s_otaOk) ota::scheduleReboot(); + if (s_otaOk) { + sendOk(r, apicore::shape::ack); + ota::scheduleReboot(); + } else { + sendErr(r, 500, "ota_failed", s_otaErr.c_str()); + } }, nullptr, [](AsyncWebServerRequest *r, uint8_t *data, size_t len, size_t index, size_t total) { diff --git a/firmware/test/test_contract/test_contract.cpp b/firmware/test/test_contract/test_contract.cpp new file mode 100644 index 0000000..db108a4 --- /dev/null +++ b/firmware/test/test_contract/test_contract.cpp @@ -0,0 +1,117 @@ +// Wire-contract test: runs the exact apicore::shape fillers that build every +// production reply, writes the serialized JSON to spec/v2/fixtures/generated/, +// and CI validates those files against spec/v2/schemas (validate.mjs). A C++ +// key edit without a schema update — or vice versa — fails CI. +// +// Host-only (pio test -e native -d firmware); never runs on the device. +#include + +#include +#include +#include +#include + +#include "api_core.h" + +namespace fs = std::filesystem; + +// Repo root relative to the test's working directory (PlatformIO runs tests +// from the project dir, firmware/; direct invocation may use the repo root). +static fs::path repoRoot() { + for (const char *root : {"..", "."}) { + if (fs::exists(fs::path(root) / "spec" / "v2" / "schemas")) return root; + } + TEST_FAIL_MESSAGE("cannot locate spec/v2/schemas from cwd"); + return {}; +} + +static void writeFixture(const char *stem, const char *name, const JsonDocument &doc) { + fs::path dir = repoRoot() / "spec" / "v2" / "fixtures" / "generated" / stem; + fs::create_directories(dir); + std::string out; + serializeJson(doc, out); + TEST_ASSERT_GREATER_THAN_MESSAGE(2, (int)out.size(), stem); + std::ofstream f(dir / (std::string(name) + ".json")); + f << out << "\n"; + TEST_ASSERT_TRUE_MESSAGE(f.good(), stem); +} + +void test_spec_reply() { + JsonDocument d; + apicore::shape::spec(d); + TEST_ASSERT_EQUAL(2, d["protocol"].as()); + TEST_ASSERT_TRUE(d["ok"].as()); + writeFixture("spec.reply", "ok", d); +} + +void test_read_reply() { + JsonDocument d; + apicore::shape::read(d, {"04A1B2C3", true, "01001", "1200F6", "1KG", "000001"}); + writeFixture("read.reply", "ok", d); +} + +void test_dump_reply() { + JsonDocument d; + apicore::shape::dump( + d, {"04A1B2C3", true, + "AB1240276A21010010C12E1F01650000010000000000000000000000000000000000000000000000" + "0000000000000000"}); + writeFixture("dump.reply", "ok", d); +} + +void test_status_reply() { + JsonDocument idle; + apicore::shape::status(idle, {false, "sta", "192.168.1.50", {false, false, false, "", ""}}); + writeFixture("status.reply", "idle", idle); + + JsonDocument done; + apicore::shape::status(done, {false, "ap", "192.168.4.1", {true, true, true, "04A1B2C3", ""}}); + writeFixture("status.reply", "write-done", done); +} + +void test_config_reply() { + JsonDocument d; + apicore::shape::configGet(d, {"MyWifi", "SpoolID", "spoolid", 3, 115200}); + writeFixture("config.reply", "get", d); +} + +void test_simple_replies() { + JsonDocument staged; + apicore::shape::writeStaged(staged); + writeFixture("write.reply", "staged", staged); + + JsonDocument reboot; + apicore::shape::ackReboot(reboot); + writeFixture("configset.reply", "ok", reboot); + writeFixture("ota.end.reply", "ok", reboot); + + JsonDocument wrote; + apicore::shape::written(wrote, 4096); + writeFixture("db.data.reply", "ok", wrote); + writeFixture("ota.data.reply", "ok", wrote); +} + +void test_every_error_code() { + // One fixture per stable code; err-*.json validates against common ErrorReply. + static const char *codes[] = {"bad_json", "invalid_params", "no_tag", + "db_failed", "ota_failed", "size_required", + "bad_chunk", "unknown_cmd", "internal"}; + for (const char *code : codes) { + JsonDocument d; + apicore::shape::error(d, code, "sample message"); + TEST_ASSERT_FALSE(d["ok"].as()); + writeFixture("errors", (std::string("err-") + code).c_str(), d); + } +} + +int main(int, char **) { + UNITY_BEGIN(); + RUN_TEST(test_spec_reply); + RUN_TEST(test_read_reply); + RUN_TEST(test_dump_reply); + RUN_TEST(test_status_reply); + RUN_TEST(test_config_reply); + RUN_TEST(test_simple_replies); + RUN_TEST(test_every_error_code); + return UNITY_END(); +}