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..31829c8 --- /dev/null +++ b/spec/v1/fixtures/config.reply/get-http.json @@ -0,0 +1,7 @@ +{ + "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..7167155 --- /dev/null +++ b/spec/v1/fixtures/config.reply/get-serial.json @@ -0,0 +1,8 @@ +{ + "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..c5db3e1 --- /dev/null +++ b/spec/v1/fixtures/dump.reply/ok.json @@ -0,0 +1,6 @@ +{ + "ok": true, + "uid": "1018EF5F", + "encrypted": true, + "data": "57F25B78076D4C1797B1BE35CA26954044FEDAB562E696B1564F504B07E1B6BE59F35B4562CFB4214CF72FAD41A6943A" +} 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..0d216e8 --- /dev/null +++ b/spec/v1/fixtures/read.reply/err-no-tag.json @@ -0,0 +1,4 @@ +{ + "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..e5e866f --- /dev/null +++ b/spec/v1/fixtures/read.reply/ok.json @@ -0,0 +1,8 @@ +{ + "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 new file mode 100644 index 0000000..d2c4436 --- /dev/null +++ b/spec/v1/fixtures/spec.reply/ok-http.json @@ -0,0 +1,42 @@ +{ + "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 new file mode 100644 index 0000000..ec38b3e --- /dev/null +++ b/spec/v1/fixtures/spec.reply/ok-serial.json @@ -0,0 +1,43 @@ +{ + "ok": true, + "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 new file mode 100644 index 0000000..e6f02b3 --- /dev/null +++ b/spec/v1/fixtures/status.reply/idle-http.json @@ -0,0 +1,12 @@ +{ + "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 new file mode 100644 index 0000000..0b9dcef --- /dev/null +++ b/spec/v1/fixtures/status.reply/idle-serial.json @@ -0,0 +1,13 @@ +{ + "ok": true, + "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/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 +}