From 4bc8f9916aad237e6bf0241e4253a59f9a47a66a Mon Sep 17 00:00:00 2001 From: Mateus Demboski Date: Mon, 6 Jul 2026 19:10:03 -0300 Subject: [PATCH 1/2] feat(spec): versioned wire-contract spec (v1 as-is + v2 target) Formalize the firmware wire contract as JSON Schema 2020-12 under spec/: v1 pins current 1.x behavior verbatim (transport divergences documented, frozen after merge), v2 defines the unified target contract (envelope on every reply, stable error codes, protocol handshake field). Fixtures are golden replies validated by Ajv in CI; spec/scripts/capture_fixtures.py re-captures them from a live device. Bump CI to Node 26 and replace corepack (unbundled since Node 25) with pnpm/action-setup. Refs #18 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01SBnenzHAV3VVAF8b8hpwkT --- .github/workflows/ci.yml | 41 +++++-- .github/workflows/release.yml | 16 +-- .gitignore | 6 +- CLAUDE.md | 7 +- spec/scripts/capture_fixtures.py | 100 ++++++++++++++++++ spec/ts/package.json | 14 +++ spec/ts/pnpm-lock.yaml | 63 +++++++++++ spec/ts/scripts/validate.mjs | 93 ++++++++++++++++ spec/v1/PROTOCOL.md | 45 ++++++++ spec/v1/fixtures/config.reply/get-http.json | 1 + spec/v1/fixtures/config.reply/get-serial.json | 1 + spec/v1/fixtures/configset.reply/ok.json | 1 + spec/v1/fixtures/db.data.reply/ok.json | 1 + spec/v1/fixtures/dump.reply/ok.json | 1 + spec/v1/fixtures/ota.data.reply/ok.json | 1 + spec/v1/fixtures/ota.end.reply/err-md5.json | 1 + spec/v1/fixtures/ota.end.reply/ok.json | 1 + spec/v1/fixtures/read.reply/err-no-tag.json | 1 + spec/v1/fixtures/read.reply/ok.json | 1 + spec/v1/fixtures/spec.reply/ok-http.json | 7 ++ spec/v1/fixtures/spec.reply/ok-serial.json | 8 ++ spec/v1/fixtures/status.reply/idle-http.json | 1 + .../v1/fixtures/status.reply/idle-serial.json | 1 + .../status.reply/write-done-http.json | 1 + .../write.reply/err-invalid-params.json | 1 + spec/v1/fixtures/write.reply/staged.json | 1 + spec/v1/schemas/common.schema.json | 39 +++++++ spec/v1/schemas/config.params.schema.json | 16 +++ spec/v1/schemas/config.reply.schema.json | 16 +++ spec/v1/schemas/configset.reply.schema.json | 12 +++ spec/v1/schemas/db.begin.params.schema.json | 10 ++ spec/v1/schemas/db.data.params.schema.json | 11 ++ spec/v1/schemas/db.data.reply.schema.json | 11 ++ spec/v1/schemas/dump.reply.schema.json | 14 +++ spec/v1/schemas/ota.begin.params.schema.json | 13 +++ spec/v1/schemas/ota.data.params.schema.json | 11 ++ spec/v1/schemas/ota.data.reply.schema.json | 12 +++ spec/v1/schemas/ota.end.reply.schema.json | 12 +++ spec/v1/schemas/read.reply.schema.json | 16 +++ spec/v1/schemas/spec.reply.schema.json | 16 +++ spec/v1/schemas/status.reply.schema.json | 15 +++ spec/v1/schemas/write.params.schema.json | 13 +++ spec/v1/schemas/write.reply.schema.json | 12 +++ spec/v2/PROTOCOL.md | 99 +++++++++++++++++ spec/v2/fixtures/config.reply/get.json | 1 + spec/v2/fixtures/configset.reply/ok.json | 1 + spec/v2/fixtures/db.data.reply/ok.json | 1 + spec/v2/fixtures/dump.reply/ok.json | 1 + spec/v2/fixtures/ota.data.reply/ok.json | 1 + spec/v2/fixtures/ota.end.reply/err-ota.json | 1 + spec/v2/fixtures/ota.end.reply/ok.json | 1 + spec/v2/fixtures/read.reply/err-no-tag.json | 1 + spec/v2/fixtures/read.reply/ok.json | 1 + spec/v2/fixtures/spec.reply/ok.json | 9 ++ spec/v2/fixtures/status.reply/idle.json | 1 + spec/v2/fixtures/status.reply/write-done.json | 1 + .../v2/fixtures/write.reply/err-bad-json.json | 1 + .../write.reply/err-invalid-params.json | 1 + spec/v2/fixtures/write.reply/staged.json | 1 + spec/v2/schemas/common.schema.json | 44 ++++++++ spec/v2/schemas/config.params.schema.json | 16 +++ spec/v2/schemas/config.reply.schema.json | 16 +++ spec/v2/schemas/configset.reply.schema.json | 12 +++ spec/v2/schemas/db.begin.params.schema.json | 11 ++ spec/v2/schemas/db.data.params.schema.json | 11 ++ spec/v2/schemas/db.data.reply.schema.json | 12 +++ spec/v2/schemas/dump.reply.schema.json | 14 +++ spec/v2/schemas/material-database.schema.json | 34 ++++++ spec/v2/schemas/ota.begin.params.schema.json | 13 +++ spec/v2/schemas/ota.data.params.schema.json | 11 ++ spec/v2/schemas/ota.data.reply.schema.json | 12 +++ spec/v2/schemas/ota.end.reply.schema.json | 12 +++ spec/v2/schemas/read.reply.schema.json | 17 +++ spec/v2/schemas/spec.reply.schema.json | 17 +++ spec/v2/schemas/status.reply.schema.json | 15 +++ spec/v2/schemas/write.params.schema.json | 13 +++ spec/v2/schemas/write.reply.schema.json | 12 +++ 77 files changed, 1062 insertions(+), 16 deletions(-) create mode 100644 spec/scripts/capture_fixtures.py create mode 100644 spec/ts/package.json create mode 100644 spec/ts/pnpm-lock.yaml create mode 100644 spec/ts/scripts/validate.mjs create mode 100644 spec/v1/PROTOCOL.md create mode 100644 spec/v1/fixtures/config.reply/get-http.json create mode 100644 spec/v1/fixtures/config.reply/get-serial.json create mode 100644 spec/v1/fixtures/configset.reply/ok.json create mode 100644 spec/v1/fixtures/db.data.reply/ok.json create mode 100644 spec/v1/fixtures/dump.reply/ok.json create mode 100644 spec/v1/fixtures/ota.data.reply/ok.json create mode 100644 spec/v1/fixtures/ota.end.reply/err-md5.json create mode 100644 spec/v1/fixtures/ota.end.reply/ok.json create mode 100644 spec/v1/fixtures/read.reply/err-no-tag.json create mode 100644 spec/v1/fixtures/read.reply/ok.json create mode 100644 spec/v1/fixtures/spec.reply/ok-http.json create mode 100644 spec/v1/fixtures/spec.reply/ok-serial.json create mode 100644 spec/v1/fixtures/status.reply/idle-http.json create mode 100644 spec/v1/fixtures/status.reply/idle-serial.json create mode 100644 spec/v1/fixtures/status.reply/write-done-http.json create mode 100644 spec/v1/fixtures/write.reply/err-invalid-params.json create mode 100644 spec/v1/fixtures/write.reply/staged.json create mode 100644 spec/v1/schemas/common.schema.json create mode 100644 spec/v1/schemas/config.params.schema.json create mode 100644 spec/v1/schemas/config.reply.schema.json create mode 100644 spec/v1/schemas/configset.reply.schema.json create mode 100644 spec/v1/schemas/db.begin.params.schema.json create mode 100644 spec/v1/schemas/db.data.params.schema.json create mode 100644 spec/v1/schemas/db.data.reply.schema.json create mode 100644 spec/v1/schemas/dump.reply.schema.json create mode 100644 spec/v1/schemas/ota.begin.params.schema.json create mode 100644 spec/v1/schemas/ota.data.params.schema.json create mode 100644 spec/v1/schemas/ota.data.reply.schema.json create mode 100644 spec/v1/schemas/ota.end.reply.schema.json create mode 100644 spec/v1/schemas/read.reply.schema.json create mode 100644 spec/v1/schemas/spec.reply.schema.json create mode 100644 spec/v1/schemas/status.reply.schema.json create mode 100644 spec/v1/schemas/write.params.schema.json create mode 100644 spec/v1/schemas/write.reply.schema.json create mode 100644 spec/v2/PROTOCOL.md create mode 100644 spec/v2/fixtures/config.reply/get.json create mode 100644 spec/v2/fixtures/configset.reply/ok.json create mode 100644 spec/v2/fixtures/db.data.reply/ok.json create mode 100644 spec/v2/fixtures/dump.reply/ok.json create mode 100644 spec/v2/fixtures/ota.data.reply/ok.json create mode 100644 spec/v2/fixtures/ota.end.reply/err-ota.json create mode 100644 spec/v2/fixtures/ota.end.reply/ok.json create mode 100644 spec/v2/fixtures/read.reply/err-no-tag.json create mode 100644 spec/v2/fixtures/read.reply/ok.json create mode 100644 spec/v2/fixtures/spec.reply/ok.json create mode 100644 spec/v2/fixtures/status.reply/idle.json create mode 100644 spec/v2/fixtures/status.reply/write-done.json create mode 100644 spec/v2/fixtures/write.reply/err-bad-json.json create mode 100644 spec/v2/fixtures/write.reply/err-invalid-params.json create mode 100644 spec/v2/fixtures/write.reply/staged.json create mode 100644 spec/v2/schemas/common.schema.json create mode 100644 spec/v2/schemas/config.params.schema.json create mode 100644 spec/v2/schemas/config.reply.schema.json create mode 100644 spec/v2/schemas/configset.reply.schema.json create mode 100644 spec/v2/schemas/db.begin.params.schema.json create mode 100644 spec/v2/schemas/db.data.params.schema.json create mode 100644 spec/v2/schemas/db.data.reply.schema.json create mode 100644 spec/v2/schemas/dump.reply.schema.json create mode 100644 spec/v2/schemas/material-database.schema.json create mode 100644 spec/v2/schemas/ota.begin.params.schema.json create mode 100644 spec/v2/schemas/ota.data.params.schema.json create mode 100644 spec/v2/schemas/ota.data.reply.schema.json create mode 100644 spec/v2/schemas/ota.end.reply.schema.json create mode 100644 spec/v2/schemas/read.reply.schema.json create mode 100644 spec/v2/schemas/spec.reply.schema.json create mode 100644 spec/v2/schemas/status.reply.schema.json create mode 100644 spec/v2/schemas/write.params.schema.json create mode 100644 spec/v2/schemas/write.reply.schema.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 71f703f..ee64a21 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,6 +16,7 @@ jobs: firmware: ${{ steps.filter.outputs.firmware }} web: ${{ steps.filter.outputs.web }} desktop: ${{ steps.filter.outputs.desktop }} + spec: ${{ steps.filter.outputs.spec }} steps: - uses: actions/checkout@v7 - uses: dorny/paths-filter@v4 @@ -31,8 +32,32 @@ jobs: - 'tools/build_web.py' - 'tools/gen_db_header.py' - 'material_database.json' + - 'spec/**' desktop: - 'desktop/**' + - 'spec/**' + spec: + - 'spec/**' + - 'material_database.json' + + spec: + needs: changes + if: needs.changes.outputs.spec == 'true' + name: Validate wire-contract spec + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v6 + with: + node-version: "26" + # corepack is not bundled with Node >= 25; use the pnpm action instead. + - uses: pnpm/action-setup@v4 + with: + version: 11 + - name: Validate schemas + fixtures + run: | + pnpm --dir spec/ts install --frozen-lockfile + pnpm --dir spec/ts validate firmware: needs: changes @@ -72,9 +97,11 @@ jobs: python-version: "3.12" - uses: actions/setup-node@v6 with: - node-version: "22" - - name: Enable pnpm (via corepack) - run: corepack enable + node-version: "26" + # corepack is not bundled with Node >= 25; use the pnpm action instead. + - uses: pnpm/action-setup@v4 + with: + version: 11 - name: Generate DB header + build/gzip web assets # build_web.py runs the Vite build (pnpm) then gzips web/dist. run: | @@ -94,9 +121,11 @@ jobs: go-version: "1.25" - uses: actions/setup-node@v6 with: - node-version: "22" - - name: Enable pnpm (via corepack) - run: corepack enable + node-version: "26" + # corepack is not bundled with Node >= 25; use the pnpm action instead. + - uses: pnpm/action-setup@v4 + with: + version: 11 - name: Install Wails system deps run: | sudo apt-get update diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0961580..0f04a6f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -39,9 +39,11 @@ jobs: python-version: "3.12" - uses: actions/setup-node@v6 with: - node-version: "22" - - name: Enable pnpm (via corepack) - run: corepack enable + node-version: "26" + # corepack is not bundled with Node >= 25; use the pnpm action instead. + - uses: pnpm/action-setup@v4 + with: + version: 11 - name: Install PlatformIO run: pip install platformio - name: Prepare generated + secret files @@ -101,9 +103,11 @@ jobs: go-version: "1.25" - uses: actions/setup-node@v6 with: - node-version: "22" - - name: Enable pnpm (via corepack) - run: corepack enable + node-version: "26" + # corepack is not bundled with Node >= 25; use the pnpm action instead. + - uses: pnpm/action-setup@v4 + with: + version: 11 - name: Install Wails system deps (Linux) if: runner.os == 'Linux' run: | diff --git a/.gitignore b/.gitignore index 7b653bf..6895f3c 100644 --- a/.gitignore +++ b/.gitignore @@ -15,9 +15,9 @@ firmware/.pio/ firmware/data/*.gz firmware/src/material_db_default.h -# generated from shared/spec.json by tools/gen_spec.py -firmware/src/generated_spec.h -web/spec.js +# spec tooling (deps + fixtures generated by the firmware native contract test) +spec/ts/node_modules/ +spec/v2/fixtures/generated/ # desktop (Wails: Go backend + Vite/TS frontend) — generated, rebuilt by wails desktop/build/bin/ diff --git a/CLAUDE.md b/CLAUDE.md index c7cb989..d4f7c21 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -33,6 +33,11 @@ Both transports are thin shells over the same core: `rfid_writer`, `config`, `ma `ui_spec`, `spool_data`, `creality_crypto`. When adding a capability, add it to the core and expose it through *both* `web_server.cpp` and `serial_proto.cpp`. +**The wire contract is specified in `spec/`** (JSON Schema 2020-12 + PROTOCOL.md, versioned: +`spec/v1/` = frozen 1.x behavior, `spec/v2/` = unified target; see issue #18). Validate with +`pnpm --dir spec/ts install && pnpm --dir spec/ts validate`. Any change to a reply shape in +`web_server.cpp` / `serial_proto.cpp` must update the schemas + fixtures in the same PR. + ### Crypto (do not get this wrong — it determines whether the printer reads the tag) `firmware/src/creality_crypto.cpp` — AES-128-ECB with two fixed keys (`U_KEY`, `D_KEY`, reverse-engineered from the K2-RFID reference: https://github.com/DnG-Crafts/K2-RFID). Mode 0 (`U_KEY`): the UID replicated to 16 @@ -71,7 +76,7 @@ cp firmware/include/secrets.h.example firmware/include/secrets.h # if missing .venv/bin/pio device monitor -d firmware # serial @115200 # Desktop app (Wails: Go backend + Vite/TS frontend). -# One-time: install Go 1.23+, Node 18+ with pnpm (corepack enable), and the Wails CLI: +# One-time: install Go 1.23+, Node 26+ with pnpm (npm i -g pnpm), and the Wails CLI: # go install github.com/wailsapp/wails/v2/cmd/wails@latest cd desktop wails dev # hot-reload dev window diff --git a/spec/scripts/capture_fixtures.py b/spec/scripts/capture_fixtures.py new file mode 100644 index 0000000..bb48293 --- /dev/null +++ b/spec/scripts/capture_fixtures.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 +"""Capture wire-protocol fixtures from a live SpoolID device. + +Overwrites the hand-authored fixtures with the device's actual replies so CI +validates against reality. Read-only operations only (spec/status/config/read/ +dump) — nothing is written to tags or flash. + +Usage: + python spec/scripts/capture_fixtures.py --version v1 --host 192.168.1.50 + python spec/scripts/capture_fixtures.py --version v1 --port /dev/cu.usbmodem101 + (pass both to capture both transports; hold a tag on the reader for read/dump + "ok" cases, remove it for the err-no-tag case when prompted) + +Serial capture needs pyserial (already in the PlatformIO venv): + .venv/bin/python spec/scripts/capture_fixtures.py ... +""" +import argparse +import json +import sys +import urllib.request +from pathlib import Path + +SPEC = Path(__file__).resolve().parent.parent + + +def save(version: str, stem: str, case: str, payload: dict) -> None: + out = SPEC / version / "fixtures" / stem / f"{case}.json" + out.parent.mkdir(parents=True, exist_ok=True) + out.write_text(json.dumps(payload, indent=2) + "\n") + print(f" {out.relative_to(SPEC)}") + + +def http_get(host: str, path: str) -> dict: + with urllib.request.urlopen(f"http://{host}{path}", timeout=10) as r: + return json.load(r) + + +def capture_http(version: str, host: str) -> None: + print(f"HTTP capture from {host}:") + suffix = "-http" if version == "v1" else "" + save(version, "spec.reply", f"ok{suffix}", http_get(host, "/api/spec")) + save(version, "status.reply", f"idle{suffix}", http_get(host, "/api/status")) + save(version, "config.reply", f"get{suffix}", http_get(host, "/api/config")) + input("hold a tag on the reader, then press Enter... ") + save(version, "read.reply", "ok", http_get(host, "/api/read")) + input("remove the tag, then press Enter... ") + try: + http_get(host, "/api/read") + except urllib.error.HTTPError as e: # 404 carries the error body + save(version, "read.reply", "err-no-tag", json.load(e)) + + +def send_serial(ser, cmd: dict) -> dict: + ser.reset_input_buffer() + ser.write((json.dumps(cmd) + "\n").encode()) + while True: + line = ser.readline().decode(errors="replace").strip() + if not line: + raise TimeoutError(f"no reply to {cmd}") + if line.startswith("{"): # log lines start with '[' + return json.loads(line) + + +def capture_serial(version: str, port: str) -> None: + import serial # pyserial, from the PlatformIO venv + + print(f"serial capture from {port}:") + with serial.Serial(port, 115200, timeout=10) as ser: + ser.dtr = False + ser.rts = False + suffix = "-serial" if version == "v1" else "" + save(version, "spec.reply", f"ok{suffix}", send_serial(ser, {"cmd": "getspec"})) + save(version, "status.reply", f"idle{suffix}", send_serial(ser, {"cmd": "status"})) + save(version, "config.reply", f"get{suffix}", send_serial(ser, {"cmd": "getconfig"})) + input("hold a tag on the reader, then press Enter... ") + save(version, "read.reply", "ok", send_serial(ser, {"cmd": "read"})) + save(version, "dump.reply", "ok", send_serial(ser, {"cmd": "dump"})) + input("remove the tag, then press Enter... ") + save(version, "read.reply", "err-no-tag", send_serial(ser, {"cmd": "read"})) + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--version", choices=["v1", "v2"], required=True) + ap.add_argument("--host", help="device IP for HTTP capture") + ap.add_argument("--port", help="serial port for serial capture") + args = ap.parse_args() + if not args.host and not args.port: + ap.error("need --host and/or --port") + if args.host: + capture_http(args.version, args.host) + if args.port: + capture_serial(args.version, args.port) + print("done — review config.reply fixtures for private values (WiFi SSID, hostname),") + print("then run `pnpm --dir spec/ts validate` and commit the diff") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/spec/ts/package.json b/spec/ts/package.json new file mode 100644 index 0000000..963e302 --- /dev/null +++ b/spec/ts/package.json @@ -0,0 +1,14 @@ +{ + "name": "@spoolid/core", + "private": true, + "version": "1.0.0", + "type": "module", + "description": "SpoolID shared core: wire-contract validation tooling (full package lands with the shared-client refactor)", + "scripts": { + "validate": "node scripts/validate.mjs" + }, + "devDependencies": { + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1" + } +} diff --git a/spec/ts/pnpm-lock.yaml b/spec/ts/pnpm-lock.yaml new file mode 100644 index 0000000..588c5ff --- /dev/null +++ b/spec/ts/pnpm-lock.yaml @@ -0,0 +1,63 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + ajv: + specifier: ^8.17.1 + version: 8.20.0 + ajv-formats: + specifier: ^3.0.1 + version: 3.0.1(ajv@8.20.0) + +packages: + + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-uri@3.1.3: + resolution: {integrity: sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==} + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + +snapshots: + + ajv-formats@3.0.1(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.3 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + fast-deep-equal@3.1.3: {} + + fast-uri@3.1.3: {} + + json-schema-traverse@1.0.0: {} + + require-from-string@2.0.2: {} diff --git a/spec/ts/scripts/validate.mjs b/spec/ts/scripts/validate.mjs new file mode 100644 index 0000000..75cf47e --- /dev/null +++ b/spec/ts/scripts/validate.mjs @@ -0,0 +1,93 @@ +#!/usr/bin/env node +// Validates the wire-contract spec: every schema compiles (Ajv strict mode is +// the schema lint), every fixture matches its schema, and the repo-root +// material_database.json matches the v2 DB schema. +// +// Fixture layout: spec//fixtures//.json validates +// against spec//schemas/.schema.json. Files named +// err-*.json validate against that version's common ErrorReply instead. +import { readFileSync, readdirSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { Ajv2020 } from "ajv/dist/2020.js"; +import addFormats from "ajv-formats"; + +const SPEC = resolve(dirname(fileURLToPath(import.meta.url)), "..", ".."); +const BASE = "https://spoolid.github.io/schemas"; + +const readJson = (p) => JSON.parse(readFileSync(p, "utf8")); +const list = (dir, filter) => { + try { + return readdirSync(dir, { withFileTypes: true }).filter(filter).map((e) => e.name); + } catch { + return []; + } +}; + +let failures = 0; +const fail = (what, errors) => { + failures++; + console.error(`FAIL ${what}`); + for (const e of errors ?? []) console.error(` ${e.instancePath || "/"} ${e.message}`); +}; + +// getSchema compiles lazily and throws on unresolvable $refs; report, don't crash. +const compiled = (ajv, id) => { + try { + return ajv.getSchema(id); + } catch { + return undefined; + } +}; + +const checkFixture = (ajv, version, dir, stem, name) => { + const schemaId = name.startsWith("err-") + ? `${BASE}/${version}/common.schema.json#/$defs/ErrorReply` + : `${BASE}/${version}/${stem}.schema.json`; + const validate = compiled(ajv, schemaId); + if (!validate) return fail(`${dir}/${stem}/${name}: no schema ${schemaId}`); + if (!validate(readJson(join(dir, stem, name)))) fail(`${dir}/${stem}/${name}`, validate.errors); +}; + +for (const version of ["v1", "v2"]) { + const ajv = new Ajv2020({ strict: true, allErrors: true }); + addFormats(ajv); + + const schemasDir = join(SPEC, version, "schemas"); + for (const name of list(schemasDir, (e) => e.name.endsWith(".schema.json"))) { + ajv.addSchema(readJson(join(schemasDir, name))); + } + // Compiling every schema up front is the lint step: bad $refs, bad keywords, + // and strict-mode violations all surface here. + for (const id of Object.keys(ajv.schemas).filter((k) => k.startsWith(BASE))) { + try { + ajv.getSchema(id) ?? fail(`${version} compile ${id}`); + } catch (e) { + fail(`${version} compile ${id}: ${e.message}`); + } + } + + // Hand-captured fixtures, plus fixtures/generated/ written by the firmware + // native contract test (git-ignored) — same /.json layout. + const fixturesDir = join(SPEC, version, "fixtures"); + for (const dir of [fixturesDir, join(fixturesDir, "generated")]) { + for (const stem of list(dir, (e) => e.isDirectory() && e.name !== "generated")) { + for (const name of list(join(dir, stem), (e) => e.name.endsWith(".json"))) { + checkFixture(ajv, version, dir, stem, name); + } + } + } + + if (version === "v2") { + const validate = compiled(ajv, `${BASE}/v2/material-database.schema.json`); + const db = readJson(join(SPEC, "..", "material_database.json")); + if (!validate) fail("material-database schema failed to compile"); + else if (!validate(db)) fail("material_database.json", validate.errors); + } +} + +if (failures) { + console.error(`\n${failures} failure(s)`); + process.exit(1); +} +console.log("spec OK: schemas compile, fixtures + material_database.json validate"); diff --git a/spec/v1/PROTOCOL.md b/spec/v1/PROTOCOL.md new file mode 100644 index 0000000..909858a --- /dev/null +++ b/spec/v1/PROTOCOL.md @@ -0,0 +1,45 @@ +# SpoolID Wire Protocol — v1 (frozen) + +**Status:** frozen snapshot of what 1.x firmware actually speaks. Errata-only after +merge; new work happens in [`../v2/PROTOCOL.md`](../v2/PROTOCOL.md). This is the contract +a v2 client falls back to when `getspec` returns no `protocol` field. + +v1 was never designed as one contract — it is two hand-written transports that drifted. +The schemas in [`schemas/`](schemas/) describe the union faithfully (relaxed where the +transports disagree), and the table below pins the divergences. Fixtures in +[`fixtures/`](fixtures/) are captured from a device running 1.x firmware. + +## Shared behavior + +- Success replies carry `ok: true` **on serial always; on HTTP only for some routes** + (see divergences). Error replies are `{"ok": false, "error": ""}` on both — + no `code` field in v1. +- Operations and shapes otherwise match v2 minus: no `protocol` in the spec reply, no + `serial` in the read reply, `dbdata` acks without `written`. +- Serial framing identical to v2: line-JSON, `[`-prefixed log lines ignored, CR/LF/CRLF, + 6144-byte line cap, base64 chunks ≤ 4096 raw bytes. + +## Transport divergence table + +| # | Divergence | HTTP (web_server.cpp) | Serial (serial_proto.cpp) | +|---|---|---|---| +| 1 | `ok` on read-style replies | **omitted** on `GET /api/spec`, `/api/status`, `/api/config` | always present (`getspec`, `status`, `getconfig`) | +| 2 | `dump` | not available | `{"cmd":"dump"}` | +| 3 | `otaabort` | not available (drop the connection) | `{"cmd":"otaabort"}` | +| 4 | DB upload | single POST of pre-gzipped bytes, streamed to LittleFS; **always replies `{"ok":true}`**, even if the write failed | `dbbegin` → `dbdata {b}` ×N → `dbend`, each chunk acked `{"ok":true}` | +| 5 | `dbbegin` `size` | n/a | desktop sends `{size}`; **firmware ignores it** (v1 schema: optional) | +| 6 | OTA parameters | query string `?target=app\|fs&md5=<32hex>`, raw streamed body | JSON body `{size, target, md5?}`, base64 chunks; `otadata` replies include `written` | +| 7 | Error transport | HTTP status 400/404/500 + body | `ok:false` body only | +| 8 | `write.params` color | leading `#` accepted (firmware trims) | same — but desktop always sends bare hex | + +## Operations (as shipped in 1.x) + +`spec` (`GET /api/spec` / `getspec`), `read`, `write`, `status`, `beep`, +`config.get`/`config.set`, `db.put`, `ota.flash`, plus serial-only `dump` and `otaabort`. +Route table and command names are identical to v2 except: no `GET /api/dump`. + +## Compatibility + +`getspec`/`/api/spec` returns `version` (firmware semver) only. Desktop 1.x gates on +matching `major.minor`; the web UI ships on the device's own flash and is inherently +matched. Dev builds report `0.0.0-dev` and skip the gate. diff --git a/spec/v1/fixtures/config.reply/get-http.json b/spec/v1/fixtures/config.reply/get-http.json new file mode 100644 index 0000000..9a762cd --- /dev/null +++ b/spec/v1/fixtures/config.reply/get-http.json @@ -0,0 +1 @@ +{"wifiSsid": "MyWifi", "apSsid": "SpoolID", "hostname": "spoolid", "logLevel": 3, "baud": 115200} diff --git a/spec/v1/fixtures/config.reply/get-serial.json b/spec/v1/fixtures/config.reply/get-serial.json new file mode 100644 index 0000000..d220715 --- /dev/null +++ b/spec/v1/fixtures/config.reply/get-serial.json @@ -0,0 +1 @@ +{"ok": true, "wifiSsid": "MyWifi", "apSsid": "SpoolID", "hostname": "spoolid", "logLevel": 3, "baud": 115200} diff --git a/spec/v1/fixtures/configset.reply/ok.json b/spec/v1/fixtures/configset.reply/ok.json new file mode 100644 index 0000000..cfc36f7 --- /dev/null +++ b/spec/v1/fixtures/configset.reply/ok.json @@ -0,0 +1 @@ +{"ok": true, "reboot": true} diff --git a/spec/v1/fixtures/db.data.reply/ok.json b/spec/v1/fixtures/db.data.reply/ok.json new file mode 100644 index 0000000..bb663e7 --- /dev/null +++ b/spec/v1/fixtures/db.data.reply/ok.json @@ -0,0 +1 @@ +{"ok": true} diff --git a/spec/v1/fixtures/dump.reply/ok.json b/spec/v1/fixtures/dump.reply/ok.json new file mode 100644 index 0000000..f97273f --- /dev/null +++ b/spec/v1/fixtures/dump.reply/ok.json @@ -0,0 +1 @@ +{"ok": true, "uid": "04A1B2C3", "encrypted": true, "data": "AB1240276A21010010C12E1F0165000001000000000000000000000000000000"} diff --git a/spec/v1/fixtures/ota.data.reply/ok.json b/spec/v1/fixtures/ota.data.reply/ok.json new file mode 100644 index 0000000..59f9407 --- /dev/null +++ b/spec/v1/fixtures/ota.data.reply/ok.json @@ -0,0 +1 @@ +{"ok": true, "written": 4096} diff --git a/spec/v1/fixtures/ota.end.reply/err-md5.json b/spec/v1/fixtures/ota.end.reply/err-md5.json new file mode 100644 index 0000000..e1aa548 --- /dev/null +++ b/spec/v1/fixtures/ota.end.reply/err-md5.json @@ -0,0 +1 @@ +{"ok": false, "error": "md5 mismatch"} diff --git a/spec/v1/fixtures/ota.end.reply/ok.json b/spec/v1/fixtures/ota.end.reply/ok.json new file mode 100644 index 0000000..cfc36f7 --- /dev/null +++ b/spec/v1/fixtures/ota.end.reply/ok.json @@ -0,0 +1 @@ +{"ok": true, "reboot": true} diff --git a/spec/v1/fixtures/read.reply/err-no-tag.json b/spec/v1/fixtures/read.reply/err-no-tag.json new file mode 100644 index 0000000..ae5ce8f --- /dev/null +++ b/spec/v1/fixtures/read.reply/err-no-tag.json @@ -0,0 +1 @@ +{"ok": false, "error": "no tag / auth failed"} diff --git a/spec/v1/fixtures/read.reply/ok.json b/spec/v1/fixtures/read.reply/ok.json new file mode 100644 index 0000000..709d16d --- /dev/null +++ b/spec/v1/fixtures/read.reply/ok.json @@ -0,0 +1 @@ +{"ok": true, "uid": "04A1B2C3", "encrypted": true, "materialId": "01001", "color": "1200F6", "weight": "1KG"} diff --git a/spec/v1/fixtures/spec.reply/ok-http.json b/spec/v1/fixtures/spec.reply/ok-http.json new file mode 100644 index 0000000..ce2b823 --- /dev/null +++ b/spec/v1/fixtures/spec.reply/ok-http.json @@ -0,0 +1,7 @@ +{ + "version": "1.0.0", + "colorSwatches": ["1200F6", "3894E1", "FEFF01", "F8D531", "F38E24", "52D048", "00FEBE", "B700F3", "EE301A", "FA5959", "FFFFFF", "D8D8D8", "4C4C4C", "782543", "000000"], + "weightLabels": ["1KG", "750G", "600G", "500G", "250G"], + "baudRates": [9600, 57600, 115200, 230400, 460800, 921600], + "logLevels": ["0 none", "1 error", "2 warn", "3 info", "4 debug"] +} diff --git a/spec/v1/fixtures/spec.reply/ok-serial.json b/spec/v1/fixtures/spec.reply/ok-serial.json new file mode 100644 index 0000000..a0e9883 --- /dev/null +++ b/spec/v1/fixtures/spec.reply/ok-serial.json @@ -0,0 +1,8 @@ +{ + "ok": true, + "version": "1.0.0", + "colorSwatches": ["1200F6", "3894E1", "FEFF01", "F8D531", "F38E24", "52D048", "00FEBE", "B700F3", "EE301A", "FA5959", "FFFFFF", "D8D8D8", "4C4C4C", "782543", "000000"], + "weightLabels": ["1KG", "750G", "600G", "500G", "250G"], + "baudRates": [9600, 57600, 115200, 230400, 460800, 921600], + "logLevels": ["0 none", "1 error", "2 warn", "3 info", "4 debug"] +} diff --git a/spec/v1/fixtures/status.reply/idle-http.json b/spec/v1/fixtures/status.reply/idle-http.json new file mode 100644 index 0000000..f9f92d4 --- /dev/null +++ b/spec/v1/fixtures/status.reply/idle-http.json @@ -0,0 +1 @@ +{"pending": false, "mode": "sta", "ip": "192.168.1.50", "last": {"done": false, "ok": false, "encrypted": false, "uid": "", "error": ""}} diff --git a/spec/v1/fixtures/status.reply/idle-serial.json b/spec/v1/fixtures/status.reply/idle-serial.json new file mode 100644 index 0000000..f991860 --- /dev/null +++ b/spec/v1/fixtures/status.reply/idle-serial.json @@ -0,0 +1 @@ +{"ok": true, "pending": false, "mode": "sta", "ip": "192.168.1.50", "last": {"done": false, "ok": false, "encrypted": false, "uid": "", "error": ""}} diff --git a/spec/v1/fixtures/status.reply/write-done-http.json b/spec/v1/fixtures/status.reply/write-done-http.json new file mode 100644 index 0000000..611db62 --- /dev/null +++ b/spec/v1/fixtures/status.reply/write-done-http.json @@ -0,0 +1 @@ +{"pending": false, "mode": "ap", "ip": "192.168.4.1", "last": {"done": true, "ok": true, "encrypted": true, "uid": "04A1B2C3", "error": ""}} diff --git a/spec/v1/fixtures/write.reply/err-invalid-params.json b/spec/v1/fixtures/write.reply/err-invalid-params.json new file mode 100644 index 0000000..e45df17 --- /dev/null +++ b/spec/v1/fixtures/write.reply/err-invalid-params.json @@ -0,0 +1 @@ +{"ok": false, "error": "invalid spool params"} diff --git a/spec/v1/fixtures/write.reply/staged.json b/spec/v1/fixtures/write.reply/staged.json new file mode 100644 index 0000000..8b64550 --- /dev/null +++ b/spec/v1/fixtures/write.reply/staged.json @@ -0,0 +1 @@ +{"ok": true, "staged": true} diff --git a/spec/v1/schemas/common.schema.json b/spec/v1/schemas/common.schema.json new file mode 100644 index 0000000..a37e4b9 --- /dev/null +++ b/spec/v1/schemas/common.schema.json @@ -0,0 +1,39 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://spoolid.github.io/schemas/v1/common.schema.json", + "title": "Common", + "$defs": { + "Uid": { "type": "string", "pattern": "^[0-9A-Fa-f]{8,20}$" }, + "HexColor6": { "type": "string", "pattern": "^[0-9A-Fa-f]{6}$" }, + "SemVer": { "type": "string", "pattern": "^\\d+\\.\\d+\\.\\d+(-[0-9A-Za-z.-]+)?$" }, + "Md5Hex": { "type": "string", "pattern": "^[0-9a-fA-F]{32}$" }, + "Target": { "enum": ["app", "fs"] }, + "ErrorReply": { + "type": "object", + "properties": { + "ok": { "const": false }, + "error": { "type": "string" } + }, + "required": ["ok", "error"], + "additionalProperties": false + }, + "Ack": { + "type": "object", + "properties": { "ok": { "const": true } }, + "required": ["ok"], + "additionalProperties": false + }, + "LastWrite": { + "type": "object", + "properties": { + "done": { "type": "boolean" }, + "ok": { "type": "boolean" }, + "encrypted": { "type": "boolean" }, + "uid": { "type": "string" }, + "error": { "type": "string" } + }, + "required": ["done", "ok", "encrypted", "uid", "error"], + "additionalProperties": false + } + } +} diff --git a/spec/v1/schemas/config.params.schema.json b/spec/v1/schemas/config.params.schema.json new file mode 100644 index 0000000..0fdf12e --- /dev/null +++ b/spec/v1/schemas/config.params.schema.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://spoolid.github.io/schemas/v1/config.params.schema.json", + "title": "ConfigParams", + "type": "object", + "properties": { + "wifiSsid": { "type": "string" }, + "wifiPass": { "type": "string" }, + "apSsid": { "type": "string" }, + "apPass": { "type": "string" }, + "hostname": { "type": "string" }, + "logLevel": { "type": "integer", "minimum": 0, "maximum": 4 }, + "baud": { "type": "integer", "minimum": 1 } + }, + "additionalProperties": false +} diff --git a/spec/v1/schemas/config.reply.schema.json b/spec/v1/schemas/config.reply.schema.json new file mode 100644 index 0000000..3a1d183 --- /dev/null +++ b/spec/v1/schemas/config.reply.schema.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://spoolid.github.io/schemas/v1/config.reply.schema.json", + "title": "ConfigReply", + "type": "object", + "properties": { + "ok": { "const": true }, + "wifiSsid": { "type": "string" }, + "apSsid": { "type": "string" }, + "hostname": { "type": "string" }, + "logLevel": { "type": "integer", "minimum": 0, "maximum": 4 }, + "baud": { "type": "integer", "minimum": 1 } + }, + "required": ["wifiSsid", "apSsid", "hostname", "logLevel", "baud"], + "additionalProperties": false +} diff --git a/spec/v1/schemas/configset.reply.schema.json b/spec/v1/schemas/configset.reply.schema.json new file mode 100644 index 0000000..11ac806 --- /dev/null +++ b/spec/v1/schemas/configset.reply.schema.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://spoolid.github.io/schemas/v1/configset.reply.schema.json", + "title": "ConfigSetReply", + "type": "object", + "properties": { + "ok": { "const": true }, + "reboot": { "const": true } + }, + "required": ["ok", "reboot"], + "additionalProperties": false +} diff --git a/spec/v1/schemas/db.begin.params.schema.json b/spec/v1/schemas/db.begin.params.schema.json new file mode 100644 index 0000000..29cfaea --- /dev/null +++ b/spec/v1/schemas/db.begin.params.schema.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://spoolid.github.io/schemas/v1/db.begin.params.schema.json", + "title": "DbBeginParams", + "type": "object", + "properties": { + "size": { "type": "integer", "minimum": 1 } + }, + "additionalProperties": false +} diff --git a/spec/v1/schemas/db.data.params.schema.json b/spec/v1/schemas/db.data.params.schema.json new file mode 100644 index 0000000..491b45c --- /dev/null +++ b/spec/v1/schemas/db.data.params.schema.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://spoolid.github.io/schemas/v1/db.data.params.schema.json", + "title": "DbDataParams", + "type": "object", + "properties": { + "b": { "type": "string", "contentEncoding": "base64" } + }, + "required": ["b"], + "additionalProperties": false +} diff --git a/spec/v1/schemas/db.data.reply.schema.json b/spec/v1/schemas/db.data.reply.schema.json new file mode 100644 index 0000000..d577372 --- /dev/null +++ b/spec/v1/schemas/db.data.reply.schema.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://spoolid.github.io/schemas/v1/db.data.reply.schema.json", + "title": "DbDataReply", + "type": "object", + "properties": { + "ok": { "const": true } + }, + "required": ["ok"], + "additionalProperties": false +} diff --git a/spec/v1/schemas/dump.reply.schema.json b/spec/v1/schemas/dump.reply.schema.json new file mode 100644 index 0000000..351bc66 --- /dev/null +++ b/spec/v1/schemas/dump.reply.schema.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://spoolid.github.io/schemas/v1/dump.reply.schema.json", + "title": "DumpReply", + "type": "object", + "properties": { + "ok": { "const": true }, + "uid": { "$ref": "common.schema.json#/$defs/Uid" }, + "encrypted": { "type": "boolean" }, + "data": { "type": "string", "pattern": "^[0-9A-Fa-f]*$" } + }, + "required": ["ok", "uid", "encrypted", "data"], + "additionalProperties": false +} diff --git a/spec/v1/schemas/ota.begin.params.schema.json b/spec/v1/schemas/ota.begin.params.schema.json new file mode 100644 index 0000000..ede4dc8 --- /dev/null +++ b/spec/v1/schemas/ota.begin.params.schema.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://spoolid.github.io/schemas/v1/ota.begin.params.schema.json", + "title": "OtaBeginParams", + "type": "object", + "properties": { + "size": { "type": "integer", "minimum": 1 }, + "target": { "$ref": "common.schema.json#/$defs/Target" }, + "md5": { "$ref": "common.schema.json#/$defs/Md5Hex" } + }, + "required": ["size", "target"], + "additionalProperties": false +} diff --git a/spec/v1/schemas/ota.data.params.schema.json b/spec/v1/schemas/ota.data.params.schema.json new file mode 100644 index 0000000..40feba8 --- /dev/null +++ b/spec/v1/schemas/ota.data.params.schema.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://spoolid.github.io/schemas/v1/ota.data.params.schema.json", + "title": "OtaDataParams", + "type": "object", + "properties": { + "b": { "type": "string", "contentEncoding": "base64" } + }, + "required": ["b"], + "additionalProperties": false +} diff --git a/spec/v1/schemas/ota.data.reply.schema.json b/spec/v1/schemas/ota.data.reply.schema.json new file mode 100644 index 0000000..c4f5003 --- /dev/null +++ b/spec/v1/schemas/ota.data.reply.schema.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://spoolid.github.io/schemas/v1/ota.data.reply.schema.json", + "title": "OtaDataReply", + "type": "object", + "properties": { + "ok": { "const": true }, + "written": { "type": "integer", "minimum": 0 } + }, + "required": ["ok", "written"], + "additionalProperties": false +} diff --git a/spec/v1/schemas/ota.end.reply.schema.json b/spec/v1/schemas/ota.end.reply.schema.json new file mode 100644 index 0000000..918b463 --- /dev/null +++ b/spec/v1/schemas/ota.end.reply.schema.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://spoolid.github.io/schemas/v1/ota.end.reply.schema.json", + "title": "OtaEndReply", + "type": "object", + "properties": { + "ok": { "const": true }, + "reboot": { "const": true } + }, + "required": ["ok", "reboot"], + "additionalProperties": false +} diff --git a/spec/v1/schemas/read.reply.schema.json b/spec/v1/schemas/read.reply.schema.json new file mode 100644 index 0000000..875eb0b --- /dev/null +++ b/spec/v1/schemas/read.reply.schema.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://spoolid.github.io/schemas/v1/read.reply.schema.json", + "title": "ReadReply", + "type": "object", + "properties": { + "ok": { "const": true }, + "uid": { "$ref": "common.schema.json#/$defs/Uid" }, + "encrypted": { "type": "boolean" }, + "materialId": { "type": "string", "minLength": 1 }, + "color": { "$ref": "common.schema.json#/$defs/HexColor6" }, + "weight": { "type": "string" } + }, + "required": ["ok", "uid", "encrypted", "materialId", "color", "weight"], + "additionalProperties": false +} diff --git a/spec/v1/schemas/spec.reply.schema.json b/spec/v1/schemas/spec.reply.schema.json new file mode 100644 index 0000000..ff729b4 --- /dev/null +++ b/spec/v1/schemas/spec.reply.schema.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://spoolid.github.io/schemas/v1/spec.reply.schema.json", + "title": "SpecReply", + "type": "object", + "properties": { + "ok": { "const": true }, + "version": { "$ref": "common.schema.json#/$defs/SemVer" }, + "colorSwatches": { "type": "array", "items": { "$ref": "common.schema.json#/$defs/HexColor6" } }, + "weightLabels": { "type": "array", "items": { "type": "string" } }, + "baudRates": { "type": "array", "items": { "type": "integer" } }, + "logLevels": { "type": "array", "items": { "type": "string" } } + }, + "required": ["version", "colorSwatches", "weightLabels", "baudRates", "logLevels"], + "additionalProperties": false +} diff --git a/spec/v1/schemas/status.reply.schema.json b/spec/v1/schemas/status.reply.schema.json new file mode 100644 index 0000000..154f02b --- /dev/null +++ b/spec/v1/schemas/status.reply.schema.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://spoolid.github.io/schemas/v1/status.reply.schema.json", + "title": "StatusReply", + "type": "object", + "properties": { + "ok": { "const": true }, + "pending": { "type": "boolean" }, + "mode": { "enum": ["ap", "sta"] }, + "ip": { "type": "string" }, + "last": { "$ref": "common.schema.json#/$defs/LastWrite" } + }, + "required": ["pending", "mode", "ip", "last"], + "additionalProperties": false +} diff --git a/spec/v1/schemas/write.params.schema.json b/spec/v1/schemas/write.params.schema.json new file mode 100644 index 0000000..93285c3 --- /dev/null +++ b/spec/v1/schemas/write.params.schema.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://spoolid.github.io/schemas/v1/write.params.schema.json", + "title": "WriteParams", + "type": "object", + "properties": { + "materialId": { "type": "string", "minLength": 1 }, + "color": { "type": "string", "pattern": "^#?[0-9A-Fa-f]{6}$" }, + "weight": { "type": "string", "minLength": 1 } + }, + "required": ["materialId", "color", "weight"], + "additionalProperties": false +} diff --git a/spec/v1/schemas/write.reply.schema.json b/spec/v1/schemas/write.reply.schema.json new file mode 100644 index 0000000..68ef607 --- /dev/null +++ b/spec/v1/schemas/write.reply.schema.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://spoolid.github.io/schemas/v1/write.reply.schema.json", + "title": "WriteReply", + "type": "object", + "properties": { + "ok": { "const": true }, + "staged": { "const": true } + }, + "required": ["ok", "staged"], + "additionalProperties": false +} diff --git a/spec/v2/PROTOCOL.md b/spec/v2/PROTOCOL.md new file mode 100644 index 0000000..d30bfe7 --- /dev/null +++ b/spec/v2/PROTOCOL.md @@ -0,0 +1,99 @@ +# SpoolID Wire Protocol — v2 + +**Protocol:** 2 · **Status:** target (becomes device behavior with the protocol-v2 firmware release) + +One logical contract, two transport bindings (HTTP for the device-served web UI, line-JSON +over USB CDC serial for the desktop app). Every payload shape is defined once in +[`schemas/`](schemas/) (JSON Schema 2020-12); this document defines the operations, the +bindings, and the compatibility rules. Fixtures in [`fixtures/`](fixtures/) are golden +replies validated in CI. + +## Invariants + +- **Firmware is the single source of truth.** AES keys, the 48-char spoolData layout, and + weight→length codes live only in firmware and are *not part of this spec*. Clients send + `{materialId, color, weight}`; the firmware builds and encrypts everything. `dump` + returns opaque hex, uninterpreted. +- UI lists (color swatches, weight labels, baud rates, log levels) are **device-owned + data**: the spec fixes only their shape; values come from the `spec` operation at runtime. +- Wire mechanics (envelope, error codes, chunk size, enums) are **spec-owned**: firmware + and every client conform to the values written here. + +## Envelope and errors + +Every reply on every transport carries `ok`: + +- Success: `{"ok": true, ...}` +- Error: `{"ok": false, "error": "", "code": ""}` + +`error` is display text and may change; `code` is the programmatic key. Codes: +`bad_json`, `invalid_params`, `no_tag`, `db_failed`, `ota_failed`, `size_required`, +`bad_chunk`, `unknown_cmd`, `internal`. + +HTTP status codes (400/404/500) are hints only — clients key off the body envelope, +because serial has no status codes. + +## Operations + +| Operation | Params schema | Reply schema | Notes | +|---|---|---|---| +| `spec` | — | [`spec.reply`](schemas/spec.reply.schema.json) | UI lists + `version` + `protocol: 2` | +| `read` | — | [`read.reply`](schemas/read.reply.schema.json) | decrypt tapped tag; includes 6-digit `serial` | +| `write` | [`write.params`](schemas/write.params.schema.json) | [`write.reply`](schemas/write.reply.schema.json) | stages a write; result arrives via `status` | +| `status` | — | [`status.reply`](schemas/status.reply.schema.json) | poll for staged-write outcome (`last.done`) | +| `dump` | — | [`dump.reply`](schemas/dump.reply.schema.json) | raw hex of tag blocks, undecrypted semantics | +| `beep` | — | `Ack` (common) | buzzer test | +| `config.get` | — | [`config.reply`](schemas/config.reply.schema.json) | passwords never returned | +| `config.set` | [`config.params`](schemas/config.params.schema.json) | [`configset.reply`](schemas/configset.reply.schema.json) | all fields optional; reboot applies WiFi | +| `db.put` | begin/data per-transport (below) | `db.data.reply` per chunk | client gzips; device stores to LittleFS | +| `ota.flash` | begin/data per-transport (below) | `ota.end.reply` | MD5-verified, A/B rollback, reboots | + +Params schemas contain no framing (`cmd`, routes). Bulk transfer (`db.put`, `ota.flash`) +is a logical operation whose chunking mechanics are transport bindings. + +## HTTP binding + +| Route | Method | Body | Reply | +|---|---|---|---| +| `/api/spec` | GET | — | `spec.reply` | +| `/api/read` | GET | — | `read.reply` · 404 + error(`no_tag`) | +| `/api/dump` | GET | — | `dump.reply` · 404 + error(`no_tag`) | +| `/api/write` | POST | `write.params` JSON | `write.reply` · 400 + error | +| `/api/status` | GET | — | `status.reply` | +| `/api/beep` | GET | — | `Ack` | +| `/api/config` | GET / POST | — / `config.params` JSON | `config.reply` / `configset.reply` | +| `/api/db` | GET / POST | — / raw gzipped bytes (streamed) | gzip stream / `Ack` | +| `/api/ota` | POST | raw image bytes; query `?target=app\|fs&md5=<32hex>` | `Ack` · 500 + error(`ota_failed`) | + +HTTP has no `otaabort`: aborting is dropping the connection mid-body. + +## Serial binding + +- Framing: one JSON object per line. Requests are the params object plus + `"cmd": ""`. Replies are single-line JSON + `\n`. +- Firmware log lines start with `[` — clients ignore any line not starting with `{`. +- Line endings: CR, LF, or CRLF accepted; input lines are capped at 6144 bytes + (fits a base64 `OTA_CHUNK` + JSON wrapper). +- Commands: `getspec`, `read`, `write`, `status`, `dump`, `beep`, `getconfig`, + `setconfig`, and the bulk sessions below. + +Bulk sessions (chunks are base64 of at most **`OTA_CHUNK` = 4096** raw bytes): + +- `db.put`: `dbbegin {size}` → `dbdata {b}` ×N (each → `db.data.reply {ok, written}`) → `dbend` → `Ack` +- `ota.flash`: `otabegin {size, target, md5?}` → `otadata {b}` ×N (each → `ota.data.reply {ok, written}`) → `otaend` → `ota.end.reply {ok, reboot}` · `otaabort` → `Ack` (serial-only session control) + +## Compatibility + +- The `spec` reply carries `protocol` (integer) and `version` (firmware semver). +- Clients gate: if `protocol` is present, require equality with the client's supported + protocol. If absent (pre-v2 firmware), fall back to comparing `major.minor` of + `version` — see [`../v1/PROTOCOL.md`](../v1/PROTOCOL.md) for what such firmware speaks. +- Change policy: adding an optional reply field = minor release, no protocol bump. + Changing the envelope, removing/renaming a field, or changing semantics = protocol + bump + major release. + +## Design tokens + +Client UIs share the `@spoolid/core` design tokens (`tokens.css`): `--bg`, `--surface`, +`--text`, `--muted`, `--accent`, `--ok`, `--err`, `--border`. Token *names* are part of +this standard; values are themeable (future printer-brand themes swap values only). diff --git a/spec/v2/fixtures/config.reply/get.json b/spec/v2/fixtures/config.reply/get.json new file mode 100644 index 0000000..d220715 --- /dev/null +++ b/spec/v2/fixtures/config.reply/get.json @@ -0,0 +1 @@ +{"ok": true, "wifiSsid": "MyWifi", "apSsid": "SpoolID", "hostname": "spoolid", "logLevel": 3, "baud": 115200} diff --git a/spec/v2/fixtures/configset.reply/ok.json b/spec/v2/fixtures/configset.reply/ok.json new file mode 100644 index 0000000..cfc36f7 --- /dev/null +++ b/spec/v2/fixtures/configset.reply/ok.json @@ -0,0 +1 @@ +{"ok": true, "reboot": true} diff --git a/spec/v2/fixtures/db.data.reply/ok.json b/spec/v2/fixtures/db.data.reply/ok.json new file mode 100644 index 0000000..59f9407 --- /dev/null +++ b/spec/v2/fixtures/db.data.reply/ok.json @@ -0,0 +1 @@ +{"ok": true, "written": 4096} diff --git a/spec/v2/fixtures/dump.reply/ok.json b/spec/v2/fixtures/dump.reply/ok.json new file mode 100644 index 0000000..f97273f --- /dev/null +++ b/spec/v2/fixtures/dump.reply/ok.json @@ -0,0 +1 @@ +{"ok": true, "uid": "04A1B2C3", "encrypted": true, "data": "AB1240276A21010010C12E1F0165000001000000000000000000000000000000"} diff --git a/spec/v2/fixtures/ota.data.reply/ok.json b/spec/v2/fixtures/ota.data.reply/ok.json new file mode 100644 index 0000000..59f9407 --- /dev/null +++ b/spec/v2/fixtures/ota.data.reply/ok.json @@ -0,0 +1 @@ +{"ok": true, "written": 4096} diff --git a/spec/v2/fixtures/ota.end.reply/err-ota.json b/spec/v2/fixtures/ota.end.reply/err-ota.json new file mode 100644 index 0000000..932cd07 --- /dev/null +++ b/spec/v2/fixtures/ota.end.reply/err-ota.json @@ -0,0 +1 @@ +{"ok": false, "error": "md5 mismatch", "code": "ota_failed"} diff --git a/spec/v2/fixtures/ota.end.reply/ok.json b/spec/v2/fixtures/ota.end.reply/ok.json new file mode 100644 index 0000000..cfc36f7 --- /dev/null +++ b/spec/v2/fixtures/ota.end.reply/ok.json @@ -0,0 +1 @@ +{"ok": true, "reboot": true} diff --git a/spec/v2/fixtures/read.reply/err-no-tag.json b/spec/v2/fixtures/read.reply/err-no-tag.json new file mode 100644 index 0000000..dd77d5e --- /dev/null +++ b/spec/v2/fixtures/read.reply/err-no-tag.json @@ -0,0 +1 @@ +{"ok": false, "error": "no tag / auth failed", "code": "no_tag"} diff --git a/spec/v2/fixtures/read.reply/ok.json b/spec/v2/fixtures/read.reply/ok.json new file mode 100644 index 0000000..15795ba --- /dev/null +++ b/spec/v2/fixtures/read.reply/ok.json @@ -0,0 +1 @@ +{"ok": true, "uid": "04A1B2C3", "encrypted": true, "materialId": "01001", "color": "1200F6", "weight": "1KG", "serial": "000001"} diff --git a/spec/v2/fixtures/spec.reply/ok.json b/spec/v2/fixtures/spec.reply/ok.json new file mode 100644 index 0000000..5924fe0 --- /dev/null +++ b/spec/v2/fixtures/spec.reply/ok.json @@ -0,0 +1,9 @@ +{ + "ok": true, + "protocol": 2, + "version": "2.0.0", + "colorSwatches": ["1200F6", "3894E1", "FEFF01", "F8D531", "F38E24", "52D048", "00FEBE", "B700F3", "EE301A", "FA5959", "FFFFFF", "D8D8D8", "4C4C4C", "782543", "000000"], + "weightLabels": ["1KG", "750G", "600G", "500G", "250G"], + "baudRates": [9600, 57600, 115200, 230400, 460800, 921600], + "logLevels": ["0 none", "1 error", "2 warn", "3 info", "4 debug"] +} diff --git a/spec/v2/fixtures/status.reply/idle.json b/spec/v2/fixtures/status.reply/idle.json new file mode 100644 index 0000000..f991860 --- /dev/null +++ b/spec/v2/fixtures/status.reply/idle.json @@ -0,0 +1 @@ +{"ok": true, "pending": false, "mode": "sta", "ip": "192.168.1.50", "last": {"done": false, "ok": false, "encrypted": false, "uid": "", "error": ""}} diff --git a/spec/v2/fixtures/status.reply/write-done.json b/spec/v2/fixtures/status.reply/write-done.json new file mode 100644 index 0000000..e1f0071 --- /dev/null +++ b/spec/v2/fixtures/status.reply/write-done.json @@ -0,0 +1 @@ +{"ok": true, "pending": false, "mode": "ap", "ip": "192.168.4.1", "last": {"done": true, "ok": true, "encrypted": true, "uid": "04A1B2C3", "error": ""}} diff --git a/spec/v2/fixtures/write.reply/err-bad-json.json b/spec/v2/fixtures/write.reply/err-bad-json.json new file mode 100644 index 0000000..17df6de --- /dev/null +++ b/spec/v2/fixtures/write.reply/err-bad-json.json @@ -0,0 +1 @@ +{"ok": false, "error": "bad json", "code": "bad_json"} diff --git a/spec/v2/fixtures/write.reply/err-invalid-params.json b/spec/v2/fixtures/write.reply/err-invalid-params.json new file mode 100644 index 0000000..2a643a7 --- /dev/null +++ b/spec/v2/fixtures/write.reply/err-invalid-params.json @@ -0,0 +1 @@ +{"ok": false, "error": "invalid spool params", "code": "invalid_params"} diff --git a/spec/v2/fixtures/write.reply/staged.json b/spec/v2/fixtures/write.reply/staged.json new file mode 100644 index 0000000..8b64550 --- /dev/null +++ b/spec/v2/fixtures/write.reply/staged.json @@ -0,0 +1 @@ +{"ok": true, "staged": true} diff --git a/spec/v2/schemas/common.schema.json b/spec/v2/schemas/common.schema.json new file mode 100644 index 0000000..d5904e8 --- /dev/null +++ b/spec/v2/schemas/common.schema.json @@ -0,0 +1,44 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://spoolid.github.io/schemas/v2/common.schema.json", + "title": "Common", + "$defs": { + "Uid": { "type": "string", "pattern": "^[0-9A-Fa-f]{8,20}$" }, + "HexColor6": { "type": "string", "pattern": "^[0-9A-Fa-f]{6}$" }, + "SemVer": { "type": "string", "pattern": "^\\d+\\.\\d+\\.\\d+(-[0-9A-Za-z.-]+)?$" }, + "Md5Hex": { "type": "string", "pattern": "^[0-9a-fA-F]{32}$" }, + "Target": { "enum": ["app", "fs"] }, + "ErrorCode": { + "enum": ["bad_json", "invalid_params", "no_tag", "db_failed", "ota_failed", + "size_required", "bad_chunk", "unknown_cmd", "internal"] + }, + "ErrorReply": { + "type": "object", + "properties": { + "ok": { "const": false }, + "error": { "type": "string" }, + "code": { "$ref": "#/$defs/ErrorCode" } + }, + "required": ["ok", "error", "code"], + "additionalProperties": false + }, + "Ack": { + "type": "object", + "properties": { "ok": { "const": true } }, + "required": ["ok"], + "additionalProperties": false + }, + "LastWrite": { + "type": "object", + "properties": { + "done": { "type": "boolean" }, + "ok": { "type": "boolean" }, + "encrypted": { "type": "boolean" }, + "uid": { "type": "string" }, + "error": { "type": "string" } + }, + "required": ["done", "ok", "encrypted", "uid", "error"], + "additionalProperties": false + } + } +} diff --git a/spec/v2/schemas/config.params.schema.json b/spec/v2/schemas/config.params.schema.json new file mode 100644 index 0000000..d824413 --- /dev/null +++ b/spec/v2/schemas/config.params.schema.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://spoolid.github.io/schemas/v2/config.params.schema.json", + "title": "ConfigParams", + "type": "object", + "properties": { + "wifiSsid": { "type": "string" }, + "wifiPass": { "type": "string" }, + "apSsid": { "type": "string" }, + "apPass": { "type": "string" }, + "hostname": { "type": "string" }, + "logLevel": { "type": "integer", "minimum": 0, "maximum": 4 }, + "baud": { "type": "integer", "minimum": 1 } + }, + "additionalProperties": false +} diff --git a/spec/v2/schemas/config.reply.schema.json b/spec/v2/schemas/config.reply.schema.json new file mode 100644 index 0000000..0b14faa --- /dev/null +++ b/spec/v2/schemas/config.reply.schema.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://spoolid.github.io/schemas/v2/config.reply.schema.json", + "title": "ConfigReply", + "type": "object", + "properties": { + "ok": { "const": true }, + "wifiSsid": { "type": "string" }, + "apSsid": { "type": "string" }, + "hostname": { "type": "string" }, + "logLevel": { "type": "integer", "minimum": 0, "maximum": 4 }, + "baud": { "type": "integer", "minimum": 1 } + }, + "required": ["ok", "wifiSsid", "apSsid", "hostname", "logLevel", "baud"], + "additionalProperties": false +} diff --git a/spec/v2/schemas/configset.reply.schema.json b/spec/v2/schemas/configset.reply.schema.json new file mode 100644 index 0000000..14f7e7b --- /dev/null +++ b/spec/v2/schemas/configset.reply.schema.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://spoolid.github.io/schemas/v2/configset.reply.schema.json", + "title": "ConfigSetReply", + "type": "object", + "properties": { + "ok": { "const": true }, + "reboot": { "const": true } + }, + "required": ["ok", "reboot"], + "additionalProperties": false +} diff --git a/spec/v2/schemas/db.begin.params.schema.json b/spec/v2/schemas/db.begin.params.schema.json new file mode 100644 index 0000000..386f619 --- /dev/null +++ b/spec/v2/schemas/db.begin.params.schema.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://spoolid.github.io/schemas/v2/db.begin.params.schema.json", + "title": "DbBeginParams", + "type": "object", + "properties": { + "size": { "type": "integer", "minimum": 1 } + }, + "required": ["size"], + "additionalProperties": false +} diff --git a/spec/v2/schemas/db.data.params.schema.json b/spec/v2/schemas/db.data.params.schema.json new file mode 100644 index 0000000..9e7c325 --- /dev/null +++ b/spec/v2/schemas/db.data.params.schema.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://spoolid.github.io/schemas/v2/db.data.params.schema.json", + "title": "DbDataParams", + "type": "object", + "properties": { + "b": { "type": "string", "contentEncoding": "base64" } + }, + "required": ["b"], + "additionalProperties": false +} diff --git a/spec/v2/schemas/db.data.reply.schema.json b/spec/v2/schemas/db.data.reply.schema.json new file mode 100644 index 0000000..c95aa84 --- /dev/null +++ b/spec/v2/schemas/db.data.reply.schema.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://spoolid.github.io/schemas/v2/db.data.reply.schema.json", + "title": "DbDataReply", + "type": "object", + "properties": { + "ok": { "const": true }, + "written": { "type": "integer", "minimum": 0 } + }, + "required": ["ok", "written"], + "additionalProperties": false +} diff --git a/spec/v2/schemas/dump.reply.schema.json b/spec/v2/schemas/dump.reply.schema.json new file mode 100644 index 0000000..7aa414e --- /dev/null +++ b/spec/v2/schemas/dump.reply.schema.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://spoolid.github.io/schemas/v2/dump.reply.schema.json", + "title": "DumpReply", + "type": "object", + "properties": { + "ok": { "const": true }, + "uid": { "$ref": "common.schema.json#/$defs/Uid" }, + "encrypted": { "type": "boolean" }, + "data": { "type": "string", "pattern": "^[0-9A-Fa-f]*$" } + }, + "required": ["ok", "uid", "encrypted", "data"], + "additionalProperties": false +} diff --git a/spec/v2/schemas/material-database.schema.json b/spec/v2/schemas/material-database.schema.json new file mode 100644 index 0000000..d78721e --- /dev/null +++ b/spec/v2/schemas/material-database.schema.json @@ -0,0 +1,34 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://spoolid.github.io/schemas/v2/material-database.schema.json", + "title": "MaterialDatabase", + "type": "object", + "properties": { + "result": { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "type": "object", + "properties": { + "base": { + "type": "object", + "properties": { + "id": { "type": "string", "minLength": 1 }, + "brand": { "type": "string", "minLength": 1 }, + "name": { "type": "string", "minLength": 1 }, + "meterialType": { "type": "string", "minLength": 1 } + }, + "required": ["id", "brand", "name", "meterialType"] + } + }, + "required": ["base"] + } + } + }, + "required": ["list"] + } + }, + "required": ["result"] +} diff --git a/spec/v2/schemas/ota.begin.params.schema.json b/spec/v2/schemas/ota.begin.params.schema.json new file mode 100644 index 0000000..fc818e5 --- /dev/null +++ b/spec/v2/schemas/ota.begin.params.schema.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://spoolid.github.io/schemas/v2/ota.begin.params.schema.json", + "title": "OtaBeginParams", + "type": "object", + "properties": { + "size": { "type": "integer", "minimum": 1 }, + "target": { "$ref": "common.schema.json#/$defs/Target" }, + "md5": { "$ref": "common.schema.json#/$defs/Md5Hex" } + }, + "required": ["size", "target"], + "additionalProperties": false +} diff --git a/spec/v2/schemas/ota.data.params.schema.json b/spec/v2/schemas/ota.data.params.schema.json new file mode 100644 index 0000000..cffed7e --- /dev/null +++ b/spec/v2/schemas/ota.data.params.schema.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://spoolid.github.io/schemas/v2/ota.data.params.schema.json", + "title": "OtaDataParams", + "type": "object", + "properties": { + "b": { "type": "string", "contentEncoding": "base64" } + }, + "required": ["b"], + "additionalProperties": false +} diff --git a/spec/v2/schemas/ota.data.reply.schema.json b/spec/v2/schemas/ota.data.reply.schema.json new file mode 100644 index 0000000..69b9926 --- /dev/null +++ b/spec/v2/schemas/ota.data.reply.schema.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://spoolid.github.io/schemas/v2/ota.data.reply.schema.json", + "title": "OtaDataReply", + "type": "object", + "properties": { + "ok": { "const": true }, + "written": { "type": "integer", "minimum": 0 } + }, + "required": ["ok", "written"], + "additionalProperties": false +} diff --git a/spec/v2/schemas/ota.end.reply.schema.json b/spec/v2/schemas/ota.end.reply.schema.json new file mode 100644 index 0000000..712d7e7 --- /dev/null +++ b/spec/v2/schemas/ota.end.reply.schema.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://spoolid.github.io/schemas/v2/ota.end.reply.schema.json", + "title": "OtaEndReply", + "type": "object", + "properties": { + "ok": { "const": true }, + "reboot": { "const": true } + }, + "required": ["ok", "reboot"], + "additionalProperties": false +} diff --git a/spec/v2/schemas/read.reply.schema.json b/spec/v2/schemas/read.reply.schema.json new file mode 100644 index 0000000..3b9bcf8 --- /dev/null +++ b/spec/v2/schemas/read.reply.schema.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://spoolid.github.io/schemas/v2/read.reply.schema.json", + "title": "ReadReply", + "type": "object", + "properties": { + "ok": { "const": true }, + "uid": { "$ref": "common.schema.json#/$defs/Uid" }, + "encrypted": { "type": "boolean" }, + "materialId": { "type": "string", "minLength": 1 }, + "color": { "$ref": "common.schema.json#/$defs/HexColor6" }, + "weight": { "type": "string" }, + "serial": { "type": "string", "pattern": "^[0-9]{6}$" } + }, + "required": ["ok", "uid", "encrypted", "materialId", "color", "weight", "serial"], + "additionalProperties": false +} diff --git a/spec/v2/schemas/spec.reply.schema.json b/spec/v2/schemas/spec.reply.schema.json new file mode 100644 index 0000000..61d5db6 --- /dev/null +++ b/spec/v2/schemas/spec.reply.schema.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://spoolid.github.io/schemas/v2/spec.reply.schema.json", + "title": "SpecReply", + "type": "object", + "properties": { + "ok": { "const": true }, + "protocol": { "const": 2 }, + "version": { "$ref": "common.schema.json#/$defs/SemVer" }, + "colorSwatches": { "type": "array", "items": { "$ref": "common.schema.json#/$defs/HexColor6" } }, + "weightLabels": { "type": "array", "items": { "type": "string" } }, + "baudRates": { "type": "array", "items": { "type": "integer" } }, + "logLevels": { "type": "array", "items": { "type": "string" } } + }, + "required": ["ok", "protocol", "version", "colorSwatches", "weightLabels", "baudRates", "logLevels"], + "additionalProperties": false +} diff --git a/spec/v2/schemas/status.reply.schema.json b/spec/v2/schemas/status.reply.schema.json new file mode 100644 index 0000000..66edfec --- /dev/null +++ b/spec/v2/schemas/status.reply.schema.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://spoolid.github.io/schemas/v2/status.reply.schema.json", + "title": "StatusReply", + "type": "object", + "properties": { + "ok": { "const": true }, + "pending": { "type": "boolean" }, + "mode": { "enum": ["ap", "sta"] }, + "ip": { "type": "string" }, + "last": { "$ref": "common.schema.json#/$defs/LastWrite" } + }, + "required": ["ok", "pending", "mode", "ip", "last"], + "additionalProperties": false +} diff --git a/spec/v2/schemas/write.params.schema.json b/spec/v2/schemas/write.params.schema.json new file mode 100644 index 0000000..c41a53e --- /dev/null +++ b/spec/v2/schemas/write.params.schema.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://spoolid.github.io/schemas/v2/write.params.schema.json", + "title": "WriteParams", + "type": "object", + "properties": { + "materialId": { "type": "string", "minLength": 1 }, + "color": { "$ref": "common.schema.json#/$defs/HexColor6" }, + "weight": { "type": "string", "minLength": 1 } + }, + "required": ["materialId", "color", "weight"], + "additionalProperties": false +} diff --git a/spec/v2/schemas/write.reply.schema.json b/spec/v2/schemas/write.reply.schema.json new file mode 100644 index 0000000..3fd72c4 --- /dev/null +++ b/spec/v2/schemas/write.reply.schema.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://spoolid.github.io/schemas/v2/write.reply.schema.json", + "title": "WriteReply", + "type": "object", + "properties": { + "ok": { "const": true }, + "staged": { "const": true } + }, + "required": ["ok", "staged"], + "additionalProperties": false +} From c72bf7fc2665e6297b13327912d25d95e7a3121a Mon Sep 17 00:00:00 2001 From: Mateus Demboski Date: Mon, 6 Jul 2026 19:20:18 -0300 Subject: [PATCH 2/2] test(spec): pin v1 fixtures to replies captured from a live device Replaces the source-derived v1 fixtures with actual 1.x firmware output captured over HTTP (spoolid.local) and USB serial, per transport variant. wifiSsid values are redacted; everything else is verbatim device output. Refs #18 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01SBnenzHAV3VVAF8b8hpwkT --- spec/v1/fixtures/config.reply/get-http.json | 8 +++- spec/v1/fixtures/config.reply/get-serial.json | 9 +++- spec/v1/fixtures/dump.reply/ok.json | 7 ++- spec/v1/fixtures/read.reply/err-no-tag.json | 5 ++- spec/v1/fixtures/read.reply/ok.json | 9 +++- spec/v1/fixtures/spec.reply/ok-http.json | 45 ++++++++++++++++--- spec/v1/fixtures/spec.reply/ok-serial.json | 45 ++++++++++++++++--- spec/v1/fixtures/status.reply/idle-http.json | 13 +++++- .../v1/fixtures/status.reply/idle-serial.json | 14 +++++- 9 files changed, 138 insertions(+), 17 deletions(-) diff --git a/spec/v1/fixtures/config.reply/get-http.json b/spec/v1/fixtures/config.reply/get-http.json index 9a762cd..31829c8 100644 --- a/spec/v1/fixtures/config.reply/get-http.json +++ b/spec/v1/fixtures/config.reply/get-http.json @@ -1 +1,7 @@ -{"wifiSsid": "MyWifi", "apSsid": "SpoolID", "hostname": "spoolid", "logLevel": 3, "baud": 115200} +{ + "wifiSsid": "MyWifi", + "apSsid": "SpoolID", + "hostname": "spoolid", + "logLevel": 3, + "baud": 115200 +} diff --git a/spec/v1/fixtures/config.reply/get-serial.json b/spec/v1/fixtures/config.reply/get-serial.json index d220715..7167155 100644 --- a/spec/v1/fixtures/config.reply/get-serial.json +++ b/spec/v1/fixtures/config.reply/get-serial.json @@ -1 +1,8 @@ -{"ok": true, "wifiSsid": "MyWifi", "apSsid": "SpoolID", "hostname": "spoolid", "logLevel": 3, "baud": 115200} +{ + "ok": true, + "wifiSsid": "MyWifi", + "apSsid": "SpoolID", + "hostname": "spoolid", + "logLevel": 3, + "baud": 115200 +} diff --git a/spec/v1/fixtures/dump.reply/ok.json b/spec/v1/fixtures/dump.reply/ok.json index f97273f..c5db3e1 100644 --- a/spec/v1/fixtures/dump.reply/ok.json +++ b/spec/v1/fixtures/dump.reply/ok.json @@ -1 +1,6 @@ -{"ok": true, "uid": "04A1B2C3", "encrypted": true, "data": "AB1240276A21010010C12E1F0165000001000000000000000000000000000000"} +{ + "ok": true, + "uid": "1018EF5F", + "encrypted": true, + "data": "57F25B78076D4C1797B1BE35CA26954044FEDAB562E696B1564F504B07E1B6BE59F35B4562CFB4214CF72FAD41A6943A" +} diff --git a/spec/v1/fixtures/read.reply/err-no-tag.json b/spec/v1/fixtures/read.reply/err-no-tag.json index ae5ce8f..0d216e8 100644 --- a/spec/v1/fixtures/read.reply/err-no-tag.json +++ b/spec/v1/fixtures/read.reply/err-no-tag.json @@ -1 +1,4 @@ -{"ok": false, "error": "no tag / auth failed"} +{ + "ok": false, + "error": "no tag / auth failed" +} diff --git a/spec/v1/fixtures/read.reply/ok.json b/spec/v1/fixtures/read.reply/ok.json index 709d16d..e5e866f 100644 --- a/spec/v1/fixtures/read.reply/ok.json +++ b/spec/v1/fixtures/read.reply/ok.json @@ -1 +1,8 @@ -{"ok": true, "uid": "04A1B2C3", "encrypted": true, "materialId": "01001", "color": "1200F6", "weight": "1KG"} +{ + "ok": true, + "uid": "1018EF5F", + "encrypted": true, + "materialId": "01001", + "color": "F38E24", + "weight": "1KG" +} diff --git a/spec/v1/fixtures/spec.reply/ok-http.json b/spec/v1/fixtures/spec.reply/ok-http.json index ce2b823..d2c4436 100644 --- a/spec/v1/fixtures/spec.reply/ok-http.json +++ b/spec/v1/fixtures/spec.reply/ok-http.json @@ -1,7 +1,42 @@ { - "version": "1.0.0", - "colorSwatches": ["1200F6", "3894E1", "FEFF01", "F8D531", "F38E24", "52D048", "00FEBE", "B700F3", "EE301A", "FA5959", "FFFFFF", "D8D8D8", "4C4C4C", "782543", "000000"], - "weightLabels": ["1KG", "750G", "600G", "500G", "250G"], - "baudRates": [9600, 57600, 115200, 230400, 460800, 921600], - "logLevels": ["0 none", "1 error", "2 warn", "3 info", "4 debug"] + "version": "0.0.0-dev", + "colorSwatches": [ + "1200F6", + "3894E1", + "FEFF01", + "F8D531", + "F38E24", + "52D048", + "00FEBE", + "B700F3", + "EE301A", + "FA5959", + "FFFFFF", + "D8D8D8", + "4C4C4C", + "782543", + "000000" + ], + "weightLabels": [ + "1KG", + "750G", + "600G", + "500G", + "250G" + ], + "baudRates": [ + 9600, + 57600, + 115200, + 230400, + 460800, + 921600 + ], + "logLevels": [ + "0 none", + "1 error", + "2 warn", + "3 info", + "4 debug" + ] } diff --git a/spec/v1/fixtures/spec.reply/ok-serial.json b/spec/v1/fixtures/spec.reply/ok-serial.json index a0e9883..ec38b3e 100644 --- a/spec/v1/fixtures/spec.reply/ok-serial.json +++ b/spec/v1/fixtures/spec.reply/ok-serial.json @@ -1,8 +1,43 @@ { "ok": true, - "version": "1.0.0", - "colorSwatches": ["1200F6", "3894E1", "FEFF01", "F8D531", "F38E24", "52D048", "00FEBE", "B700F3", "EE301A", "FA5959", "FFFFFF", "D8D8D8", "4C4C4C", "782543", "000000"], - "weightLabels": ["1KG", "750G", "600G", "500G", "250G"], - "baudRates": [9600, 57600, 115200, 230400, 460800, 921600], - "logLevels": ["0 none", "1 error", "2 warn", "3 info", "4 debug"] + "version": "0.0.0-dev", + "colorSwatches": [ + "1200F6", + "3894E1", + "FEFF01", + "F8D531", + "F38E24", + "52D048", + "00FEBE", + "B700F3", + "EE301A", + "FA5959", + "FFFFFF", + "D8D8D8", + "4C4C4C", + "782543", + "000000" + ], + "weightLabels": [ + "1KG", + "750G", + "600G", + "500G", + "250G" + ], + "baudRates": [ + 9600, + 57600, + 115200, + 230400, + 460800, + 921600 + ], + "logLevels": [ + "0 none", + "1 error", + "2 warn", + "3 info", + "4 debug" + ] } diff --git a/spec/v1/fixtures/status.reply/idle-http.json b/spec/v1/fixtures/status.reply/idle-http.json index f9f92d4..e6f02b3 100644 --- a/spec/v1/fixtures/status.reply/idle-http.json +++ b/spec/v1/fixtures/status.reply/idle-http.json @@ -1 +1,12 @@ -{"pending": false, "mode": "sta", "ip": "192.168.1.50", "last": {"done": false, "ok": false, "encrypted": false, "uid": "", "error": ""}} +{ + "pending": false, + "mode": "sta", + "ip": "192.168.0.183", + "last": { + "done": false, + "ok": false, + "encrypted": false, + "uid": "", + "error": "" + } +} diff --git a/spec/v1/fixtures/status.reply/idle-serial.json b/spec/v1/fixtures/status.reply/idle-serial.json index f991860..0b9dcef 100644 --- a/spec/v1/fixtures/status.reply/idle-serial.json +++ b/spec/v1/fixtures/status.reply/idle-serial.json @@ -1 +1,13 @@ -{"ok": true, "pending": false, "mode": "sta", "ip": "192.168.1.50", "last": {"done": false, "ok": false, "encrypted": false, "uid": "", "error": ""}} +{ + "ok": true, + "pending": false, + "mode": "sta", + "ip": "192.168.0.183", + "last": { + "done": false, + "ok": false, + "encrypted": false, + "uid": "", + "error": "" + } +}