Skip to content
Open
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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ jobs:
- name: Build
# `wails build` generates bindings, installs frontend deps (pnpm),
# type-checks (tsc) + bundles the frontend, then compiles the Go binary.
run: cd desktop && wails build
run: cd desktop && wails build -clean
- name: Lint frontend (ESLint)
# deps + generated bindings already present from the build above.
run: cd desktop/frontend && pnpm lint
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ jobs:
if [ "$RUNNER_OS" = "Linux" ]; then sudo apt-get install -y upx-ucl; fi
if [ "$RUNNER_OS" = "Windows" ]; then choco install upx -y; fi
- name: Build (version from tag)
run: cd desktop && wails build ${{ matrix.upx }} ${{ matrix.platform }} -ldflags "-X main.version=${{ needs.release-please.outputs.version }}"
run: cd desktop && wails build -clean ${{ matrix.upx }} ${{ matrix.platform }} -ldflags "-X main.version=${{ needs.release-please.outputs.version }}"
- name: Package artifact
shell: bash
run: |
Expand Down
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,12 @@ firmware/.pio/
firmware/data/*.gz
firmware/src/material_db_default.h

# spec tooling (deps + fixtures generated by the firmware native contract test)
# spec tooling: deps, json2ts output, fixtures from the firmware contract test
spec/ts/node_modules/
spec/ts/src/generated/
spec/v2/fixtures/generated/
# root workspace deps
node_modules/

# desktop (Wails: Go backend + Vite/TS frontend) — generated, rebuilt by wails
desktop/build/bin/
Expand Down
8 changes: 8 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ sizes, baud rates, log levels). It builds AND encrypts every payload.

**Web and desktop are thin clients** — they only collect the user's selections and send
them. They hold no baked constants: they fetch the UI lists from the device at startup.
Shared client logic (material-DB parsing, read-prefill, status interpretation, color
normalization, compat gate, design tokens) lives in **`spec/ts` (`@spoolid/core`)**, a
pnpm-workspace package imported by both frontends; the workspace root is the repo root
(`pnpm-workspace.yaml`: web, desktop/frontend, spec/ts — one root lockfile).
- web → HTTP (`web_server.cpp`): `/api/spec`, `/api/db`, `/api/write`, `/api/read`, `/api/status`, `/api/config`, `/api/ota`
- desktop → line-JSON over USB CDC (`serial_proto.cpp`): `getspec`, `write`, `read`, `status`, `dump`, `getconfig`, `setconfig`, `dbbegin`/`dbdata`/`dbend`, `otabegin`/`otadata`/`otaend`

Expand Down Expand Up @@ -93,6 +97,10 @@ copies, not the sources). After editing `material_database.json`, re-run `tools/
run `gen_db_header.py` + `build_web.py` and create `secrets.h`. CI does this in `.github/workflows/ci.yml`.
- **If `material_database.json` is lost**, recover it by gunzipping the byte array in the
generated `firmware/src/material_db_default.h` (content-identical, reformatted compact).
- **`spec/ts/src/generated/` is git-ignored** — TS wire types generated from
`spec/v2/schemas` by `pnpm --dir spec/ts generate` (the web/desktop build scripts run
it automatically). A `spec/ts`-only change doesn't dirty Wails' frontend hash cache —
CI uses `wails build -clean`; locally use `wails build -f` after core changes.
- **Desktop (Wails) generated files are git-ignored** and rebuilt by `wails build`/`wails dev`:
`desktop/frontend/wailsjs/` (Go↔JS bindings), `desktop/frontend/dist/`, `desktop/build/bin/`,
and `desktop/frontend/material_database.json` (copied from the repo root by the `copy-db` pnpm
Expand Down
5 changes: 4 additions & 1 deletion desktop/frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
"scripts": {
"copy-db": "node -e \"require('fs').copyFileSync('../../material_database.json','material_database.json')\"",
"dev": "pnpm copy-db && vite",
"build": "pnpm copy-db && tsc && vite build",
"build": "pnpm --dir ../../spec/ts generate && pnpm copy-db && tsc && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
Expand All @@ -16,5 +16,8 @@
"typescript": "~6.0.3",
"typescript-eslint": "^8.62.1",
"vite": "^8.1.3"
},
"dependencies": {
"@spoolid/core": "workspace:*"
}
}
6 changes: 0 additions & 6 deletions desktop/frontend/pnpm-workspace.yaml

This file was deleted.

48 changes: 8 additions & 40 deletions desktop/frontend/src/db.ts
Original file line number Diff line number Diff line change
@@ -1,43 +1,11 @@
// Material database: parsed in the frontend from the bundled Creality JSON
// (mirrors the web client, which fetches /api/db and parses in the browser).
// The JSON is copied from the repo root into frontend/ by the `copy-db` script.
// Material database: the bundled Creality JSON (copied from the repo root by
// the `copy-db` script) parsed by the shared @spoolid/core logic — same code
// path as the web client, which fetches /api/db instead.
import rawDb from "../material_database.json";
import { parseMaterialDb } from "@spoolid/core";
import type { Material } from "@spoolid/core";

export interface Material {
id: string;
brand: string;
name: string;
type: string;
}
export type { Material };
export { brands, byBrand, nameFor } from "@spoolid/core";

interface DbDoc {
result?: {
list?: Array<{
base?: { id?: string; brand?: string; name?: string; meterialType?: string };
}>;
};
}

// loadDb flattens result.list[].base into Material rows. Note the DB's
// misspelled type key, "meterialType".
export function loadDb(): Material[] {
const doc = rawDb as DbDoc;
const out: Material[] = [];
for (const it of doc.result?.list ?? []) {
const b = it.base;
if (!b?.id) continue;
out.push({ id: b.id, brand: b.brand ?? "", name: b.name ?? "", type: b.meterialType ?? "" });
}
return out;
}

export const brands = (items: Material[]): string[] =>
[...new Set(items.map((m) => m.brand))].sort();

export const byBrand = (items: Material[], brand: string): Material[] =>
items.filter((m) => m.brand === brand);

export const nameFor = (items: Material[], id: string): string => {
const m = items.find((x) => x.id === id);
return m ? `${m.brand} ${m.name}` : "";
};
export const loadDb = (): Material[] => parseMaterialDb(rawDb);
148 changes: 48 additions & 100 deletions desktop/frontend/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,17 @@
// UI lists (swatches, sizes, log levels, baud rates) and current config over
// serial, then drive the same brand -> filament -> size -> color -> Write flow
// as the web UI. Serial calls are bound Go methods (see serial.ts).
import "@spoolid/core/tokens.css";
import "./theme.css";
import {
checkCompat,
cmpVer,
colorName,
interpretWriteStatus,
isFilesystemImage,
readToPrefill,
} from "@spoolid/core";
import type { SpecReply, StatusReply, ReadReply, ConfigReply } from "@spoolid/core";
import {
appVersion,
checkUpdate,
Expand All @@ -15,32 +25,12 @@ import {
send,
uploadDb,
} from "./serial";
import type { PortInfo, Release, Reply } from "./serial";
import { brands, byBrand, loadDb, nameFor } from "./db";
import type { PortInfo, Release } from "./serial";
import { brands, byBrand, loadDb } from "./db";
import type { Material } from "./db";

const BAUD = 115200;

// Friendly names for the Creality swatch palette (presentation only; the hex
// list comes from the device spec). Unknown hex shows as "Custom".
const COLOR_NAMES: Record<string, string> = {
"1200F6": "Blue",
"3894E1": "Light Blue",
"FEFF01": "Yellow",
"F8D531": "Gold",
"F38E24": "Orange",
"52D048": "Green",
"00FEBE": "Teal",
"B700F3": "Purple",
"EE301A": "Red",
"FA5959": "Coral",
"FFFFFF": "White",
"D8D8D8": "Light Gray",
"4C4C4C": "Dark Gray",
"782543": "Maroon",
"000000": "Black",
};

const $ = <T extends HTMLElement = HTMLElement>(id: string): T =>
document.getElementById(id) as T;

Expand Down Expand Up @@ -145,7 +135,7 @@ function show(which: "write" | "config"): void {

// ---- color ----
function colorLabel(hex: string): string {
return `${COLOR_NAMES[hex.toUpperCase()] ?? "Custom"} #${hex}`;
return `${colorName(hex) ?? "Custom"} #${hex}`;
}

function setColor(hex: string): void {
Expand Down Expand Up @@ -199,45 +189,23 @@ function applyCustom(): void {
}

// ---- device spec + config ----
function applySpec(spec: Reply): void {
swatches = (spec.colorSwatches ?? []) as string[];
options(el.size, (spec.weightLabels ?? []) as string[]);
options(el.logLevel, (spec.logLevels ?? []) as string[]);
options(el.baud, ((spec.baudRates ?? []) as number[]).map(String));
function applySpec(spec: SpecReply): void {
swatches = spec.colorSwatches ?? [];
options(el.size, spec.weightLabels ?? []);
options(el.logLevel, spec.logLevels ?? []);
options(el.baud, (spec.baudRates ?? []).map(String));
buildSwatches();
if (swatches.length) selectSwatch(swatches[0]);
checkCompat(String(spec.version ?? ""));
}

// ---- version compatibility gate ----
// Desktop and firmware must share the same major.minor (full match preferred).
// Dev builds (0.0.0-dev / missing) skip the check.
function minorOf(v: string): string | null {
const m = /^(\d+)\.(\d+)\./.exec(v);
return m ? `${m[1]}.${m[2]}` : null;
}

function checkCompat(fwVer: string): void {
const app = minorOf(appVer);
const fw = minorOf(fwVer);
if (!app || !fw) {
el.compat.hidden = true; // a dev build on either side — don't nag
return;
}
if (app === fw) {
el.compat.hidden = true;
return;
}
el.compat.hidden = false;
el.compat.textContent =
`⚠ version mismatch — desktop v${appVer} vs firmware v${fwVer}. ` +
`Update so both share the same minor (Config → Firmware update).`;
// Shared compatibility gate: protocol match on v2+ firmware, major.minor on 1.x.
const compat = checkCompat(appVer, spec);
el.compat.hidden = compat.compatible;
if (!compat.compatible) el.compat.textContent = `⚠ ${compat.message}`;
}

function applyConfig(cfg: Reply): void {
el.wifiSsid.value = cfg.wifiSsid ?? "";
el.hostname.value = cfg.hostname ?? "";
el.apSsid.value = cfg.apSsid ?? "";
function applyConfig(cfg: ConfigReply): void {
el.wifiSsid.value = cfg.wifiSsid;
el.hostname.value = cfg.hostname;
el.apSsid.value = cfg.apSsid;
if (cfg.logLevel != null) el.logLevel.selectedIndex = Number(cfg.logLevel);
if (cfg.baud != null) el.baud.value = String(cfg.baud);
}
Expand Down Expand Up @@ -334,9 +302,11 @@ async function doConnect(): Promise<void> {
return;
}
try {
const spec = await send({ cmd: "getspec" });
const spec = await send<SpecReply>({ cmd: "getspec" });
if (!spec.ok) throw new Error(spec.error);
applySpec(spec);
const cfg = await send({ cmd: "getconfig" });
const cfg = await send<ConfigReply>({ cmd: "getconfig" });
if (!cfg.ok) throw new Error(cfg.error);
applyConfig(cfg);
fillBrands();
} catch (e) {
Expand Down Expand Up @@ -389,17 +359,12 @@ async function doWrite(): Promise<void> {

async function pollStatus(): Promise<void> {
try {
const st = await send({ cmd: "status" });
const last = st.last;
if (!last || !last.done) return;
const st = await send<StatusReply>({ cmd: "status" });
const outcome = interpretWriteStatus(st.ok ? st : {});
if (!outcome.done) return;
if (polling != null) { clearInterval(polling); polling = null; }
el.write.disabled = false;
if (last.ok) {
const enc = last.encrypted ? " (re-encrypted)" : "";
setStatus(el.writeStatus, `written ✓ UID ${last.uid}${enc}`, "ok");
} else {
setStatus(el.writeStatus, `error: ${last.error}`, "err");
}
setStatus(el.writeStatus, outcome.message ?? "", outcome.ok ? "ok" : "err");
} catch (e) {
if (polling != null) { clearInterval(polling); polling = null; }
el.write.disabled = false;
Expand All @@ -411,7 +376,7 @@ async function doRead(): Promise<void> {
el.read.disabled = true;
setStatus(el.writeStatus, "reading — tap a tag on the reader…");
try {
const j = await send({ cmd: "read" });
const j = await send<ReadReply>({ cmd: "read" });
if (!j.ok) {
setStatus(el.writeStatus, `read failed: ${j.error}`, "err");
return;
Expand All @@ -425,30 +390,24 @@ async function doRead(): Promise<void> {
}
}

function applyRead(j: Reply): void {
const materialId = String(j.materialId ?? "");
const m = items.find((x) => x.id === materialId);
if (m) {
el.brand.value = m.brand;
function applyRead(j: ReadReply): void {
const pre = readToPrefill(j, items);
if (pre.material) {
el.brand.value = pre.material.brand;
fillFilaments();
el.filament.value = m.id;
el.filament.value = pre.material.id;
}
if (j.weight) el.size.value = j.weight;

const hex = String(j.color ?? "").toUpperCase();
if (hex) {
if (swatches.some((s) => s.toUpperCase() === hex)) {
selectSwatch(hex);
if (pre.weight) el.size.value = pre.weight;
if (pre.colorHex) {
if (swatches.some((s) => s.toUpperCase() === pre.colorHex)) {
selectSwatch(pre.colorHex);
} else {
selectCustom();
el.custom.value = hex;
el.custom.value = pre.colorHex;
applyCustom();
}
}

const label = m ? `${m.brand} ${m.name}` : nameFor(items, materialId) || `id ${materialId}`;
const locked = j.encrypted ? " · locked" : "";
el.readout.textContent = `read: ${label} · ${j.weight ?? "?"} · #${hex}${locked}`;
el.readout.textContent = pre.label;
}

// ---- config ----
Expand Down Expand Up @@ -575,17 +534,6 @@ async function doCheckUpdate(): Promise<void> {
}
}

// Compare dotted numeric versions (prerelease suffixes ignored). >0 if a > b.
function cmpVer(a: string, b: string): number {
const pa = a.split(/[.\-+]/).map(Number);
const pb = b.split(/[.\-+]/).map(Number);
for (let i = 0; i < 3; i++) {
const d = (pa[i] || 0) - (pb[i] || 0);
if (d) return d;
}
return 0;
}

// ---- boot ----
async function boot(): Promise<void> {
el.rescan.onclick = () => void refreshPorts();
Expand All @@ -606,8 +554,8 @@ async function boot(): Promise<void> {
el.flash.onclick = () => doFlash();
// Auto-pick the image type from the URL (littlefs.bin -> fs, else app).
el.otaUrl.oninput = () => {
const u = el.otaUrl.value.toLowerCase();
if (u) el.otaTarget.value = /littlefs|spiffs|filesystem/.test(u) ? "fs" : "app";
const u = el.otaUrl.value.trim();
if (u) el.otaTarget.value = isFilesystemImage(u) ? "fs" : "app";
};
el.checkUpdate.onclick = () => void doCheckUpdate();
el.flashLatestApp.onclick = () => void runFlash(latest?.firmware ?? "", false);
Expand Down
13 changes: 10 additions & 3 deletions desktop/frontend/src/serial.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,15 @@ import {
} from "../wailsjs/go/main/App";
import { EventsOn } from "../wailsjs/runtime/runtime";

import type { ErrorReply } from "@spoolid/core";

export type Cmd = Record<string, unknown>;
export type Reply = Record<string, any>;
// Every device reply carries the ok envelope (spec/v2/PROTOCOL.md): success
// replies have ok: true, errors are ErrorReply (ok: false + error + code) — a
// discriminated union, so `if (!r.ok)` narrows. Callers pass the concrete
// success type: send<SpecReply>(...).
export interface Reply { ok: true }
export type Result<T> = T | ErrorReply;

export interface Release {
version: string;
Expand All @@ -39,8 +46,8 @@ export const connect = (port: string, baud: number): Promise<void> =>

export const disconnect = (): Promise<void> => Disconnect();

export const send = (cmd: Cmd): Promise<Reply> =>
Send(cmd as { [key: string]: any });
export const send = <T extends Reply = Reply>(cmd: Cmd): Promise<Result<T>> =>
Send(cmd as { [key: string]: any }) as Promise<Result<T>>;

// Desktop app version (for the firmware/desktop compatibility gate).
export const appVersion = (): Promise<string> => Version();
Expand Down
Loading
Loading