ticket: site metadata header + scannable Albanian ticket; widen barcode

- site_config gains optional park identity (park_name, operator_name, nius,
  address, phone, email); additive Drizzle migration 0001. GET/PUT
  /api/site-config read/write the full config (PUT partial patch, admin only);
  SiteSettings + SetupWizard expose the fields.
- renderTicket() prints an Albanian header sourced from site_config, the
  all-numeric 13-digit ticket id (12 random + Luhn) as Code128, large digits,
  and a lost-ticket footer. CP852 codepage so ë/ç render.
- Widen the Code128 module width 2->3 and height 80->100 dots so the
  short-range "Simple" QR/barcode reader decodes reliably (was barely reading
  at module width 2 on the 80mm head).

See wiki/concepts/site-metadata.md and ticket-encoding.md.
This commit is contained in:
2026-06-17 12:17:21 +02:00
parent 1efa77bf56
commit 727c62da90
20 changed files with 1596 additions and 155 deletions
+7
View File
@@ -8,6 +8,13 @@
# Generate one with: openssl rand -hex 32
JWT_SECRET=
# Dedicated HMAC key for signing the append-only event ledger (>=16 chars).
# Generate with: openssl rand -hex 32
# If unset, the server falls back to JWT_SECRET (logged as a warning) — fine for
# dev, but set a dedicated key before production. Events store the key that signed
# them (keyId), so verifyChain still validates a chain that spans a key change.
EVENT_SIGNING_KEY=
# Optional ----------------------------------------------------------------
# PORT=3000
# HOST=0.0.0.0 # interface to bind. 127.0.0.1 = loopback only.
+64 -5
View File
@@ -1,5 +1,5 @@
import { randomUUID } from "node:crypto";
import { sessions, type Db, type DeviceRow } from "@parking/db";
import { randomInt } from "node:crypto";
import { eq, sessions, siteConfig, type Db, type DeviceRow } from "@parking/db";
import {
NoPrinterAvailableError,
printWithFailover,
@@ -8,6 +8,7 @@ import {
type PrinterDevice,
type PrinterInstance,
type TicketData,
type TicketHeader,
} from "@parking/devices";
import type { FastifyBaseLogger } from "fastify";
import type { DeviceInputEvent } from "./device-events.js";
@@ -91,7 +92,7 @@ export class EntryFlow {
const printers = this.#loadPrinters();
// 1. PRINT FIRST. The ticket is the transient's session key — no ticket, no entry.
const ticket: TicketData = { ticketId, issuedAt };
const ticket: TicketData = { ticketId, issuedAt, header: this.#ticketHeader() };
try {
const printedBy = await printWithFailover(printers, "entry-dispenser", (d: PrinterDevice) =>
d.printTicket(ticket),
@@ -182,9 +183,67 @@ export class EntryFlow {
}
return out;
}
/** Park identity for the ticket header, from site_config (all fields optional;
* the driver prints only what's set). See wiki/concepts/site-metadata.md. */
#ticketHeader(): TicketHeader | undefined {
const row = this.#db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
if (!row) return undefined;
return {
parkName: row.parkName,
operatorName: row.operatorName,
nius: row.nius,
address: row.address,
phone: row.phone,
};
}
}
/** Opaque, unguessable transient ticket id (wiki/concepts/ticket-encoding.md). */
/**
* Opaque, unguessable transient ticket id (wiki/concepts/ticket-encoding.md).
*
* Format: 13 digits = 12 cryptographically-random digits + 1 trailing Luhn check
* digit. All-numeric so the booth can read it on ANY legacy 1D barcode scanner and
* an operator can hand-key it if every reader is down. RANDOM (not sequential): the
* id must stay unguessable so an attacker can't iterate to claim a cheaper session
* — the anti-fraud property the wiki settles. 12 random digits = 10^12 space, so
* collisions are negligible at lot scale; the unique constraints on
* ledger_events.index / sessions.id are the backstop. The Luhn digit lets a manual
* entry reject a typo (validateTicketCode) instead of failing as "session not found".
*/
function newTicketId(): string {
return `T-${randomUUID()}`;
let body = "";
for (let i = 0; i < 12; i += 1) body += String(randomInt(10));
return body + luhnCheckDigit(body);
}
/** The Luhn (mod-10) check digit for an all-digit string. */
function luhnCheckDigit(digits: string): string {
let sum = 0;
// Walk right-to-left; the check digit sits at position 0 from the right, so the
// last body digit is an "even" position that gets doubled.
let double = true;
for (let i = digits.length - 1; i >= 0; i -= 1) {
let d = digits.charCodeAt(i) - 48;
if (double) {
d *= 2;
if (d > 9) d -= 9;
}
sum += d;
double = !double;
}
return String((10 - (sum % 10)) % 10);
}
/**
* True if `code` is a well-formed ticket code: all digits and a valid Luhn checksum.
* Lets a manual-entry path (operator types the code off the ticket when readers are
* down) reject a typo up front. A scanned/looked-up id that predates this format
* (e.g. legacy `T-<uuid>`) won't pass — callers should only gate MANUAL entry on it,
* never reject an id that already exists in the ledger. See ticket-encoding.md.
*/
export function validateTicketCode(code: string): boolean {
if (!/^\d{13}$/.test(code)) return false;
const body = code.slice(0, 12);
return luhnCheckDigit(body) === code[12];
}
+26 -3
View File
@@ -81,15 +81,24 @@ export function hashEvent(canonical: string): string {
return createHash("sha256").update(canonical, "utf8").digest("hex");
}
/** Resolve a verifier for an event's stored `keyId` (see signer.buildVerifier).
* Returns undefined when the key that signed an event is not available. */
export type SignerResolver = (keyId: string) => Signer | undefined;
export class EventLog {
readonly #db: Db;
readonly #signer: Signer;
/** Picks the verifying signer per event keyId; lets a chain span key rotations
* (JWT-fallback → dedicated key → ATECC608). Defaults to the append signer for
* callers that don't pass one (single-key chains, tests). */
readonly #resolveVerifier: SignerResolver;
/** Serialize appends: each waits for the previous to finish. */
#tail: Promise<unknown> = Promise.resolve();
constructor(db: Db, signer: Signer) {
constructor(db: Db, signer: Signer, resolveVerifier?: SignerResolver) {
this.#db = db;
this.#signer = signer;
this.#resolveVerifier = resolveVerifier ?? (() => signer);
}
/** Append one event to the chain. Returns the persisted row. Serialized. */
@@ -146,7 +155,13 @@ export class EventLog {
* Walk the chain oldest→newest and recompute hashes + signatures. Returns the
* first detected break, or { ok: true }. This is what reconciliation and an
* integrity self-check call. Catches: tampered content, reordering, a deleted
* row (index gap), and a forged/invalid signature.
* row (index gap), a forged/invalid signature, and an event signed under a key
* that is no longer configured.
*
* Each row is verified against the signer for ITS OWN `keyId`, not the current
* append signer — so a chain that spans a key rotation (e.g. early events under
* the JWT_SECRET fallback, later ones under a dedicated EVENT_SIGNING_KEY) still
* verifies end to end. See signer.buildVerifier.
*/
verifyChain(): { ok: true } | { ok: false; index: number; reason: string } {
const rows = this.#db.select().from(ledgerEvents).orderBy(ledgerEvents.index).all();
@@ -159,8 +174,16 @@ export class EventLog {
if ((row.prevHash ?? null) !== prevHash) {
return { ok: false, index: row.index, reason: "prevHash does not match chain" };
}
const verifier = this.#resolveVerifier(row.keyId);
if (!verifier) {
return {
ok: false,
index: row.index,
reason: `no signer for keyId "${row.keyId}" (key not configured)`,
};
}
const canonical = canonicalize(row);
if (!this.#signer.verify(canonical, row.signature)) {
if (!verifier.verify(canonical, row.signature)) {
return { ok: false, index: row.index, reason: "signature invalid (content tampered or wrong key)" };
}
prevHash = hashEvent(canonical);
+172 -82
View File
@@ -51,6 +51,127 @@ function redactSecrets(config: Record<string, unknown>): Record<string, unknown>
return out;
}
/** Result of the device configure pipeline: a ready-to-persist config, or an
* HTTP error to send back. Shared by assign (create) and patch (edit). */
type ConfigureOutcome =
| { config: Record<string, unknown>; warnings: string[] }
| { error: { code: number; message: string } };
/**
* Validate + configure a device, returning the config to persist. Runs the same
* pipeline for both create and edit: validate the driver config, fix
* preconditions, harden (relay password + protocol lockdown), and set up input
* push (Digest creds + push URLs). Each step is a device write (the device
* reboots on apply). The caller owns the DB row; this never touches the DB.
*
* `id` is the assignment id (stable across an edit) — it's baked into the push
* URL, so editing in place keeps the device pushing to the same path.
* `existingConfig` carries forward secrets the client never sees on edit
* (push/relay passwords), so a PATCH that omits them doesn't wipe them.
*/
async function configureDevice(
app: FastifyInstance,
args: {
id: string;
driverId: string;
config: DeviceConfig;
backendIp?: string;
existingConfig?: Record<string, unknown>;
},
): Promise<ConfigureOutcome> {
const { id, driverId, config, backendIp, existingConfig } = args;
// Start from any machine-only secrets already on the row (push/relay passwords
// are redacted out of the client's copy, so an edit would otherwise drop them),
// then layer the submitted config on top.
const fullConfig: Record<string, unknown> = { ...existingConfig, ...config };
// The web password the admin typed is a DESIRED value, not a stored fact:
// it's passed to the driver (via create(config) below) as the rotation
// target, but we do NOT persist it from the form. Only harden()'s VERIFIED
// secrets.webPassword gets saved — otherwise a failed rotation would leave
// the DB claiming a password the device never accepted (login stays old).
delete fullConfig.webPassword;
// webPasswordCurrent is an input-only credential (the OLD password used to
// authorize the change) — never persist it as typed.
delete fullConfig.webPasswordCurrent;
// Residual-risk warnings from device hardening (shown to the admin; the
// save still succeeds — these are "configured, but note X" advisories).
const hardenWarnings: string[] = [];
let device;
try {
device = registry.create(driverId, config); // validates required fields
} catch (err) {
return { error: { code: 400, message: (err as Error).message } };
}
// Configure the device on save (before persisting, so we don't store a row
// for a device we couldn't configure):
// 1. fix preconditions (e.g. disable input_link_relay so a button press
// doesn't auto-fire its relay — host must decide first),
// 2. harden (relay password + disable unused protocol channels), and
// 3. set up input push (Digest creds + push URLs).
// Each step is a device config write (the device reboots on apply).
try {
if (hasPreconditions(device)) {
const fixed = await device.fixPreconditions();
if (!fixed.ok) {
const unfixable = fixed.issues.find((i) => !i.fixable);
return {
error: {
code: 502,
message: `device precondition not satisfied: ${unfixable?.message ?? fixed.issues[0]?.message}`,
},
};
}
}
if (isHardenable(device)) {
const { secrets, warnings } = await device.harden();
Object.assign(fullConfig, secrets); // e.g. relayPassword
// Surface residual-risk warnings (e.g. firmware that won't disable the
// password-less string protocol) so the admin can act (web-UI step).
for (const w of warnings ?? []) {
app.log.warn(`harden(${driverId} ${id}): ${w}`);
hardenWarnings.push(w);
}
}
if (hasPushConfig(device)) {
const host = String(config.host ?? "");
// Admin-provided backend IP wins; else auto-derive (on-subnet NIC).
const pushHost = backendIp ?? backendIpForDevice(host);
if (!pushHost) {
return {
error: {
code: 400,
message: `cannot determine the backend IP on the device's subnet (${host}). Pick one in setup or set BACKEND_HOST_IP.`,
},
};
}
const pushUser = "dingtian";
// 24 hex chars = 96 bits. The Dingtian `pass` field caps at 31 chars
// (longer is silently truncated → auth mismatch), so keep it short.
const pushPassword = randomBytes(12).toString("hex");
await device.configureInputPush({
host: pushHost,
port: backendPort(),
pathBase: `/api/devices/${driverId}/${id}/input`,
auth: { user: pushUser, password: pushPassword },
});
fullConfig.pushUser = pushUser;
fullConfig.pushPassword = pushPassword;
// Record the backend IP the device was told to push to — lets us detect
// a later mismatch if the host's IP changes.
fullConfig.backendIp = pushHost;
}
} catch (err) {
return { error: { code: 502, message: `device configuration failed: ${(err as Error).message}` } };
}
return { config: fullConfig, warnings: hardenWarnings };
}
export async function setupRoutes(app: FastifyInstance, db: Db): Promise<void> {
registerBuiltinDrivers();
setDeviceLogSink((line) => app.log.info(line));
@@ -166,100 +287,69 @@ export async function setupRoutes(app: FastifyInstance, db: Db): Promise<void> {
}
const id = randomUUID();
const fullConfig: Record<string, unknown> = { ...config };
// The web password the admin typed is a DESIRED value, not a stored fact:
// it's passed to the driver (via create(config) below) as the rotation
// target, but we do NOT persist it from the form. Only harden()'s VERIFIED
// secrets.webPassword gets saved — otherwise a failed rotation would leave
// the DB claiming a password the device never accepted (login stays old).
delete fullConfig.webPassword;
// webPasswordCurrent is an input-only credential (the OLD password used to
// authorize the change) — never persist it as typed.
delete fullConfig.webPasswordCurrent;
// Residual-risk warnings from device hardening (shown to the admin; the
// save still succeeds — these are "configured, but note X" advisories).
const hardenWarnings: string[] = [];
let device;
try {
device = registry.create(driverId, config); // validates required fields
} catch (err) {
return reply.code(400).send({ error: (err as Error).message });
}
// Configure the device on save (before persisting, so we don't store a row
// for a device we couldn't configure):
// 1. fix preconditions (e.g. disable input_link_relay so a button press
// doesn't auto-fire its relay — host must decide first),
// 2. harden (relay password + disable unused protocol channels), and
// 3. set up input push (Digest creds + push URLs).
// Each step is a device config write (the device reboots on apply).
try {
if (hasPreconditions(device)) {
const fixed = await device.fixPreconditions();
if (!fixed.ok) {
const unfixable = fixed.issues.find((i) => !i.fixable);
return reply.code(502).send({
error: `device precondition not satisfied: ${unfixable?.message ?? fixed.issues[0]?.message}`,
});
}
}
if (isHardenable(device)) {
const { secrets, warnings } = await device.harden();
Object.assign(fullConfig, secrets); // e.g. relayPassword
// Surface residual-risk warnings (e.g. firmware that won't disable the
// password-less string protocol) so the admin can act (web-UI step).
for (const w of warnings ?? []) {
app.log.warn(`harden(${driverId} ${id}): ${w}`);
hardenWarnings.push(w);
}
}
if (hasPushConfig(device)) {
const host = String(config.host ?? "");
// Admin-provided backend IP wins; else auto-derive (on-subnet NIC).
const pushHost = backendIp ?? backendIpForDevice(host);
if (!pushHost) {
return reply.code(400).send({
error: `cannot determine the backend IP on the device's subnet (${host}). Pick one in setup or set BACKEND_HOST_IP.`,
});
}
const pushUser = "dingtian";
// 24 hex chars = 96 bits. The Dingtian `pass` field caps at 31 chars
// (longer is silently truncated → auth mismatch), so keep it short.
const pushPassword = randomBytes(12).toString("hex");
await device.configureInputPush({
host: pushHost,
port: backendPort(),
pathBase: `/api/devices/${driverId}/${id}/input`,
auth: { user: pushUser, password: pushPassword },
});
fullConfig.pushUser = pushUser;
fullConfig.pushPassword = pushPassword;
// Record the backend IP the device was told to push to — lets us detect
// a later mismatch if the host's IP changes.
fullConfig.backendIp = pushHost;
}
} catch (err) {
return reply
.code(502)
.send({ error: `device configuration failed: ${(err as Error).message}` });
const outcome = await configureDevice(app, { id, driverId, config, backendIp });
if ("error" in outcome) {
return reply.code(outcome.error.code).send({ error: outcome.error.message });
}
const row = {
id,
category,
driverId,
config: fullConfig,
config: outcome.config,
enabled: true,
};
await db.insert(devices).values(row);
// Don't echo device secrets back (push Digest password, web-UI login, …).
return reply.code(201).send({
...row,
config: redactSecrets(fullConfig),
...(hardenWarnings.length ? { warnings: hardenWarnings } : {}),
config: redactSecrets(outcome.config),
...(outcome.warnings.length ? { warnings: outcome.warnings } : {}),
});
},
);
// Edit an assigned device in place. Same configure pipeline as assign, but it
// UPDATEs the existing row and KEEPS the id — which matters for controllers,
// since the id is baked into the device's input-push URL
// (/api/devices/:driverId/:id/input). Delete+re-add would mint a new id and
// break push until reconfigured; PATCH re-runs harden/push against the same id.
// The category and driver are fixed at create time (an edit can't change what
// KIND of device a slot is); only config changes. Admin-only.
app.patch<{ Params: { id: string }; Body: Omit<AssignBody, "category" | "driverId"> }>(
"/api/setup/assign/:id",
{ preHandler: adminGuard },
async (req, reply) => {
const existing = await db
.select()
.from(devices)
.where(eq(devices.id, req.params.id))
.get();
if (!existing) return reply.code(404).send({ error: "no such device assignment" });
const { config, backendIp } = req.body;
const outcome = await configureDevice(app, {
id: existing.id,
driverId: existing.driverId,
config,
backendIp,
// Carry forward machine-only secrets the client never received, so an
// edit that omits them doesn't blank out push/relay passwords.
existingConfig: existing.config,
});
if ("error" in outcome) {
return reply.code(outcome.error.code).send({ error: outcome.error.message });
}
await db.update(devices).set({ config: outcome.config }).where(eq(devices.id, existing.id));
app.log.info(`reconfigured device ${existing.id} (${existing.category}/${existing.driverId})`);
return reply.code(200).send({
id: existing.id,
category: existing.category,
driverId: existing.driverId,
config: redactSecrets(outcome.config),
enabled: existing.enabled,
...(outcome.warnings.length ? { warnings: outcome.warnings } : {}),
});
},
);
+50 -11
View File
@@ -7,9 +7,36 @@ import { getOccupancy } from "../occupancy.js";
// ledger; capacity is an admin-set knob. The FULL gate (refuse transient entry at
// capacity) lives in the entry flow. See wiki/concepts/capacity-occupancy.md.
interface SiteConfigBody {
// Optional park-metadata text fields (all nullable). Trimmed; "" → null.
const TEXT_FIELDS = [
"parkName",
"operatorName",
"nius",
"address",
"phone",
"email",
] as const;
type TextField = (typeof TEXT_FIELDS)[number];
interface SiteConfigBody extends Partial<Record<TextField, string | null>> {
/** Nominal capacity; null = no limit. */
capacity: number | null;
capacity?: number | null;
}
/** Shape returned by GET/PUT: capacity + every metadata field (null when unset). */
type SiteConfig = { capacity: number | null } & Record<TextField, string | null>;
function toSiteConfig(row: typeof siteConfig.$inferSelect | undefined): SiteConfig {
const out = { capacity: row?.capacity ?? null } as SiteConfig;
for (const f of TEXT_FIELDS) out[f] = row?.[f] ?? null;
return out;
}
/** Trim a text field; empty string becomes null so blank input clears it. */
function normText(v: unknown): string | null {
if (v == null) return null;
const s = String(v).trim();
return s === "" ? null : s;
}
export async function siteRoutes(app: FastifyInstance, db: Db): Promise<void> {
@@ -19,25 +46,37 @@ export async function siteRoutes(app: FastifyInstance, db: Db): Promise<void> {
// Live occupancy: cars inside, capacity, free, full. Any signed-in role.
app.get("/api/occupancy", { preHandler: readGuard }, async () => getOccupancy(db));
// Read site config (capacity).
// Read site config (capacity + park metadata).
app.get("/api/site-config", { preHandler: readGuard }, async () => {
const row = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
return { capacity: row?.capacity ?? null };
return toSiteConfig(row);
});
// Set capacity (admin). null or 0+ integer.
// Set site config (admin). Capacity: null or 0+ integer. Metadata: optional text
// (only the fields PRESENT in the body are updated; absent fields are untouched).
app.put<{ Body: SiteConfigBody }>("/api/site-config", { preHandler: writeGuard }, async (req, reply) => {
const { capacity } = req.body ?? ({} as SiteConfigBody);
if (capacity != null && (!Number.isInteger(capacity) || capacity < 0)) {
return reply.code(400).send({ error: "capacity must be a non-negative integer or null" });
const body = req.body ?? ({} as SiteConfigBody);
const patch: Partial<typeof siteConfig.$inferInsert> = {};
if ("capacity" in body) {
const c = body.capacity;
if (c != null && (!Number.isInteger(c) || c < 0)) {
return reply.code(400).send({ error: "capacity must be a non-negative integer or null" });
}
patch.capacity = c ?? null;
}
for (const f of TEXT_FIELDS) {
if (f in body) patch[f] = normText(body[f]);
}
const existing = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
const updatedAt = new Date().toISOString();
if (existing) {
db.update(siteConfig).set({ capacity: capacity ?? null, updatedAt }).where(eq(siteConfig.id, 1)).run();
db.update(siteConfig).set({ ...patch, updatedAt }).where(eq(siteConfig.id, 1)).run();
} else {
db.insert(siteConfig).values({ id: 1, capacity: capacity ?? null, updatedAt }).run();
db.insert(siteConfig).values({ id: 1, ...patch, updatedAt }).run();
}
return { capacity: capacity ?? null };
const row = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
return toSiteConfig(row);
});
}
+2 -2
View File
@@ -13,7 +13,7 @@ import { PermitFlow } from "./permit-flow.js";
import { ShiftService } from "./shift-service.js";
import { ReadDispatcher } from "./read-dispatch.js";
import { PrinterMonitor } from "./printer-monitor.js";
import { buildSigner } from "./signer.js";
import { buildSigner, buildVerifier } from "./signer.js";
import { authRoutes } from "./routes/auth.js";
import { deviceRoutes } from "./routes/devices.js";
import { eventRoutes } from "./routes/events.js";
@@ -86,7 +86,7 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
// in device_events. The entry flow (TODO) turns an input into a signed
// vehicle_entry once a ticket prints + the barrier is commanded.
// See wiki/decisions/event-streams-split.md.
const eventLog = new EventLog(db, buildSigner(app.log));
const eventLog = new EventLog(db, buildSigner(app.log), buildVerifier);
await eventRoutes(app, db, eventLog);
// Entry/exit camera snapshots (BLOB-in-DB), read-only. See snapshot.ts.
+27
View File
@@ -58,3 +58,30 @@ export function buildSigner(log?: { warn: (msg: string) => void }): Signer {
"event signing: no signing key. Set EVENT_SIGNING_KEY (>=16 chars) for the append-only event chain.",
);
}
/**
* Resolve the signer that can VERIFY an existing event, by its stored `keyId`.
* Appends always use the one signer from buildSigner(), but a chain can contain
* events signed under different keys across a rotation (e.g. the JWT_SECRET
* fallback before a dedicated EVENT_SIGNING_KEY was set, or an ATECC608 swap).
* Each event stores its own `keyId`, so verifyChain() must check each row against
* the key that produced it — not the current append-signer. Returns undefined for
* an unknown keyId (the key is gone / not configured), which verifyChain surfaces
* as a distinct failure rather than a false "tampered" alarm.
*
* TODO(atecc608): add an "atecc608-slotN" case returning a public-key verifier.
*/
export function buildVerifier(keyId: string): Signer | undefined {
switch (keyId) {
case "sw-hmac-v2": {
const k = process.env.EVENT_SIGNING_KEY;
return k && k.length >= 16 ? new SoftwareSigner(k, "sw-hmac-v2") : undefined;
}
case "sw-hmac-jwtfallback": {
const k = process.env.JWT_SECRET;
return k && k.length >= 16 ? new SoftwareSigner(k, "sw-hmac-jwtfallback") : undefined;
}
default:
return undefined;
}
}
+86 -19
View File
@@ -1,6 +1,7 @@
import { useState, useEffect, useCallback } from "react";
import {
assignDevice,
editDevice,
discoverDevices,
fetchBackendIps,
fetchCatalog,
@@ -127,8 +128,12 @@ function CategorySection({
onChanged: () => Promise<void> | void;
}) {
const [adding, setAdding] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [warnings, setWarnings] = useState<string[]>([]);
const showForm = adding || assignments.length === 0;
const editing = editingId ? assignments.find((a) => a.id === editingId) : undefined;
// Show the add form for an empty category or an explicit "+ Add", but not while
// editing an existing row (that row renders its own inline form).
const showForm = !editing && (adding || assignments.length === 0);
// Binding categories need a controller to point at first.
const isBound = category !== "access";
@@ -162,15 +167,43 @@ function CategorySection({
{assignments.length > 0 && (
<ul style={{ listStyle: "none", padding: 0, margin: "0 0 0.75rem" }}>
{assignments.map((a) => (
<AssignmentRow key={a.id} assignment={a} controllers={controllers} onChanged={onChanged} />
))}
{assignments.map((a) =>
editingId === a.id ? (
<li key={a.id} style={{ listStyle: "none", padding: 0 }}>
<DeviceForm
category={category}
entries={entries}
discoverableIds={discoverableIds}
pushCapableIds={pushCapableIds}
controllers={controllers}
editing={a}
onSaved={async (w) => {
setWarnings(w);
await onChanged();
setEditingId(null);
}}
onCancel={() => setEditingId(null)}
/>
</li>
) : (
<AssignmentRow
key={a.id}
assignment={a}
controllers={controllers}
onChanged={onChanged}
onEdit={() => {
setAdding(false);
setEditingId(a.id);
}}
/>
),
)}
</ul>
)}
{blockedNoController ? (
<p style={{ color: "#b45309", margin: 0 }}>Add a controller first — a {noun} points at one of its relays.</p>
) : showForm ? (
) : editing ? null : showForm ? (
<DeviceForm
category={category}
entries={entries}
@@ -197,10 +230,12 @@ function AssignmentRow({
assignment,
controllers,
onChanged,
onEdit,
}: {
assignment: Assignment;
controllers: Assignment[];
onChanged: () => Promise<void> | void;
onEdit: () => void;
}) {
const [removing, setRemoving] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -237,6 +272,9 @@ function AssignmentRow({
{!assignment.enabled && <span style={{ color: "#b45309" }}>(disabled)</span>}
<span style={{ flex: 1 }} />
{error && <span style={{ color: "crimson" }}>{error}</span>}
<button type="button" onClick={onEdit} disabled={removing}>
Edit
</button>
<button type="button" onClick={remove} disabled={removing}>
{removing ? "Removing…" : "Remove"}
</button>
@@ -280,6 +318,7 @@ function DeviceForm({
discoverableIds,
pushCapableIds,
controllers,
editing,
onSaved,
onCancel,
}: {
@@ -288,21 +327,42 @@ function DeviceForm({
discoverableIds: string[];
pushCapableIds: string[];
controllers: Assignment[];
/** When set, the form edits this assignment in place (driver locked, config
* pre-filled) instead of adding a new device. */
editing?: Assignment;
onSaved: (warnings: string[]) => Promise<void> | void;
onCancel?: () => void;
}) {
const [selectedId, setSelectedId] = useState<string>("");
// On edit the driver is fixed (you can't change what KIND of device a slot is —
// that's a remove + re-add); pre-select it and lock the picker.
const editCfg = editing?.config as Record<string, unknown> | undefined;
const [selectedId, setSelectedId] = useState<string>(editing?.driverId ?? "");
const selected = entries.find((e) => e.id === selectedId);
const canDiscover = selected != null && discoverableIds.includes(selected.id);
const canDiscover = !editing && selected != null && discoverableIds.includes(selected.id);
const pushesToBackend = selected != null && pushCapableIds.includes(selected.id);
const isController = category === "access";
const [config, setConfig] = useState<Record<string, string | number>>({});
// Pre-fill scalar config fields from the existing assignment when editing.
// (relays/controllerId/relay are model fields handled by their own state below.)
const [config, setConfig] = useState<Record<string, string | number>>(() => {
if (!editCfg) return {};
const out: Record<string, string | number> = {};
for (const [k, v] of Object.entries(editCfg)) {
if (typeof v === "string" || typeof v === "number") out[k] = v;
}
return out;
});
// Controllers: the relay map (which relay = entry/exit/both, + entry button terminal).
const [relays, setRelays] = useState<RelaySpec[]>([{ relay: 1, direction: "both" }]);
const [relays, setRelays] = useState<RelaySpec[]>(() =>
Array.isArray(editCfg?.relays) ? (editCfg!.relays as RelaySpec[]) : [{ relay: 1, direction: "both" }],
);
// Bound devices: which controller + relay this device sits at.
const [controllerId, setControllerId] = useState<string>("");
const [boundRelay, setBoundRelay] = useState<number | "">("");
const [controllerId, setControllerId] = useState<string>(
typeof editCfg?.controllerId === "string" ? editCfg.controllerId : "",
);
const [boundRelay, setBoundRelay] = useState<number | "">(
typeof editCfg?.relay === "number" ? editCfg.relay : "",
);
const [tested, setTested] = useState<TestResult | null>(null);
const [testing, setTesting] = useState(false);
@@ -420,12 +480,17 @@ function DeviceForm({
setSaving(true);
setSaveError(null);
try {
const result = await assignDevice({
category,
driverId: selected.id,
config: mergedConfig(),
...(backendIp ? { backendIp } : {}),
});
const result = editing
? await editDevice(editing.id, {
config: mergedConfig(),
...(backendIp ? { backendIp } : {}),
})
: await assignDevice({
category,
driverId: selected.id,
config: mergedConfig(),
...(backendIp ? { backendIp } : {}),
});
await onSaved(result.warnings ?? []);
} catch (e) {
setSaveError((e as Error).message);
@@ -439,7 +504,9 @@ function DeviceForm({
{entries.length === 0 ? (
<em>No drivers registered.</em>
) : (
<select value={selectedId} onChange={(e) => selectDriver(e.target.value)}>
// Driver is locked when editing — changing the kind of device is a
// remove + re-add, not an in-place edit.
<select value={selectedId} onChange={(e) => selectDriver(e.target.value)} disabled={!!editing}>
<option value="" disabled>
Choose a device…
</option>
@@ -538,7 +605,7 @@ function DeviceForm({
{testing ? "Testing…" : "Test connection"}
</button>
<button type="button" onClick={save} disabled={saving}>
{saving ? "Saving…" : "Save & configure"}
{saving ? "Saving…" : editing ? "Save changes" : "Save & configure"}
</button>
{onCancel && (
<button type="button" onClick={onCancel} disabled={saving}>
+56 -13
View File
@@ -1,14 +1,26 @@
import { useEffect, useState } from "react";
import { fetchOccupancy, fetchSiteConfig, setCapacity, type Occupancy } from "./api.js";
import { fetchOccupancy, fetchSiteConfig, saveSiteConfig, type Occupancy, type SiteConfig } from "./api.js";
// Live occupancy + capacity. Occupancy is shown to everyone (it's a fold over the
// signed ledger); the capacity field is admin-editable. The FULL gate (refuse
// transient entry at capacity) is enforced server-side in the entry flow.
// See wiki/concepts/capacity-occupancy.md.
// Live occupancy + capacity + park metadata. Occupancy is shown to everyone (it's a
// fold over the signed ledger); capacity and the metadata fields are admin-editable.
// The FULL gate (refuse transient entry at capacity) is enforced server-side in the
// entry flow. Metadata (name, NIUS, address, contact) feeds the ticket header.
// See wiki/concepts/capacity-occupancy.md and wiki/concepts/site-metadata.md.
// The optional text fields, in display order, with labels + placeholders.
const META_FIELDS: ReadonlyArray<{ key: keyof SiteConfig; label: string; placeholder?: string; multiline?: boolean }> = [
{ key: "parkName", label: "Park name", placeholder: "e.g. Acme Parking" },
{ key: "operatorName", label: "Operator (legal name)", placeholder: "operating company" },
{ key: "nius", label: "NIUS", placeholder: "e.g. L01234567A" },
{ key: "address", label: "Address", multiline: true },
{ key: "phone", label: "Phone" },
{ key: "email", label: "Email" },
];
export function SiteSettings({ canEdit }: { canEdit: boolean }) {
const [occ, setOcc] = useState<Occupancy | null>(null);
const [capInput, setCapInput] = useState("");
const [meta, setMeta] = useState<Record<string, string>>({});
const [msg, setMsg] = useState<string | null>(null);
function reload() {
@@ -17,18 +29,25 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
useEffect(() => {
reload();
fetchSiteConfig()
.then((c) => setCapInput(c.capacity == null ? "" : String(c.capacity)))
.then((c) => {
setCapInput(c.capacity == null ? "" : String(c.capacity));
const m: Record<string, string> = {};
for (const { key } of META_FIELDS) m[key] = c[key] == null ? "" : String(c[key]);
setMeta(m);
})
.catch(() => {});
}, []);
async function save() {
setMsg(null);
const raw = capInput.trim();
const capacity = raw === "" ? null : Math.round(Number(raw));
const patch: Partial<SiteConfig> = { capacity: raw === "" ? null : Math.round(Number(raw)) };
// Send each metadata field; "" → null is applied server-side.
for (const { key } of META_FIELDS) (patch as Record<string, string | null>)[key] = meta[key] ?? "";
try {
await setCapacity(capacity);
await saveSiteConfig(patch);
reload();
setMsg("Capacity saved.");
setMsg("Saved.");
} catch (e) {
setMsg((e as Error).message);
}
@@ -51,13 +70,37 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
</>
)}
{canEdit && (
<div style={{ marginTop: "0.6rem" }}>
<div style={{ marginTop: "0.6rem", display: "grid", gap: "0.5rem" }}>
<label>
Capacity (blank = no limit):{" "}
<input value={capInput} onChange={(e) => setCapInput(e.target.value)} style={{ width: 80 }} placeholder="e.g. 120" />
</label>{" "}
<button type="button" onClick={save}>Save</button>
{msg && <span style={{ marginLeft: "0.5rem", color: "#555" }}>{msg}</span>}
</label>
<div style={{ borderTop: "1px solid #eee", paddingTop: "0.5rem", color: "#666", fontSize: "0.85rem" }}>
Park details (optional — shown on tickets/receipts)
</div>
{META_FIELDS.map(({ key, label, placeholder, multiline }) => (
<label key={key} style={{ display: "flex", flexDirection: "column", fontSize: "0.85rem" }}>
{label}
{multiline ? (
<textarea
value={meta[key] ?? ""}
onChange={(e) => setMeta((m) => ({ ...m, [key]: e.target.value }))}
rows={2}
placeholder={placeholder}
/>
) : (
<input
value={meta[key] ?? ""}
onChange={(e) => setMeta((m) => ({ ...m, [key]: e.target.value }))}
placeholder={placeholder}
/>
)}
</label>
))}
<div>
<button type="button" onClick={save}>Save</button>
{msg && <span style={{ marginLeft: "0.5rem", color: "#555" }}>{msg}</span>}
</div>
</div>
)}
</section>
+28 -3
View File
@@ -186,6 +186,15 @@ export function assignDevice(body: AssignBody): Promise<AssignResult> {
return apiFetch("/api/setup/assign", { method: "POST", body: JSON.stringify(body) });
}
/** Re-configure an existing device in place, keeping its id (and so its push
* URL). Category/driver are fixed at create time, so only config changes. */
export function editDevice(
id: string,
body: Omit<AssignBody, "category" | "driverId">,
): Promise<AssignResult> {
return apiFetch(`/api/setup/assign/${id}`, { method: "PATCH", body: JSON.stringify(body) });
}
/** A persisted device assignment (one per instance; machine-only secrets stripped). */
export interface Assignment {
id: string;
@@ -333,12 +342,28 @@ export interface Occupancy {
full: boolean;
}
/** Capacity + optional park metadata (all nullable). Mirrors site_config. */
export interface SiteConfig {
capacity: number | null;
parkName: string | null;
operatorName: string | null;
/** NIUS — Albanian tax/identification number. */
nius: string | null;
address: string | null;
phone: string | null;
email: string | null;
}
export function fetchOccupancy(): Promise<Occupancy> {
return apiFetch("/api/occupancy");
}
export function fetchSiteConfig(): Promise<{ capacity: number | null }> {
export function fetchSiteConfig(): Promise<SiteConfig> {
return apiFetch("/api/site-config");
}
export function setCapacity(capacity: number | null): Promise<{ capacity: number | null }> {
return apiFetch("/api/site-config", { method: "PUT", body: JSON.stringify({ capacity }) });
/** PUT a partial config — only the fields supplied are changed. */
export function saveSiteConfig(patch: Partial<SiteConfig>): Promise<SiteConfig> {
return apiFetch("/api/site-config", { method: "PUT", body: JSON.stringify(patch) });
}
export function setCapacity(capacity: number | null): Promise<SiteConfig> {
return saveSiteConfig({ capacity });
}