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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 35 additions & 6 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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: |
Expand All @@ -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
Expand Down
16 changes: 10 additions & 6 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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: |
Expand Down
6 changes: 3 additions & 3 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand Down
7 changes: 6 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
100 changes: 100 additions & 0 deletions spec/scripts/capture_fixtures.py
Original file line number Diff line number Diff line change
@@ -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())
14 changes: 14 additions & 0 deletions spec/ts/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
63 changes: 63 additions & 0 deletions spec/ts/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading