Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand Down
25 changes: 11 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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":"<base64>"} / {"cmd":"dbend"} // upload a gzipped DB
{"cmd":"otabegin","size":N,"target":"app","md5":"..."} // start an OTA session
{"cmd":"otadata","b":"<base64 chunk>"} // 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=<hex>`.
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

Expand Down
12 changes: 12 additions & 0 deletions firmware/platformio.ini
Original file line number Diff line number Diff line change
Expand Up @@ -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 = -<*> +<api_core.cpp> +<ui_spec.cpp>
lib_deps = bblanchon/ArduinoJson@^7.4.3
80 changes: 80 additions & 0 deletions firmware/src/api_core.cpp
Original file line number Diff line number Diff line change
@@ -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<JsonObject>();
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
71 changes: 71 additions & 0 deletions firmware/src/api_core.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
#pragma once
#include <ArduinoJson.h>

#include <cstdint>

// 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
67 changes: 67 additions & 0 deletions firmware/src/api_core_device.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
#include <Arduino.h>

#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<const char *>()) c.wifiSsid = in["wifiSsid"].as<String>();
if (in["wifiPass"].is<const char *>()) c.wifiPass = in["wifiPass"].as<String>();
if (in["apSsid"].is<const char *>()) c.apSsid = in["apSsid"].as<String>();
if (in["apPass"].is<const char *>()) c.apPass = in["apPass"].as<String>();
if (in["hostname"].is<const char *>()) c.hostname = in["hostname"].as<String>();
if (in["logLevel"].is<uint8_t>()) c.logLevel = in["logLevel"];
if (in["baud"].is<uint32_t>()) c.baud = in["baud"];
config::save();
logger::setLevel(static_cast<LogLevel>(c.logLevel));
}

} // namespace apicore
Loading
Loading