Compare commits
10 Commits
3429642edb
...
15d3e1ba08
| Author | SHA1 | Date | |
|---|---|---|---|
| 15d3e1ba08 | |||
| ff3b011fe0 | |||
| 5705098054 | |||
| 68d61f2d99 | |||
| 04135b27cf | |||
| 392d44d842 | |||
| f67c1ead87 | |||
| bf37106c5c | |||
| e579fe5b6e | |||
| 644bfa1462 |
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"hooks": {
|
||||||
|
"PreToolUse": [
|
||||||
|
{
|
||||||
|
"matcher": "Bash",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "CMD=$(python3 -c \"import json,sys; d=json.load(sys.stdin); print(d.get('tool_input',d).get('command',''))\" 2>/dev/null || true); case \"$CMD\" in *grep*|*rg\\ *|*ripgrep*|*find\\ *|*fd\\ *|*ack\\ *|*ag\\ *) [ -f graphify-out/graph.json ] && echo '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"additionalContext\":\"MANDATORY: graphify-out/graph.json exists. You MUST run `graphify query \\\"<question>\\\"` before grepping raw files. Only grep after graphify has oriented you, or to modify/debug specific lines.\"}}' || true ;; esac"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"matcher": "Read|Glob",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "HIT=$(python3 -c \"import json,sys;d=json.load(sys.stdin);t=d.get('tool_input',d);s=(str(t.get('file_path') or '')+' '+str(t.get('pattern') or '')+' '+str(t.get('path') or '')).lower().replace(chr(92),'/');exts=('.py','.js','.ts','.tsx','.jsx','.go','.rs','.java','.rb','.c','.h','.cpp','.hpp','.cc','.cs','.kt','.swift','.php','.scala','.lua','.sh','.md','.rst','.txt','.mdx');sys.stdout.write('1' if 'graphify-out/' not in s and any(e in s for e in exts) else '')\" 2>/dev/null || true); if [ \"$HIT\" = 1 ] && [ -f graphify-out/graph.json ]; then echo '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"additionalContext\":\"MANDATORY: graphify-out/graph.json exists. You MUST run graphify before reading source files. Use: `graphify query \\\"<question>\\\"` (scoped subgraph), `graphify explain \\\"<concept>\\\"`, or `graphify path \\\"<A>\\\" \\\"<B>\\\"`. Only read raw files after graphify has oriented you, or to modify/debug specific lines. This rule applies to subagents too \u2014 include it in every subagent prompt involving code exploration.\"}}'; fi || true"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,3 +20,7 @@ dist/
|
|||||||
/*.png
|
/*.png
|
||||||
# Vendor device SDKs (reference only — protocol captured in wiki, not committed)
|
# Vendor device SDKs (reference only — protocol captured in wiki, not committed)
|
||||||
/dingtian/
|
/dingtian/
|
||||||
|
/QRCode_sdk*/
|
||||||
|
|
||||||
|
# Graphify knowledge-graph output (dev tool; generated, not committed)
|
||||||
|
graphify-out/
|
||||||
|
|||||||
@@ -86,3 +86,13 @@ For the full reasoning behind each, follow the links from `wiki/overview.md`.
|
|||||||
|
|
||||||
- TypeScript throughout. Match the style of surrounding code.
|
- TypeScript throughout. Match the style of surrounding code.
|
||||||
- Confirm before destructive or outward-facing actions. Commit/push only when asked.
|
- Confirm before destructive or outward-facing actions. Commit/push only when asked.
|
||||||
|
|
||||||
|
## graphify
|
||||||
|
|
||||||
|
This project has a knowledge graph at graphify-out/ with god nodes, community structure, and cross-file relationships.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- For codebase questions, first run `graphify query "<question>"` when graphify-out/graph.json exists. Use `graphify path "<A>" "<B>"` for relationships and `graphify explain "<concept>"` for focused concepts. These return a scoped subgraph, usually much smaller than GRAPH_REPORT.md or raw grep output.
|
||||||
|
- If graphify-out/wiki/index.md exists, use it for broad navigation instead of raw source browsing.
|
||||||
|
- Read graphify-out/GRAPH_REPORT.md only for broad architecture review or when query/path/explain do not surface enough context.
|
||||||
|
- After modifying code, run `graphify update .` to keep the graph current (AST-only, no API cost).
|
||||||
|
|||||||
@@ -26,6 +26,20 @@ export interface DeviceReadEvent {
|
|||||||
readonly at: string; // ISO-8601
|
readonly at: string; // ISO-8601
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The decision a read produced. Returned by the read flows so a SYNCHRONOUS reader
|
||||||
|
* (e.g. the QR reader, whose HTTP reply drives its beep + output) can answer the
|
||||||
|
* device. A fire-and-forget reader simply ignores it. See wiki/entities/gee-qr-er80.md.
|
||||||
|
*/
|
||||||
|
export interface ReadOutcome {
|
||||||
|
/** Was the vehicle admitted/exited (barrier opened)? Drives the reader's beep. */
|
||||||
|
readonly accepted: boolean;
|
||||||
|
/** Which way it went, when known (permit/exit infer this). */
|
||||||
|
readonly direction?: "entry" | "exit";
|
||||||
|
/** Human-readable reason (for logs / the reader UI), esp. on reject. */
|
||||||
|
readonly reason?: string;
|
||||||
|
}
|
||||||
|
|
||||||
/** A printer's status as tracked by the live monitor (status + identity). */
|
/** A printer's status as tracked by the live monitor (status + identity). */
|
||||||
export interface PrinterStatusEvent {
|
export interface PrinterStatusEvent {
|
||||||
readonly deviceId: string; // lane_devices id
|
readonly deviceId: string; // lane_devices id
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
} from "@parking/devices";
|
} from "@parking/devices";
|
||||||
import type { FastifyBaseLogger } from "fastify";
|
import type { FastifyBaseLogger } from "fastify";
|
||||||
import type { DeviceInputEvent } from "./device-events.js";
|
import type { DeviceInputEvent } from "./device-events.js";
|
||||||
|
import { getOccupancy } from "./occupancy.js";
|
||||||
import type { EventLog } from "./event-log.js";
|
import type { EventLog } from "./event-log.js";
|
||||||
import type { LaneMap } from "./lane-map.js";
|
import type { LaneMap } from "./lane-map.js";
|
||||||
|
|
||||||
@@ -77,6 +78,22 @@ export class EntryFlow {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async #runEntry(lane: number, input: number, access: AccessControlDevice): Promise<void> {
|
async #runEntry(lane: number, input: number, access: AccessControlDevice): Promise<void> {
|
||||||
|
// CAPACITY GATE (transient only). When the lot is full, refuse transient entry:
|
||||||
|
// no ticket, no vehicle_entry, no open — sign an anomaly. Permit holders are NOT
|
||||||
|
// gated here (their flow ignores site-full; their own maxConcurrent applies), so
|
||||||
|
// subscribers aren't locked out. "Full" is a soft policy seam for valet over-
|
||||||
|
// capacity later. See wiki/concepts/capacity-occupancy.md.
|
||||||
|
const occ = getOccupancy(this.#db);
|
||||||
|
if (occ.full) {
|
||||||
|
await this.#log.append({
|
||||||
|
type: "anomaly",
|
||||||
|
lane,
|
||||||
|
payload: { reason: `transient entry refused — lot full (${occ.count}/${occ.capacity})`, entryRefused: true, full: true },
|
||||||
|
});
|
||||||
|
this.#logger.warn(`transient entry REFUSED on lane ${lane}: full (${occ.count}/${occ.capacity})`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const ticketId = newTicketId();
|
const ticketId = newTicketId();
|
||||||
const issuedAt = new Date().toISOString();
|
const issuedAt = new Date().toISOString();
|
||||||
const printers = await this.#loadPrinters(lane);
|
const printers = await this.#loadPrinters(lane);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { and, eq, laneDevices, ledgerEvents, sessions, type Db } from "@parking/
|
|||||||
import { registry, type AccessControlDevice } from "@parking/devices";
|
import { registry, type AccessControlDevice } from "@parking/devices";
|
||||||
import type { LedgerPayload } from "@parking/shared";
|
import type { LedgerPayload } from "@parking/shared";
|
||||||
import type { FastifyBaseLogger } from "fastify";
|
import type { FastifyBaseLogger } from "fastify";
|
||||||
import type { DeviceReadEvent } from "./device-events.js";
|
import type { DeviceReadEvent, ReadOutcome } from "./device-events.js";
|
||||||
import type { EventLog } from "./event-log.js";
|
import type { EventLog } from "./event-log.js";
|
||||||
|
|
||||||
// The EXIT flow (pay-on-foot model): a credential read at the exit lane → look up
|
// The EXIT flow (pay-on-foot model): a credential read at the exit lane → look up
|
||||||
@@ -46,32 +46,34 @@ export class ExitFlow {
|
|||||||
|
|
||||||
/** Handle a transient-ticket read at a known exit lane (lane pre-resolved by the
|
/** Handle a transient-ticket read at a known exit lane (lane pre-resolved by the
|
||||||
* read dispatcher, which has already ruled out a permit match). */
|
* read dispatcher, which has already ruled out a permit match). */
|
||||||
async handleAt(lane: number, e: DeviceReadEvent): Promise<void> {
|
async handleAt(lane: number, e: DeviceReadEvent): Promise<ReadOutcome> {
|
||||||
const key = `${e.deviceId}:${e.value}`;
|
const key = `${e.deviceId}:${e.value}`;
|
||||||
if (this.#inFlight.has(key)) return;
|
if (this.#inFlight.has(key)) return { accepted: false, reason: "duplicate read in flight" };
|
||||||
this.#inFlight.add(key);
|
this.#inFlight.add(key);
|
||||||
try {
|
try {
|
||||||
await this.#runExit(lane, e);
|
return await this.#runExit(lane, e);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.#logger.error(`exit-flow failed (lane ${lane}): ${(err as Error).message}`);
|
this.#logger.error(`exit-flow failed (lane ${lane}): ${(err as Error).message}`);
|
||||||
|
return { accepted: false, reason: (err as Error).message };
|
||||||
} finally {
|
} finally {
|
||||||
this.#inFlight.delete(key);
|
this.#inFlight.delete(key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async #runExit(lane: number, e: DeviceReadEvent): Promise<void> {
|
async #runExit(lane: number, e: DeviceReadEvent): Promise<ReadOutcome> {
|
||||||
const view = this.#sessionFor(e.value);
|
const view = this.#sessionFor(e.value);
|
||||||
|
|
||||||
// No matching open session — unknown/duplicate ticket. Reject + log.
|
// No matching open session — unknown/duplicate ticket. Reject + log.
|
||||||
if (!view || !view.open) {
|
if (!view || !view.open) {
|
||||||
|
const reason = view ? "exit refused — session already closed" : "exit refused — no open session for credential";
|
||||||
await this.#log.append({
|
await this.#log.append({
|
||||||
type: "anomaly",
|
type: "anomaly",
|
||||||
lane,
|
lane,
|
||||||
identity: e.value,
|
identity: e.value,
|
||||||
payload: { reason: view ? "exit refused — session already closed" : "exit refused — no open session for credential", exitRefused: true },
|
payload: { reason, exitRefused: true },
|
||||||
});
|
});
|
||||||
this.#logger.warn(`exit refused (lane ${lane}): no open session for ${e.value}`);
|
this.#logger.warn(`exit refused (lane ${lane}): no open session for ${e.value}`);
|
||||||
return;
|
return { accepted: false, direction: "exit", reason };
|
||||||
}
|
}
|
||||||
|
|
||||||
// PAID + within walk-back grace?
|
// PAID + within walk-back grace?
|
||||||
@@ -92,7 +94,7 @@ export class ExitFlow {
|
|||||||
payload: { reason, exitRefused: true, sessionRef: e.value },
|
payload: { reason, exitRefused: true, sessionRef: e.value },
|
||||||
});
|
});
|
||||||
this.#logger.warn(`exit refused (lane ${lane}, ${e.value}): ${reason}`);
|
this.#logger.warn(`exit refused (lane ${lane}, ${e.value}): ${reason}`);
|
||||||
return;
|
return { accepted: false, direction: "exit", reason };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Valid: sign the exit BEFORE opening, then open, then update the cache.
|
// Valid: sign the exit BEFORE opening, then open, then update the cache.
|
||||||
@@ -121,6 +123,7 @@ export class ExitFlow {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.#logger.error(`session-cache close failed for ${e.value}: ${(err as Error).message}`);
|
this.#logger.error(`session-cache close failed for ${e.value}: ${(err as Error).message}`);
|
||||||
}
|
}
|
||||||
|
return { accepted: true, direction: "exit" };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Fold the signed ledger into a session view for one identity (authoritative). */
|
/** Fold the signed ledger into a session view for one identity (authoritative). */
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { eq, ledgerEvents, siteConfig, type Db } from "@parking/db";
|
||||||
|
|
||||||
|
// Occupancy = a FOLD over the signed ledger: the count of vehicle_entry events
|
||||||
|
// with no matching vehicle_exit. Never a hand-maintained counter (which is
|
||||||
|
// editable + drifts) — the chain is the truth. See wiki/concepts/capacity-occupancy.md.
|
||||||
|
|
||||||
|
export interface Occupancy {
|
||||||
|
/** Cars currently inside (open sessions). */
|
||||||
|
readonly count: number;
|
||||||
|
/** Admin-set nominal capacity, or null = no limit. */
|
||||||
|
readonly capacity: number | null;
|
||||||
|
/** capacity − count, or null when uncapped. Can read 0 (or below) when full. */
|
||||||
|
readonly free: number | null;
|
||||||
|
/** True when count ≥ capacity (always false when uncapped). */
|
||||||
|
readonly full: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Count cars inside: entries minus exits, per identity, over the ledger. */
|
||||||
|
export function occupancyCount(db: Db): number {
|
||||||
|
const rows = db
|
||||||
|
.select({ type: ledgerEvents.type, identity: ledgerEvents.identity })
|
||||||
|
.from(ledgerEvents)
|
||||||
|
.all();
|
||||||
|
const balance = new Map<string, number>();
|
||||||
|
for (const r of rows) {
|
||||||
|
if (r.type === "vehicle_entry") balance.set(r.identity ?? "", (balance.get(r.identity ?? "") ?? 0) + 1);
|
||||||
|
else if (r.type === "vehicle_exit") balance.set(r.identity ?? "", (balance.get(r.identity ?? "") ?? 0) - 1);
|
||||||
|
}
|
||||||
|
let open = 0;
|
||||||
|
for (const v of balance.values()) if (v > 0) open += 1;
|
||||||
|
return open;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Admin-set capacity (null = uncapped). */
|
||||||
|
export function siteCapacity(db: Db): number | null {
|
||||||
|
const row = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
||||||
|
return row?.capacity ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getOccupancy(db: Db): Occupancy {
|
||||||
|
const count = occupancyCount(db);
|
||||||
|
const capacity = siteCapacity(db);
|
||||||
|
return {
|
||||||
|
count,
|
||||||
|
capacity,
|
||||||
|
free: capacity == null ? null : capacity - count,
|
||||||
|
full: capacity != null && count >= capacity,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { and, eq, laneDevices, ledgerEvents, permitCredentials, permitPlates, permits, sessions, type Db } from "@parking/db";
|
import { and, eq, laneDevices, ledgerEvents, permitCredentials, permitPlates, permits, sessions, type Db } from "@parking/db";
|
||||||
import { registry, type AccessControlDevice } from "@parking/devices";
|
import { registry, type AccessControlDevice } from "@parking/devices";
|
||||||
import type { FastifyBaseLogger } from "fastify";
|
import type { FastifyBaseLogger } from "fastify";
|
||||||
import type { DeviceReadEvent } from "./device-events.js";
|
import type { DeviceReadEvent, ReadOutcome } from "./device-events.js";
|
||||||
import type { EventLog } from "./event-log.js";
|
import type { EventLog } from "./event-log.js";
|
||||||
|
|
||||||
// PERMIT flow: a subscriber identified by card/QR/plate enters/exits without paying.
|
// PERMIT flow: a subscriber identified by card/QR/plate enters/exits without paying.
|
||||||
@@ -58,22 +58,23 @@ export class PermitFlow {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Run the permit entry/exit for a matched read at a lane. */
|
/** Run the permit entry/exit for a matched read at a lane. */
|
||||||
async run(lane: number, e: DeviceReadEvent, m: PermitMatch): Promise<void> {
|
async run(lane: number, e: DeviceReadEvent, m: PermitMatch): Promise<ReadOutcome> {
|
||||||
const key = `${m.permitId}:${m.carKey}`;
|
const key = `${m.permitId}:${m.carKey}`;
|
||||||
if (this.#inFlight.has(key)) return;
|
if (this.#inFlight.has(key)) return { accepted: false, reason: "duplicate read in flight" };
|
||||||
this.#inFlight.add(key);
|
this.#inFlight.add(key);
|
||||||
try {
|
try {
|
||||||
await this.#run(lane, e, m);
|
return await this.#run(lane, e, m);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.#logger.error(`permit-flow failed (lane ${lane}): ${(err as Error).message}`);
|
this.#logger.error(`permit-flow failed (lane ${lane}): ${(err as Error).message}`);
|
||||||
|
return { accepted: false, reason: (err as Error).message };
|
||||||
} finally {
|
} finally {
|
||||||
this.#inFlight.delete(key);
|
this.#inFlight.delete(key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async #run(lane: number, e: DeviceReadEvent, m: PermitMatch): Promise<void> {
|
async #run(lane: number, e: DeviceReadEvent, m: PermitMatch): Promise<ReadOutcome> {
|
||||||
const permit = this.#db.select().from(permits).where(eq(permits.id, m.permitId)).get();
|
const permit = this.#db.select().from(permits).where(eq(permits.id, m.permitId)).get();
|
||||||
if (!permit) return;
|
if (!permit) return { accepted: false, reason: "permit not found" };
|
||||||
|
|
||||||
// Validity: active + within the coverage window.
|
// Validity: active + within the coverage window.
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
@@ -82,8 +83,9 @@ export class PermitFlow {
|
|||||||
(permit.validFrom != null && now < permit.validFrom) ||
|
(permit.validFrom != null && now < permit.validFrom) ||
|
||||||
(permit.validTo != null && now > permit.validTo);
|
(permit.validTo != null && now > permit.validTo);
|
||||||
if (invalid) {
|
if (invalid) {
|
||||||
await this.#reject(lane, m, `permit ${permit.status}/out-of-window`);
|
const reason = `permit ${permit.status}/out-of-window`;
|
||||||
return;
|
await this.#reject(lane, m, reason);
|
||||||
|
return { accepted: false, reason };
|
||||||
}
|
}
|
||||||
|
|
||||||
const carOpen = this.#carHasOpenSession(m.carKey);
|
const carOpen = this.#carHasOpenSession(m.carKey);
|
||||||
@@ -100,15 +102,16 @@ export class PermitFlow {
|
|||||||
});
|
});
|
||||||
await this.#open(lane, m.carKey, "permit exit");
|
await this.#open(lane, m.carKey, "permit exit");
|
||||||
this.#closeCache(m.carKey);
|
this.#closeCache(m.carKey);
|
||||||
return;
|
return { accepted: true, direction: "exit" };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ENTRY: enforce the car-count binding (maxConcurrent), then sign + open.
|
// ENTRY: enforce the car-count binding (maxConcurrent), then sign + open.
|
||||||
if (permit.maxConcurrent != null) {
|
if (permit.maxConcurrent != null) {
|
||||||
const open = this.#permitOpenCount(m.permitId);
|
const open = this.#permitOpenCount(m.permitId);
|
||||||
if (open >= permit.maxConcurrent) {
|
if (open >= permit.maxConcurrent) {
|
||||||
await this.#reject(lane, m, `permit at capacity (${open}/${permit.maxConcurrent} cars in)`);
|
const reason = `permit at capacity (${open}/${permit.maxConcurrent} cars in)`;
|
||||||
return;
|
await this.#reject(lane, m, reason);
|
||||||
|
return { accepted: false, direction: "entry", reason };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,6 +134,7 @@ export class PermitFlow {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.#logger.error(`session-cache insert failed for ${m.carKey}: ${(err as Error).message}`);
|
this.#logger.error(`session-cache insert failed for ${m.carKey}: ${(err as Error).message}`);
|
||||||
}
|
}
|
||||||
|
return { accepted: true, direction: "entry" };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Does this specific car (credential value) have an open session right now? */
|
/** Does this specific car (credential value) have an open session right now? */
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { Db } from "@parking/db";
|
import type { Db } from "@parking/db";
|
||||||
import type { FastifyBaseLogger } from "fastify";
|
import type { FastifyBaseLogger } from "fastify";
|
||||||
import type { DeviceReadEvent } from "./device-events.js";
|
import type { DeviceReadEvent, ReadOutcome } from "./device-events.js";
|
||||||
import type { ExitFlow } from "./exit-flow.js";
|
import type { ExitFlow } from "./exit-flow.js";
|
||||||
import type { PermitFlow } from "./permit-flow.js";
|
import type { PermitFlow } from "./permit-flow.js";
|
||||||
import { readerLaneWithAccess } from "./lane-map.js";
|
import { readerLaneWithAccess } from "./lane-map.js";
|
||||||
@@ -26,16 +26,17 @@ export class ReadDispatcher {
|
|||||||
this.#logger = logger;
|
this.#logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
async dispatch(e: DeviceReadEvent): Promise<void> {
|
async dispatch(e: DeviceReadEvent): Promise<ReadOutcome> {
|
||||||
const lane = await readerLaneWithAccess(this.#db, e.deviceId);
|
const lane = await readerLaneWithAccess(this.#db, e.deviceId);
|
||||||
if (lane == null) return; // reader not on an access-equipped lane — ignore
|
if (lane == null) {
|
||||||
|
return { accepted: false, reason: "reader not on an access-equipped lane" };
|
||||||
|
}
|
||||||
|
|
||||||
const permit = this.#permit.match(e);
|
const permit = this.#permit.match(e);
|
||||||
if (permit) {
|
if (permit) {
|
||||||
await this.#permit.run(lane, e, permit);
|
return this.#permit.run(lane, e, permit);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
// Not a permit → transient ticket exit (the exit flow rejects+logs if unknown).
|
// Not a permit → transient ticket exit (the exit flow rejects+logs if unknown).
|
||||||
await this.#exit.handleAt(lane, e);
|
return this.#exit.handleAt(lane, e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import { eq, laneDevices, type Db } from "@parking/db";
|
||||||
|
import type { DeviceReadEvent } from "../device-events.js";
|
||||||
|
import type { ReadDispatcher } from "../read-dispatch.js";
|
||||||
|
|
||||||
|
// GEE/Dingtian QR reader endpoint. The reader is configured (vendor tool) with our
|
||||||
|
// host as its "server"; on each scan it sends an HTTP GET and BEEPS/acts based on
|
||||||
|
// our JSON reply — host-in-the-loop and synchronous. Protocol from the QRCode SDK
|
||||||
|
// v1.6.5; see wiki/sources/qrcode-sdk.md and wiki/entities/gee-qr-er80.md.
|
||||||
|
//
|
||||||
|
// reader → GET /qa/mcardsea.php?cardid=<QR>&mjihao=<devId>&cjihao=<devSN>&status=<2ch>&time=<utc>
|
||||||
|
// server → {"data":[{cardid,cjihao,mjihao,status,time,output}],"code":0,"message":""}
|
||||||
|
// reply status: 1 = valid (beep 2×) / 0 = invalid (beep 1×)
|
||||||
|
// reply output: 0 = Access, 1 = WG26, 2 = WG34 (line driven on a valid read)
|
||||||
|
// reply time: UTC — syncs the device clock
|
||||||
|
//
|
||||||
|
// The "server language" set on the device only selects this URL path; we accept the
|
||||||
|
// SDK default path. No auth on the device side (it can't); the reader sits on the
|
||||||
|
// device subnet (network-isolation) and the signed ledger is the real guarantee.
|
||||||
|
|
||||||
|
interface ReaderQuery {
|
||||||
|
cardid?: string;
|
||||||
|
mjihao?: string; // device id
|
||||||
|
cjihao?: string; // device serial
|
||||||
|
status?: string; // 2 chars: high valid/invalid, low 1=in/0=out
|
||||||
|
time?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function qrReaderRoutes(
|
||||||
|
app: FastifyInstance,
|
||||||
|
db: Db,
|
||||||
|
dispatcher: ReadDispatcher,
|
||||||
|
): Promise<void> {
|
||||||
|
// Resolve the lane_devices row whose config.serial matches the reader's reported
|
||||||
|
// serial (cjihao). The row id is a normal UUID; the serial is config the admin
|
||||||
|
// enters when assigning the gee-qr-reader. Returns the row id, or null if no
|
||||||
|
// reader is assigned for that serial. (Small device set → scan in JS.)
|
||||||
|
const readerRowIdForSerial = (serial: string): string | null => {
|
||||||
|
if (!serial) return null;
|
||||||
|
const rows = db.select().from(laneDevices).where(eq(laneDevices.category, "reader")).all();
|
||||||
|
const match = rows.find((r) => r.enabled && (r.config as { serial?: string }).serial === serial);
|
||||||
|
return match?.id ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// No auth: the reader is a machine on the isolated device subnet and offers no
|
||||||
|
// auth on its side. Public route, like the Dingtian input push.
|
||||||
|
const handler = async (req: { query: ReaderQuery }, reply: import("fastify").FastifyReply) => {
|
||||||
|
const q = req.query;
|
||||||
|
// The reader sends `Connection: keep-alive` but only ACTS on our verdict (beep,
|
||||||
|
// drive output) once the socket CLOSES — every vendor demo replies
|
||||||
|
// `Connection: close` and shuts the socket. Without it the reader waits out a
|
||||||
|
// ~10 s keep-alive timeout before beeping. So force-close the connection.
|
||||||
|
// See wiki/sources/qrcode-sdk.md, entities/gee-qr-er80.md.
|
||||||
|
reply.header("connection", "close");
|
||||||
|
const cardid = (q.cardid ?? "").trim();
|
||||||
|
const mjihao = q.mjihao != null ? Number(q.mjihao) : 0;
|
||||||
|
const serial = (q.cjihao ?? "").trim();
|
||||||
|
|
||||||
|
// Map the reader's serial → its assigned lane_devices row id (the dispatcher
|
||||||
|
// resolves the lane from that row). If unassigned, deviceId stays the serial so
|
||||||
|
// the dispatcher simply finds no lane and rejects (status:0) — never crashes.
|
||||||
|
const deviceId = readerRowIdForSerial(serial) ?? serial;
|
||||||
|
|
||||||
|
let accepted = false;
|
||||||
|
if (cardid) {
|
||||||
|
const read: DeviceReadEvent = {
|
||||||
|
driverId: "gee-qr-reader",
|
||||||
|
deviceId,
|
||||||
|
value: cardid,
|
||||||
|
kind: "qr",
|
||||||
|
at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const outcome = await dispatcher.dispatch(read);
|
||||||
|
accepted = outcome.accepted;
|
||||||
|
if (!accepted) app.log.info(`QR ${cardid} rejected: ${outcome.reason ?? "?"}`);
|
||||||
|
} catch (err) {
|
||||||
|
app.log.error(`QR dispatch failed for ${cardid}: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reply the SDK verdict. status 1 → beep 2× (valid) / 0 → beep 1× (invalid).
|
||||||
|
// output 0 = Access (drive the reader's access line on a valid read).
|
||||||
|
return {
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
cardid,
|
||||||
|
cjihao: q.cjihao ?? 0,
|
||||||
|
mjihao,
|
||||||
|
status: accepted ? 1 : 0,
|
||||||
|
time: String(Math.floor(Date.now() / 1000)),
|
||||||
|
output: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
code: 0,
|
||||||
|
message: "",
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// The reader's "server language" setting (JSP/PHP/C#/ASP/CGI) selects the URL
|
||||||
|
// EXTENSION it GETs — verified on hardware: a JSP-configured unit posts
|
||||||
|
// /qa/mcardsea.jsp. Register every extension so the endpoint works whatever the
|
||||||
|
// device is set to; accept POST too in case a variant differs.
|
||||||
|
for (const ext of ["php", "jsp", "asp", "aspx", "cgi"]) {
|
||||||
|
const path = `/qa/mcardsea.${ext}`;
|
||||||
|
app.get<{ Querystring: ReaderQuery }>(path, handler);
|
||||||
|
app.post<{ Querystring: ReaderQuery }>(path, handler);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import { requireRole } from "../auth.js";
|
||||||
|
import {
|
||||||
|
NoOpenShiftError,
|
||||||
|
ShiftAlreadyOpenError,
|
||||||
|
type ShiftService,
|
||||||
|
} from "../shift-service.js";
|
||||||
|
|
||||||
|
// Shift endpoints (manned mode). The operator is the logged-in user; a shift is
|
||||||
|
// opened/closed explicitly (not time-based — see wiki/concepts/shift.md and
|
||||||
|
// local-jwt-auth.md "until logout"). End Shift signs a shift_z_report + prints it.
|
||||||
|
|
||||||
|
export async function shiftRoutes(app: FastifyInstance, shift: ShiftService): Promise<void> {
|
||||||
|
// Cashier/operator/admin run shifts; readonly can't.
|
||||||
|
const guard = requireRole("admin", "operator", "cashier");
|
||||||
|
|
||||||
|
// Is the current operator's shift open? (For the UI to show Start vs. End.)
|
||||||
|
app.get("/api/shift/current", { preHandler: guard }, async (req) => {
|
||||||
|
const operator = req.user.username;
|
||||||
|
const open = shift.openShiftFor(operator);
|
||||||
|
return { operator, open: open ? { startedAt: open.occurredAt } : null };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/shift/open", { preHandler: guard }, async (req, reply) => {
|
||||||
|
try {
|
||||||
|
return await shift.open(req.user.username);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ShiftAlreadyOpenError) return reply.code(409).send({ error: err.message });
|
||||||
|
return reply.code(500).send({ error: (err as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/shift/close", { preHandler: guard }, async (req, reply) => {
|
||||||
|
try {
|
||||||
|
return await shift.close(req.user.username);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof NoOpenShiftError) return reply.code(409).send({ error: err.message });
|
||||||
|
return reply.code(500).send({ error: (err as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import { eq, siteConfig, type Db } from "@parking/db";
|
||||||
|
import { requireRole } from "../auth.js";
|
||||||
|
import { getOccupancy } from "../occupancy.js";
|
||||||
|
|
||||||
|
// Site config (capacity) + live occupancy. Occupancy is a fold over the signed
|
||||||
|
// 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 {
|
||||||
|
/** Nominal capacity; null = no limit. */
|
||||||
|
capacity: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function siteRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||||
|
const readGuard = requireRole("admin", "operator", "cashier", "readonly");
|
||||||
|
const writeGuard = requireRole("admin");
|
||||||
|
|
||||||
|
// Live occupancy: cars inside, capacity, free, full. Any signed-in role.
|
||||||
|
app.get("/api/occupancy", { preHandler: readGuard }, async () => getOccupancy(db));
|
||||||
|
|
||||||
|
// Read site config (capacity).
|
||||||
|
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 };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set capacity (admin). null or 0+ integer.
|
||||||
|
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 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();
|
||||||
|
} else {
|
||||||
|
db.insert(siteConfig).values({ id: 1, capacity: capacity ?? null, updatedAt }).run();
|
||||||
|
}
|
||||||
|
return { capacity: capacity ?? null };
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import { EventLog } from "./event-log.js";
|
|||||||
import { ExitFlow } from "./exit-flow.js";
|
import { ExitFlow } from "./exit-flow.js";
|
||||||
import { PayStation } from "./pay-station.js";
|
import { PayStation } from "./pay-station.js";
|
||||||
import { PermitFlow } from "./permit-flow.js";
|
import { PermitFlow } from "./permit-flow.js";
|
||||||
|
import { ShiftService } from "./shift-service.js";
|
||||||
import { ReadDispatcher } from "./read-dispatch.js";
|
import { ReadDispatcher } from "./read-dispatch.js";
|
||||||
import { LaneMap } from "./lane-map.js";
|
import { LaneMap } from "./lane-map.js";
|
||||||
import { PrinterMonitor } from "./printer-monitor.js";
|
import { PrinterMonitor } from "./printer-monitor.js";
|
||||||
@@ -19,6 +20,9 @@ import { deviceRoutes } from "./routes/devices.js";
|
|||||||
import { eventRoutes } from "./routes/events.js";
|
import { eventRoutes } from "./routes/events.js";
|
||||||
import { payRoutes } from "./routes/pay.js";
|
import { payRoutes } from "./routes/pay.js";
|
||||||
import { permitRoutes } from "./routes/permits.js";
|
import { permitRoutes } from "./routes/permits.js";
|
||||||
|
import { qrReaderRoutes } from "./routes/qr-reader.js";
|
||||||
|
import { shiftRoutes } from "./routes/shift.js";
|
||||||
|
import { siteRoutes } from "./routes/site.js";
|
||||||
import { tariffRoutes } from "./routes/tariffs.js";
|
import { tariffRoutes } from "./routes/tariffs.js";
|
||||||
import { printerRoutes } from "./routes/printers.js";
|
import { printerRoutes } from "./routes/printers.js";
|
||||||
import { setupRoutes } from "./routes/setup.js";
|
import { setupRoutes } from "./routes/setup.js";
|
||||||
@@ -110,6 +114,11 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
|||||||
});
|
});
|
||||||
app.addHook("onClose", async () => unsubscribeRead());
|
app.addHook("onClose", async () => unsubscribeRead());
|
||||||
|
|
||||||
|
// GEE/Dingtian QR reader: it HTTP-GETs on each scan and beeps/acts on our JSON
|
||||||
|
// verdict (host-in-the-loop, synchronous). Routes the read through the dispatcher
|
||||||
|
// and replies the SDK verdict. See wiki/entities/gee-qr-er80.md, qrcode-sdk.md.
|
||||||
|
await qrReaderRoutes(app, db, readDispatcher);
|
||||||
|
|
||||||
// Pay station (pay-on-foot): quote an open session against the active tariff +
|
// Pay station (pay-on-foot): quote an open session against the active tariff +
|
||||||
// take payment → signed `payment` event. See wiki/concepts/tariff.md.
|
// take payment → signed `payment` event. See wiki/concepts/tariff.md.
|
||||||
const payStation = new PayStation(db, eventLog, app.log);
|
const payStation = new PayStation(db, eventLog, app.log);
|
||||||
@@ -122,6 +131,15 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
|||||||
// Permit (subscription) admin CRUD. See wiki/entities/permit.md.
|
// Permit (subscription) admin CRUD. See wiki/entities/permit.md.
|
||||||
await permitRoutes(app, db);
|
await permitRoutes(app, db);
|
||||||
|
|
||||||
|
// Shifts (manned mode): explicit open/close → signed shift_open / shift_z_report
|
||||||
|
// (sum payments by tender, print the Z-report). See wiki/concepts/shift.md.
|
||||||
|
const shiftService = new ShiftService(db, eventLog, app.log);
|
||||||
|
await shiftRoutes(app, shiftService);
|
||||||
|
|
||||||
|
// Site config (capacity) + live occupancy. The FULL gate (refuse transient entry
|
||||||
|
// at capacity) is in the entry flow. See wiki/concepts/capacity-occupancy.md.
|
||||||
|
await siteRoutes(app, db);
|
||||||
|
|
||||||
const unsubscribeInput = deviceEvents.onInput((e) => {
|
const unsubscribeInput = deviceEvents.onInput((e) => {
|
||||||
// Resolve which lane the device belongs to. -1 marks "device fired but isn't
|
// Resolve which lane the device belongs to. -1 marks "device fired but isn't
|
||||||
// mapped to a lane" (assigned without a lane, or a stale id) — still recorded
|
// mapped to a lane" (assigned without a lane, or a stale id) — still recorded
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
import { eq, laneDevices, ledgerEvents, type Db } from "@parking/db";
|
||||||
|
import { registry, type PrinterDevice } from "@parking/devices";
|
||||||
|
import type { LedgerPayload } from "@parking/shared";
|
||||||
|
import type { FastifyBaseLogger } from "fastify";
|
||||||
|
import type { EventLog } from "./event-log.js";
|
||||||
|
|
||||||
|
// Shift service (manned mode only). A shift is an operator's accountability period,
|
||||||
|
// delimited by EXPLICIT marks — not a clock. Represented entirely as signed ledger
|
||||||
|
// events (no mutable table): `shift_open` … `shift_z_report`. At close, sum the
|
||||||
|
// `payment` events taken during the shift by tender and print a Z-report.
|
||||||
|
// See wiki/concepts/shift.md.
|
||||||
|
|
||||||
|
export class ShiftAlreadyOpenError extends Error {
|
||||||
|
constructor(operator: string) {
|
||||||
|
super(`operator ${operator} already has an open shift`);
|
||||||
|
this.name = "ShiftAlreadyOpenError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class NoOpenShiftError extends Error {
|
||||||
|
constructor(operator: string) {
|
||||||
|
super(`operator ${operator} has no open shift`);
|
||||||
|
this.name = "NoOpenShiftError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShiftReport {
|
||||||
|
readonly operator: string;
|
||||||
|
readonly startedAt: string;
|
||||||
|
readonly endedAt: string;
|
||||||
|
readonly cashTotalMinor: number;
|
||||||
|
readonly cardTotalMinor: number;
|
||||||
|
readonly currency: string | null;
|
||||||
|
readonly paymentCount: number;
|
||||||
|
readonly printed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ShiftService {
|
||||||
|
readonly #db: Db;
|
||||||
|
readonly #log: EventLog;
|
||||||
|
readonly #logger: FastifyBaseLogger;
|
||||||
|
|
||||||
|
constructor(db: Db, log: EventLog, logger: FastifyBaseLogger) {
|
||||||
|
this.#db = db;
|
||||||
|
this.#log = log;
|
||||||
|
this.#logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Is there an open shift for this operator? Returns the open `shift_open` row or null. */
|
||||||
|
openShiftFor(operator: string) {
|
||||||
|
// Scan shift events for this operator; the shift is open if the most recent
|
||||||
|
// shift event for them is a `shift_open` (not yet closed by a z_report).
|
||||||
|
const rows = this.#db
|
||||||
|
.select()
|
||||||
|
.from(ledgerEvents)
|
||||||
|
.where(eq(ledgerEvents.identity, operator))
|
||||||
|
.orderBy(ledgerEvents.index)
|
||||||
|
.all()
|
||||||
|
.filter((r) => r.type === "shift_open" || r.type === "shift_z_report");
|
||||||
|
const last = rows[rows.length - 1];
|
||||||
|
return last && last.type === "shift_open" ? last : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Open a shift for the operator (explicit start). */
|
||||||
|
async open(operator: string): Promise<{ startedAt: string }> {
|
||||||
|
if (this.openShiftFor(operator)) throw new ShiftAlreadyOpenError(operator);
|
||||||
|
const startedAt = new Date().toISOString();
|
||||||
|
await this.#log.append({
|
||||||
|
type: "shift_open",
|
||||||
|
lane: -1,
|
||||||
|
source: "manual",
|
||||||
|
identity: operator, // the shift's operator; `identity` keys the shift to them
|
||||||
|
payload: { operator },
|
||||||
|
occurredAt: startedAt,
|
||||||
|
});
|
||||||
|
this.#logger.info(`shift opened for ${operator}`);
|
||||||
|
return { startedAt };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Close the operator's open shift: sum payments in the window, sign + print the Z-report. */
|
||||||
|
async close(operator: string): Promise<ShiftReport> {
|
||||||
|
const open = this.openShiftFor(operator);
|
||||||
|
if (!open) throw new NoOpenShiftError(operator);
|
||||||
|
const startedAt = open.occurredAt;
|
||||||
|
const endedAt = new Date().toISOString();
|
||||||
|
|
||||||
|
// All payments taken in [startedAt, endedAt], summed by tender. Payment time =
|
||||||
|
// the operator who handled the money (decision: sum by payment time).
|
||||||
|
const payments = this.#db
|
||||||
|
.select()
|
||||||
|
.from(ledgerEvents)
|
||||||
|
.where(eq(ledgerEvents.type, "payment"))
|
||||||
|
.all()
|
||||||
|
.filter((r) => r.occurredAt >= startedAt && r.occurredAt <= endedAt);
|
||||||
|
|
||||||
|
let cashTotalMinor = 0;
|
||||||
|
let cardTotalMinor = 0;
|
||||||
|
let currency: string | null = null;
|
||||||
|
for (const p of payments) {
|
||||||
|
const pl = (p.payload ?? {}) as LedgerPayload;
|
||||||
|
const amt = typeof pl.amountMinor === "number" ? pl.amountMinor : 0;
|
||||||
|
if (pl.tender === "card") cardTotalMinor += amt;
|
||||||
|
else cashTotalMinor += amt;
|
||||||
|
if (pl.currency) currency = pl.currency;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.#log.append({
|
||||||
|
type: "shift_z_report",
|
||||||
|
lane: -1,
|
||||||
|
source: "manual",
|
||||||
|
identity: operator,
|
||||||
|
payload: {
|
||||||
|
operator,
|
||||||
|
startedAt,
|
||||||
|
endedAt,
|
||||||
|
cashTotalMinor,
|
||||||
|
cardTotalMinor,
|
||||||
|
currency: currency ?? undefined,
|
||||||
|
paymentCount: payments.length,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const printed = await this.#printZReport({
|
||||||
|
operator,
|
||||||
|
startedAt,
|
||||||
|
endedAt,
|
||||||
|
cashTotalMinor,
|
||||||
|
cardTotalMinor,
|
||||||
|
currency,
|
||||||
|
paymentCount: payments.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.#logger.info(
|
||||||
|
`shift closed for ${operator}: cash ${cashTotalMinor} card ${cardTotalMinor} (${payments.length} payments)`,
|
||||||
|
);
|
||||||
|
return { operator, startedAt, endedAt, cashTotalMinor, cardTotalMinor, currency, paymentCount: payments.length, printed };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Print the Z-report on a booth-receipt printer (best-effort; the signed event
|
||||||
|
* is the record — a failed print doesn't undo the close). */
|
||||||
|
async #printZReport(r: Omit<ShiftReport, "printed">): Promise<boolean> {
|
||||||
|
const printer = await this.#boothPrinter();
|
||||||
|
if (!printer) {
|
||||||
|
this.#logger.warn(`no booth-receipt printer — Z-report for ${r.operator} not printed (event is recorded)`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const cur = r.currency ?? "";
|
||||||
|
const money = (m: number) => (m / 100).toFixed(2);
|
||||||
|
const lines = [
|
||||||
|
`Operator: ${r.operator}`,
|
||||||
|
`From: ${r.startedAt}`,
|
||||||
|
`To: ${r.endedAt}`,
|
||||||
|
"",
|
||||||
|
`Payments: ${r.paymentCount}`,
|
||||||
|
`Cash: ${money(r.cashTotalMinor)} ${cur}`,
|
||||||
|
`Card: ${money(r.cardTotalMinor)} ${cur}`,
|
||||||
|
];
|
||||||
|
try {
|
||||||
|
await printer.printReport({ title: "SHIFT Z-REPORT", lines });
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
this.#logger.warn(`Z-report print failed for ${r.operator}: ${(err as Error).message} (event recorded)`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** First enabled booth-receipt printer (any lane), or any enabled printer. */
|
||||||
|
async #boothPrinter(): Promise<PrinterDevice | null> {
|
||||||
|
const rows = await this.#db.select().from(laneDevices).where(eq(laneDevices.category, "printer")).all();
|
||||||
|
const enabled = rows.filter((r) => r.enabled);
|
||||||
|
const booth = enabled.find((r) => (r.config as { role?: string }).role === "booth-receipt") ?? enabled[0];
|
||||||
|
if (!booth) return null;
|
||||||
|
const driver = registry.get(booth.driverId);
|
||||||
|
if (!driver) return null;
|
||||||
|
try {
|
||||||
|
return driver.create(booth.config as never) as PrinterDevice;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,8 @@ import { fetchMe, logout, type SessionUser } from "./api.js";
|
|||||||
import { Login } from "./Login.js";
|
import { Login } from "./Login.js";
|
||||||
import { PermitManager } from "./PermitManager.js";
|
import { PermitManager } from "./PermitManager.js";
|
||||||
import { SetupWizard } from "./SetupWizard.js";
|
import { SetupWizard } from "./SetupWizard.js";
|
||||||
|
import { ShiftControl } from "./ShiftControl.js";
|
||||||
|
import { SiteSettings } from "./SiteSettings.js";
|
||||||
import { TariffComposer } from "./TariffComposer.js";
|
import { TariffComposer } from "./TariffComposer.js";
|
||||||
|
|
||||||
// Operator UI shell. Plain React (no admin framework) — the operator UI is
|
// Operator UI shell. Plain React (no admin framework) — the operator UI is
|
||||||
@@ -40,6 +42,8 @@ export function App() {
|
|||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
</header>
|
</header>
|
||||||
|
<SiteSettings canEdit={user.role === "admin"} />
|
||||||
|
{user.role !== "readonly" && <ShiftControl />}
|
||||||
{user.role === "admin" ? (
|
{user.role === "admin" ? (
|
||||||
<>
|
<>
|
||||||
<SetupWizard />
|
<SetupWizard />
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { closeShift, fetchShift, openShift, type ShiftReport } from "./api.js";
|
||||||
|
|
||||||
|
// Manned-mode shift control. Start/End are explicit (not time-based — see
|
||||||
|
// wiki/concepts/shift.md). End Shift signs + prints a Z-report and shows the
|
||||||
|
// totals. Available to cashier/operator/admin (readonly has no shift).
|
||||||
|
|
||||||
|
const money = (m: number, cur: string | null) => `${(m / 100).toFixed(2)} ${cur ?? ""}`.trim();
|
||||||
|
|
||||||
|
export function ShiftControl() {
|
||||||
|
const [startedAt, setStartedAt] = useState<string | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [report, setReport] = useState<ShiftReport | null>(null);
|
||||||
|
const [err, setErr] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchShift()
|
||||||
|
.then((s) => setStartedAt(s.open?.startedAt ?? null))
|
||||||
|
.catch(() => {
|
||||||
|
/* readonly / not permitted — hide control */
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function start() {
|
||||||
|
setBusy(true);
|
||||||
|
setErr(null);
|
||||||
|
setReport(null);
|
||||||
|
try {
|
||||||
|
const { startedAt } = await openShift();
|
||||||
|
setStartedAt(startedAt);
|
||||||
|
} catch (e) {
|
||||||
|
setErr((e as Error).message);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function end() {
|
||||||
|
setBusy(true);
|
||||||
|
setErr(null);
|
||||||
|
try {
|
||||||
|
const z = await closeShift();
|
||||||
|
setReport(z);
|
||||||
|
setStartedAt(null);
|
||||||
|
} catch (e) {
|
||||||
|
setErr((e as Error).message);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section style={{ marginTop: "1.5rem", padding: "0.75rem 1rem", border: "1px solid #ddd", borderRadius: 6, maxWidth: 460 }}>
|
||||||
|
<strong>Shift:</strong>{" "}
|
||||||
|
{startedAt ? (
|
||||||
|
<>
|
||||||
|
<span style={{ color: "#16a34a" }}>open</span> since {new Date(startedAt).toLocaleString()}{" "}
|
||||||
|
<button type="button" onClick={end} disabled={busy}>
|
||||||
|
{busy ? "Ending…" : "End shift"}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span style={{ color: "#777" }}>not started</span>{" "}
|
||||||
|
<button type="button" onClick={start} disabled={busy}>
|
||||||
|
{busy ? "Starting…" : "Start shift"}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{err && <p style={{ color: "crimson", margin: "0.5rem 0 0" }}>{err}</p>}
|
||||||
|
{report && (
|
||||||
|
<div style={{ marginTop: "0.75rem", fontFamily: "ui-monospace, monospace", fontSize: "0.9em" }}>
|
||||||
|
<div style={{ fontWeight: 600 }}>Z-REPORT — {report.operator}</div>
|
||||||
|
<div>Payments: {report.paymentCount}</div>
|
||||||
|
<div>Cash: {money(report.cashTotalMinor, report.currency)}</div>
|
||||||
|
<div>Card: {money(report.cardTotalMinor, report.currency)}</div>
|
||||||
|
<div style={{ color: report.printed ? "#16a34a" : "#b45309" }}>
|
||||||
|
{report.printed ? "Printed to booth receipt." : "Recorded (no printer to print to)."}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { fetchOccupancy, fetchSiteConfig, setCapacity, type Occupancy } 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.
|
||||||
|
|
||||||
|
export function SiteSettings({ canEdit }: { canEdit: boolean }) {
|
||||||
|
const [occ, setOcc] = useState<Occupancy | null>(null);
|
||||||
|
const [capInput, setCapInput] = useState("");
|
||||||
|
const [msg, setMsg] = useState<string | null>(null);
|
||||||
|
|
||||||
|
function reload() {
|
||||||
|
fetchOccupancy().then(setOcc).catch(() => {});
|
||||||
|
}
|
||||||
|
useEffect(() => {
|
||||||
|
reload();
|
||||||
|
fetchSiteConfig()
|
||||||
|
.then((c) => setCapInput(c.capacity == null ? "" : String(c.capacity)))
|
||||||
|
.catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
setMsg(null);
|
||||||
|
const raw = capInput.trim();
|
||||||
|
const capacity = raw === "" ? null : Math.round(Number(raw));
|
||||||
|
try {
|
||||||
|
await setCapacity(capacity);
|
||||||
|
reload();
|
||||||
|
setMsg("Capacity saved.");
|
||||||
|
} catch (e) {
|
||||||
|
setMsg((e as Error).message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section style={{ marginTop: "1.5rem", padding: "0.75rem 1rem", border: "1px solid #ddd", borderRadius: 6, maxWidth: 460 }}>
|
||||||
|
<strong>Occupancy:</strong>{" "}
|
||||||
|
{occ == null ? (
|
||||||
|
"…"
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span style={{ fontWeight: 600 }}>{occ.count}</span>
|
||||||
|
{occ.capacity != null ? ` / ${occ.capacity}` : " (no capacity set)"}
|
||||||
|
{occ.capacity != null && (
|
||||||
|
<span style={{ color: "#666" }}> · {occ.free} free</span>
|
||||||
|
)}
|
||||||
|
{occ.full && <span style={{ color: "crimson", marginLeft: "0.5rem", fontWeight: 600 }}>FULL</span>}{" "}
|
||||||
|
<button type="button" onClick={reload} style={{ marginLeft: "0.5rem" }}>↻</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{canEdit && (
|
||||||
|
<div style={{ marginTop: "0.6rem" }}>
|
||||||
|
<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>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -277,3 +277,49 @@ export function revokePermit(id: string): Promise<Permit> {
|
|||||||
export function deletePermit(id: string): Promise<void> {
|
export function deletePermit(id: string): Promise<void> {
|
||||||
return apiFetch(`/api/permits/${id}`, { method: "DELETE" });
|
return apiFetch(`/api/permits/${id}`, { method: "DELETE" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Shifts ---------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface ShiftStatus {
|
||||||
|
operator: string;
|
||||||
|
open: { startedAt: string } | null;
|
||||||
|
}
|
||||||
|
export interface ShiftReport {
|
||||||
|
operator: string;
|
||||||
|
startedAt: string;
|
||||||
|
endedAt: string;
|
||||||
|
cashTotalMinor: number;
|
||||||
|
cardTotalMinor: number;
|
||||||
|
currency: string | null;
|
||||||
|
paymentCount: number;
|
||||||
|
printed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchShift(): Promise<ShiftStatus> {
|
||||||
|
return apiFetch("/api/shift/current");
|
||||||
|
}
|
||||||
|
export function openShift(): Promise<{ startedAt: string }> {
|
||||||
|
return apiFetch("/api/shift/open", { method: "POST" });
|
||||||
|
}
|
||||||
|
export function closeShift(): Promise<ShiftReport> {
|
||||||
|
return apiFetch("/api/shift/close", { method: "POST" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Site config / occupancy ----------------------------------------------
|
||||||
|
|
||||||
|
export interface Occupancy {
|
||||||
|
count: number;
|
||||||
|
capacity: number | null;
|
||||||
|
free: number | null;
|
||||||
|
full: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchOccupancy(): Promise<Occupancy> {
|
||||||
|
return apiFetch("/api/occupancy");
|
||||||
|
}
|
||||||
|
export function fetchSiteConfig(): Promise<{ capacity: number | null }> {
|
||||||
|
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 }) });
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
CREATE TABLE `site_config` (
|
||||||
|
`id` integer PRIMARY KEY NOT NULL,
|
||||||
|
`capacity` integer,
|
||||||
|
`updated_at` text DEFAULT (current_timestamp) NOT NULL
|
||||||
|
);
|
||||||
@@ -0,0 +1,724 @@
|
|||||||
|
{
|
||||||
|
"version": "6",
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"id": "538cd745-2fe9-410b-bb9f-e4d3c2086211",
|
||||||
|
"prevId": "cd09c11f-4306-4ac8-a335-7c050d080ab6",
|
||||||
|
"tables": {
|
||||||
|
"blocklist": {
|
||||||
|
"name": "blocklist",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"kind": {
|
||||||
|
"name": "kind",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"value": {
|
||||||
|
"name": "value",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"reason": {
|
||||||
|
"name": "reason",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"active": {
|
||||||
|
"name": "active",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"added_by": {
|
||||||
|
"name": "added_by",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"added_at": {
|
||||||
|
"name": "added_at",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(current_timestamp)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"device_events": {
|
||||||
|
"name": "device_events",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"device_id": {
|
||||||
|
"name": "device_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"lane": {
|
||||||
|
"name": "lane",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"category": {
|
||||||
|
"name": "category",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"kind": {
|
||||||
|
"name": "kind",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"detail": {
|
||||||
|
"name": "detail",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"occurred_at": {
|
||||||
|
"name": "occurred_at",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(current_timestamp)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"lane_devices": {
|
||||||
|
"name": "lane_devices",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"lane": {
|
||||||
|
"name": "lane",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"category": {
|
||||||
|
"name": "category",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"driver_id": {
|
||||||
|
"name": "driver_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"config": {
|
||||||
|
"name": "config",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"enabled": {
|
||||||
|
"name": "enabled",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(current_timestamp)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"ledger_events": {
|
||||||
|
"name": "ledger_events",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"index": {
|
||||||
|
"name": "index",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"name": "type",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"direction": {
|
||||||
|
"name": "direction",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"lane": {
|
||||||
|
"name": "lane",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"source": {
|
||||||
|
"name": "source",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"identity": {
|
||||||
|
"name": "identity",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"payload": {
|
||||||
|
"name": "payload",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"occurred_at": {
|
||||||
|
"name": "occurred_at",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"prev_hash": {
|
||||||
|
"name": "prev_hash",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"signature": {
|
||||||
|
"name": "signature",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"key_id": {
|
||||||
|
"name": "key_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"ledger_events_index_unique": {
|
||||||
|
"name": "ledger_events_index_unique",
|
||||||
|
"columns": [
|
||||||
|
"index"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"permit_credentials": {
|
||||||
|
"name": "permit_credentials",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"permit_id": {
|
||||||
|
"name": "permit_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"kind": {
|
||||||
|
"name": "kind",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"value": {
|
||||||
|
"name": "value",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"permit_plates": {
|
||||||
|
"name": "permit_plates",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"permit_id": {
|
||||||
|
"name": "permit_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"plate": {
|
||||||
|
"name": "plate",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"permits": {
|
||||||
|
"name": "permits",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"holder_name": {
|
||||||
|
"name": "holder_name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"contact": {
|
||||||
|
"name": "contact",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"max_concurrent": {
|
||||||
|
"name": "max_concurrent",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 1
|
||||||
|
},
|
||||||
|
"valid_from": {
|
||||||
|
"name": "valid_from",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"valid_to": {
|
||||||
|
"name": "valid_to",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"name": "status",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'active'"
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(current_timestamp)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"sessions": {
|
||||||
|
"name": "sessions",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"lane": {
|
||||||
|
"name": "lane",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"identity": {
|
||||||
|
"name": "identity",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"source": {
|
||||||
|
"name": "source",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"permit_id": {
|
||||||
|
"name": "permit_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"entered_at": {
|
||||||
|
"name": "entered_at",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"exited_at": {
|
||||||
|
"name": "exited_at",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"state": {
|
||||||
|
"name": "state",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'open'"
|
||||||
|
},
|
||||||
|
"last_event_index": {
|
||||||
|
"name": "last_event_index",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"setup_state": {
|
||||||
|
"name": "setup_state",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"completed_at": {
|
||||||
|
"name": "completed_at",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"site_config": {
|
||||||
|
"name": "site_config",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"capacity": {
|
||||||
|
"name": "capacity",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(current_timestamp)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"tariff_versions": {
|
||||||
|
"name": "tariff_versions",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"tariff_id": {
|
||||||
|
"name": "tariff_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"effective_from": {
|
||||||
|
"name": "effective_from",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"currency": {
|
||||||
|
"name": "currency",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"structure": {
|
||||||
|
"name": "structure",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_by": {
|
||||||
|
"name": "created_by",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(current_timestamp)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"tariffs": {
|
||||||
|
"name": "tariffs",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"scope": {
|
||||||
|
"name": "scope",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'site'"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(current_timestamp)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"users": {
|
||||||
|
"name": "users",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"username": {
|
||||||
|
"name": "username",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"password_hash": {
|
||||||
|
"name": "password_hash",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"role": {
|
||||||
|
"name": "role",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(current_timestamp)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"users_username_unique": {
|
||||||
|
"name": "users_username_unique",
|
||||||
|
"columns": [
|
||||||
|
"username"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"views": {},
|
||||||
|
"enums": {},
|
||||||
|
"_meta": {
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {},
|
||||||
|
"columns": {}
|
||||||
|
},
|
||||||
|
"internal": {
|
||||||
|
"indexes": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,13 @@
|
|||||||
"when": 1781539958008,
|
"when": 1781539958008,
|
||||||
"tag": "0000_baseline",
|
"tag": "0000_baseline",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 1,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1781590195573,
|
||||||
|
"tag": "0001_site_config",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -101,6 +101,17 @@ export const setupState = sqliteTable("setup_state", {
|
|||||||
completedAt: text("completed_at"),
|
completedAt: text("completed_at"),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Single-row site settings (admin-configurable). The home for site-wide knobs;
|
||||||
|
// `capacity` is the nominal space count the FULL gate refuses transient entry at
|
||||||
|
// (null = no cap). See wiki/concepts/capacity-occupancy.md.
|
||||||
|
export const siteConfig = sqliteTable("site_config", {
|
||||||
|
id: integer("id").primaryKey(), // always 1
|
||||||
|
capacity: integer("capacity"), // null = no capacity limit
|
||||||
|
updatedAt: text("updated_at")
|
||||||
|
.notNull()
|
||||||
|
.default(sql`(current_timestamp)`),
|
||||||
|
});
|
||||||
|
|
||||||
// --- Tariffs (composable, versioned) -------------------------------------
|
// --- Tariffs (composable, versioned) -------------------------------------
|
||||||
// A `tariffs` row is a logical rate card; its pricing lives in immutable, effective-
|
// A `tariffs` row is a logical rate card; its pricing lives in immutable, effective-
|
||||||
// dated `tariff_versions`. Editing prices PUBLISHES a new version, never mutates one.
|
// dated `tariff_versions`. Editing prices PUBLISHES a new version, never mutates one.
|
||||||
@@ -215,6 +226,7 @@ export type LedgerEventRow = typeof ledgerEvents.$inferSelect;
|
|||||||
export type DeviceEventRow = typeof deviceEvents.$inferSelect;
|
export type DeviceEventRow = typeof deviceEvents.$inferSelect;
|
||||||
export type LaneDeviceRow = typeof laneDevices.$inferSelect;
|
export type LaneDeviceRow = typeof laneDevices.$inferSelect;
|
||||||
export type SetupStateRow = typeof setupState.$inferSelect;
|
export type SetupStateRow = typeof setupState.$inferSelect;
|
||||||
|
export type SiteConfigRow = typeof siteConfig.$inferSelect;
|
||||||
export type TariffRow = typeof tariffs.$inferSelect;
|
export type TariffRow = typeof tariffs.$inferSelect;
|
||||||
export type TariffVersionRow = typeof tariffVersions.$inferSelect;
|
export type TariffVersionRow = typeof tariffVersions.$inferSelect;
|
||||||
export type PermitRow = typeof permits.$inferSelect;
|
export type PermitRow = typeof permits.$inferSelect;
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import type { AccessControlDevice, DeviceHealth } from "../interfaces.js";
|
||||||
|
import type { AccessDriver, DeviceConfig } from "../registry.js";
|
||||||
|
import { stubLog } from "./common.js";
|
||||||
|
|
||||||
|
// Stub access controller — a no-op barrier for BENCH TESTING the entry/exit/permit
|
||||||
|
// flows without real relay hardware. `pulseOpen` just logs "intent to open"; it
|
||||||
|
// performs no device I/O, so it can stand in on a lane while the real
|
||||||
|
// [[dingtian-relay]] isn't connected. NOT for production. See first-run-setup.md.
|
||||||
|
|
||||||
|
class StubAccess implements AccessControlDevice {
|
||||||
|
readonly driverId = "stub-access";
|
||||||
|
constructor(_config: DeviceConfig) {}
|
||||||
|
async connect(): Promise<void> {}
|
||||||
|
async disconnect(): Promise<void> {}
|
||||||
|
async healthCheck(): Promise<DeviceHealth> {
|
||||||
|
return { status: "ready", detail: "stub (no real barrier)" };
|
||||||
|
}
|
||||||
|
async pulseOpen(doorId: number): Promise<void> {
|
||||||
|
stubLog(this.driverId, `pulseOpen door ${doorId} (stub — no relay fired)`);
|
||||||
|
}
|
||||||
|
async getDoorStatus(): Promise<"open" | "closed"> {
|
||||||
|
return "closed";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const stubAccessDriver: AccessDriver = {
|
||||||
|
id: "stub-access",
|
||||||
|
category: "access",
|
||||||
|
label: "Stub barrier (bench testing — no relay)",
|
||||||
|
description:
|
||||||
|
"A no-op access controller for testing the flows without hardware. pulseOpen only logs; no relay is fired. Not for production.",
|
||||||
|
transports: ["tcp-ip"],
|
||||||
|
configFields: [],
|
||||||
|
create: (c) => new StubAccess(c),
|
||||||
|
};
|
||||||
@@ -3,9 +3,10 @@
|
|||||||
|
|
||||||
import { registry } from "../registry.js";
|
import { registry } from "../registry.js";
|
||||||
import { dingtianDriver } from "./access-dingtian.js";
|
import { dingtianDriver } from "./access-dingtian.js";
|
||||||
|
import { stubAccessDriver } from "./access-stub.js";
|
||||||
import { dahuaDriver, hikvisionDriver } from "./camera.js";
|
import { dahuaDriver, hikvisionDriver } from "./camera.js";
|
||||||
import { rongtaDriver } from "./printer-rongta.js";
|
import { rongtaDriver } from "./printer-rongta.js";
|
||||||
import { tcpipReaderDriver, wiegandReaderDriver } from "./reader.js";
|
import { geeQrReaderDriver, tcpipReaderDriver, wiegandReaderDriver } from "./reader.js";
|
||||||
|
|
||||||
let registered = false;
|
let registered = false;
|
||||||
|
|
||||||
@@ -14,8 +15,10 @@ export function registerBuiltinDrivers(): void {
|
|||||||
if (registered) return;
|
if (registered) return;
|
||||||
registered = true;
|
registered = true;
|
||||||
registry.register(dingtianDriver);
|
registry.register(dingtianDriver);
|
||||||
|
registry.register(stubAccessDriver);
|
||||||
registry.register(wiegandReaderDriver);
|
registry.register(wiegandReaderDriver);
|
||||||
registry.register(tcpipReaderDriver);
|
registry.register(tcpipReaderDriver);
|
||||||
|
registry.register(geeQrReaderDriver);
|
||||||
registry.register(hikvisionDriver);
|
registry.register(hikvisionDriver);
|
||||||
registry.register(dahuaDriver);
|
registry.register(dahuaDriver);
|
||||||
registry.register(rongtaDriver);
|
registry.register(rongtaDriver);
|
||||||
@@ -23,8 +26,10 @@ export function registerBuiltinDrivers(): void {
|
|||||||
|
|
||||||
export {
|
export {
|
||||||
dingtianDriver,
|
dingtianDriver,
|
||||||
|
stubAccessDriver,
|
||||||
wiegandReaderDriver,
|
wiegandReaderDriver,
|
||||||
tcpipReaderDriver,
|
tcpipReaderDriver,
|
||||||
|
geeQrReaderDriver,
|
||||||
hikvisionDriver,
|
hikvisionDriver,
|
||||||
dahuaDriver,
|
dahuaDriver,
|
||||||
rongtaDriver,
|
rongtaDriver,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type {
|
|||||||
MonitorableDevice,
|
MonitorableDevice,
|
||||||
PrinterDevice,
|
PrinterDevice,
|
||||||
PrinterStatus,
|
PrinterStatus,
|
||||||
|
PrintReport,
|
||||||
TicketData,
|
TicketData,
|
||||||
} from "../interfaces.js";
|
} from "../interfaces.js";
|
||||||
import type { ConfigField, DeviceConfig, PrinterDriver } from "../registry.js";
|
import type { ConfigField, DeviceConfig, PrinterDriver } from "../registry.js";
|
||||||
@@ -44,6 +45,21 @@ function line(text = ""): Buffer {
|
|||||||
return Buffer.concat([Buffer.from(text, "ascii"), Buffer.from([LF])]);
|
return Buffer.concat([Buffer.from(text, "ascii"), Buffer.from([LF])]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Build the ESC/POS byte stream for a free-form text report (e.g. shift Z-report). */
|
||||||
|
function renderReport(report: PrintReport): Buffer {
|
||||||
|
return Buffer.concat([
|
||||||
|
INIT,
|
||||||
|
ALIGN_CENTER,
|
||||||
|
BOLD_ON,
|
||||||
|
line(report.title),
|
||||||
|
BOLD_OFF,
|
||||||
|
ALIGN_LEFT,
|
||||||
|
line(),
|
||||||
|
...report.lines.map((l) => line(l)),
|
||||||
|
FEED_AND_CUT,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
/** Build the full ESC/POS byte stream for an entry ticket. */
|
/** Build the full ESC/POS byte stream for an entry ticket. */
|
||||||
function renderTicket(data: TicketData): Buffer {
|
function renderTicket(data: TicketData): Buffer {
|
||||||
return Buffer.concat([
|
return Buffer.concat([
|
||||||
@@ -204,6 +220,11 @@ class RongtaPrinter implements PrinterDevice, MonitorableDevice {
|
|||||||
stubLog(this.driverId, `printed ticket ${data.ticketId} (lane ${data.lane})`);
|
stubLog(this.driverId, `printed ticket ${data.ticketId} (lane ${data.lane})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async printReport(report: PrintReport): Promise<void> {
|
||||||
|
await sendRaw(this.#host, this.#port, renderReport(report), this.#timeout);
|
||||||
|
stubLog(this.driverId, `printed report "${report.title}" (${report.lines.length} lines)`);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Live operator-actionable status, scraped from the device's own status page.
|
* Live operator-actionable status, scraped from the device's own status page.
|
||||||
* The board decodes the ESC/POS status bits itself, so we trust its Yes/No
|
* The board decodes the ESC/POS status bits itself, so we trust its Yes/No
|
||||||
|
|||||||
@@ -58,3 +58,28 @@ export const tcpipReaderDriver: ReaderDriver = {
|
|||||||
configFields: [hostField, portField(9000)],
|
configFields: [hostField, portField(9000)],
|
||||||
create: (c) => new StubReader("tcpip-reader", c),
|
create: (c) => new StubReader("tcpip-reader", c),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// GEE/Fondvision QR access reader (e.g. GEE-QR-ER80). A PUSH device: on each scan
|
||||||
|
// it HTTP-GETs our backend (/qa/mcardsea.<ext>) carrying its serial (cjihao); the
|
||||||
|
// backend resolves the lane by matching that serial to this device's `serial`
|
||||||
|
// config, decides, and replies the verdict (drives the beep). No host-side
|
||||||
|
// connection — the adapter is a stub; the real integration is the HTTP endpoint
|
||||||
|
// (apps/server routes/qr-reader.ts). See wiki/entities/gee-qr-er80.md.
|
||||||
|
export const geeQrReaderDriver: ReaderDriver = {
|
||||||
|
id: "gee-qr-reader",
|
||||||
|
category: "reader",
|
||||||
|
label: "GEE/Fondvision QR reader (HTTP push)",
|
||||||
|
description:
|
||||||
|
"QR/barcode access reader that HTTP-pushes each scan to the backend. Set its server IP/port to this host in the vendor tool; enter its serial here so scans resolve to this lane.",
|
||||||
|
transports: ["tcp-ip"],
|
||||||
|
configFields: [
|
||||||
|
{
|
||||||
|
key: "serial",
|
||||||
|
label: "Device serial (cjihao)",
|
||||||
|
type: "string",
|
||||||
|
required: true,
|
||||||
|
help: "The reader's serial as it reports in each scan (the `cjihao` field). Used to map scans to this lane.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
create: (c) => new StubReader("gee-qr-reader", c),
|
||||||
|
};
|
||||||
|
|||||||
@@ -11,8 +11,10 @@ export { setDeviceLogSink, type DeviceLogSink } from "./drivers/common.js";
|
|||||||
export {
|
export {
|
||||||
registerBuiltinDrivers,
|
registerBuiltinDrivers,
|
||||||
dingtianDriver,
|
dingtianDriver,
|
||||||
|
stubAccessDriver,
|
||||||
wiegandReaderDriver,
|
wiegandReaderDriver,
|
||||||
tcpipReaderDriver,
|
tcpipReaderDriver,
|
||||||
|
geeQrReaderDriver,
|
||||||
hikvisionDriver,
|
hikvisionDriver,
|
||||||
dahuaDriver,
|
dahuaDriver,
|
||||||
rongtaDriver,
|
rongtaDriver,
|
||||||
|
|||||||
@@ -199,6 +199,15 @@ export interface TicketData {
|
|||||||
|
|
||||||
export interface PrinterDevice extends Device {
|
export interface PrinterDevice extends Device {
|
||||||
printTicket(data: TicketData): Promise<void>;
|
printTicket(data: TicketData): Promise<void>;
|
||||||
|
/** Print a free-form text report (a shift Z-report, a receipt). `lines` are
|
||||||
|
* printed as-is; the driver adds a header/cut. Kept generic so the business
|
||||||
|
* layer composes the content. See wiki/concepts/shift.md. */
|
||||||
|
printReport(report: PrintReport): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PrintReport {
|
||||||
|
readonly title: string;
|
||||||
|
readonly lines: readonly string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Live printer status (consumable / mechanical faults) ----------------
|
// --- Live printer status (consumable / mechanical faults) ----------------
|
||||||
|
|||||||
@@ -48,6 +48,9 @@ export type LedgerEventType =
|
|||||||
// (loop/sensor) — reconciled against each other.
|
// (loop/sensor) — reconciled against each other.
|
||||||
| "barrier_open_command"
|
| "barrier_open_command"
|
||||||
| "barrier_open_observed"
|
| "barrier_open_observed"
|
||||||
|
// Manned-mode shift boundary: an operator takes over (shift_open) / hands over
|
||||||
|
// with a takings summary (shift_z_report). See wiki/concepts/shift.md.
|
||||||
|
| "shift_open"
|
||||||
| "shift_z_report"
|
| "shift_z_report"
|
||||||
| "anomaly";
|
| "anomaly";
|
||||||
|
|
||||||
|
|||||||
@@ -41,8 +41,27 @@ over, stacked beyond the marked count) instead of refusing. So the FULL gate is
|
|||||||
(refuse vs. valet-accept), set by the operator per site. Valet is a manned-mode feature with its
|
(refuse vs. valet-accept), set by the operator per site. Valet is a manned-mode feature with its
|
||||||
own custody/session shape — see [[valet-overcapacity]] (deferred).
|
own custody/session shape — see [[valet-overcapacity]] (deferred).
|
||||||
|
|
||||||
|
## As-built (2026-06-16)
|
||||||
|
|
||||||
|
- **Occupancy** = `occupancyCount` (`apps/server/src/occupancy.ts`): a fold over the ledger —
|
||||||
|
entries minus exits per identity, count those `> 0`. `getOccupancy` returns `{count, capacity,
|
||||||
|
free, full}`.
|
||||||
|
- **Capacity** is a single-row `site_config` table (admin-set; `null` = uncapped). Routes
|
||||||
|
(`routes/site.ts`): `GET /api/occupancy` + `GET /api/site-config` (any role), `PUT /api/site-config`
|
||||||
|
(admin; non-negative int or null).
|
||||||
|
- **FULL gate** is in the **transient entry flow**: `occupancy.full` → refuse (no ticket, no
|
||||||
|
`vehicle_entry`, no open) + signed `anomaly`. **Permit entry is NOT gated** here — subscribers are
|
||||||
|
admitted past transient-full (their own `maxConcurrent` still applies); occupancy can read
|
||||||
|
over-capacity (`free` negative) when permits enter a full lot, as intended.
|
||||||
|
- **UI** `SiteSettings`: live occupancy + FULL badge (everyone); capacity editor (admin).
|
||||||
|
- Verified: fill to cap → 3rd transient refused; permit still admitted past full; exit frees a
|
||||||
|
slot; RBAC (operator can't set capacity); verifyChain ok. Physical FULL-sign relay output is
|
||||||
|
**deferred** (needs a sign device).
|
||||||
|
|
||||||
## Open
|
## Open
|
||||||
|
|
||||||
- Zone/level granularity at launch vs. single capacity number.
|
- Zone/level granularity at launch vs. single capacity number.
|
||||||
- Reserve-for-permits threshold.
|
- Reserve-for-permits **threshold** (a soft transient cap below the hard capacity) — currently
|
||||||
|
permits are simply ungated; a tunable threshold is the richer version.
|
||||||
|
- Physical FULL-sign relay output (a sign-device role).
|
||||||
- The valet over-capacity mode + custody model ([[valet-overcapacity]]).
|
- The valet over-capacity mode + custody model ([[valet-overcapacity]]).
|
||||||
|
|||||||
@@ -22,6 +22,12 @@ There are **two populations** of users, and they map to **two integration paths*
|
|||||||
| [[wiegand]] reader → UHPPOTE port | The controller | Controller (onboard card list) | **Yes** — works if host down |
|
| [[wiegand]] reader → UHPPOTE port | The controller | Controller (onboard card list) | **Yes** — works if host down |
|
||||||
| Pure TCP/IP reader | Host only | Host, then UDP `open` to relay | No — host on critical path |
|
| Pure TCP/IP reader | Host only | Host, then UDP `open` to relay | No — host on critical path |
|
||||||
| [[lpr-camera|LPR]] / QR scanner | Host only | Host | No |
|
| [[lpr-camera|LPR]] / QR scanner | Host only | Host | No |
|
||||||
|
| **[[gee-qr-er80]] QR reader (serial)** | Host only | Host (reads serial → `read` bus) | No |
|
||||||
|
|
||||||
|
> Concrete host-side reader on hand: the **[[gee-qr-er80]]** (QR over RS-232/RS-485). Note autonomy
|
||||||
|
> is moot here anyway — the current relay ([[dingtian-relay]]) has **no onboard card list**, so even
|
||||||
|
> a Wiegand reader would be host-decided. So we take the serial/QR path straight to the host's
|
||||||
|
> `read` bus.
|
||||||
|
|
||||||
## Key points
|
## Key points
|
||||||
|
|
||||||
|
|||||||
@@ -52,6 +52,24 @@ login ————————————————————————
|
|||||||
That's the whole human-side requirement: **print the cash and the POS (if any).** No blind count,
|
That's the whole human-side requirement: **print the cash and the POS (if any).** No blind count,
|
||||||
no variance gate, no manager override.
|
no variance gate, no manager override.
|
||||||
|
|
||||||
|
### As-built (2026-06-16)
|
||||||
|
|
||||||
|
- A shift is **two signed ledger events**, no mutable table (decision): `shift_open` (new event
|
||||||
|
type) at start, `shift_z_report` at close. The operator is the **logged-in user**, carried in the
|
||||||
|
event `identity`; a shift is **open** iff that operator's most recent shift event is a
|
||||||
|
`shift_open`. `ShiftService` (`apps/server/src/shift-service.ts`).
|
||||||
|
- **Close** sums `payment` events in `[startedAt, endedAt]` by tender (cash vs. card, by **payment
|
||||||
|
time**), appends the signed `shift_z_report` (totals + counts + window), then **prints** via the
|
||||||
|
new generic `PrinterDevice.printReport(title, lines)` (Rongta ESC/POS text) to a booth-receipt
|
||||||
|
printer. Printing is best-effort — a failed print does **not** undo the signed close (the event is
|
||||||
|
the record; `printed:false` is returned).
|
||||||
|
- **Routes** (`routes/shift.ts`, cashier/operator/admin): `GET /api/shift/current`,
|
||||||
|
`POST /api/shift/open` (409 if already open), `POST /api/shift/close` (409 if none open).
|
||||||
|
**UI** `ShiftControl` in the app shell (non-readonly): Start/End + the Z-report totals.
|
||||||
|
- Verified: open → double-open 409 → payments (cash+card, one dated outside the window excluded) →
|
||||||
|
close totals correct + signed + printed → close-again 409 → re-open works; readonly 403;
|
||||||
|
verifyChain ok.
|
||||||
|
|
||||||
## Where the fraud control actually lives
|
## Where the fraud control actually lives
|
||||||
|
|
||||||
Deliberately **not** in a shift-close ceremony. Because every payment is a **signed event in the
|
Deliberately **not** in a shift-close ceremony. Because every payment is a **signed event in the
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ must have:
|
|||||||
- The **scanner is a device behind an adapter** ([[device-adapter-pattern]]): a new `ReaderDevice`
|
- The **scanner is a device behind an adapter** ([[device-adapter-pattern]]): a new `ReaderDevice`
|
||||||
kind (QR/barcode imager) — likely the same `IdentitySource = "ticket"` / `"qr"` path. Keeps the
|
kind (QR/barcode imager) — likely the same `IdentitySource = "ticket"` / `"qr"` path. Keeps the
|
||||||
app device-agnostic; hardware model is procurement ([[bom]], [[open-questions]]).
|
app device-agnostic; hardware model is procurement ([[bom]], [[open-questions]]).
|
||||||
|
- **On hand:** the **[[gee-qr-er80]]** QR access reader (`-Q-W`: QR scanner, Wiegand/RS-232/RS-485,
|
||||||
|
Linux-supported) — the concrete scanner for this path. A serial `ReaderDevice` adapter feeds the
|
||||||
|
`read` bus; pending the reader's RS-232 frame/baud (see [[gee-qr-er80]] open questions).
|
||||||
|
|
||||||
## Ticketless alternative (plate as the ticket)
|
## Ticketless alternative (plate as the ticket)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
---
|
||||||
|
type: entity
|
||||||
|
tags: [parking, hardware, readers, qr]
|
||||||
|
sources: [gee-qr-er80]
|
||||||
|
updated: 2026-06-16
|
||||||
|
status: open
|
||||||
|
---
|
||||||
|
|
||||||
|
# GEE-QR-ER80 (QR access reader)
|
||||||
|
|
||||||
|
The project's **QR-code reader** (GEE NFC LIMITED). A static optical scanner for **QR /
|
||||||
|
DataMatrix / 1D barcode**, optional ID/IC card. This is the **[[ticket-encoding|QR ticket]]
|
||||||
|
scanner** the design called for — read at the pay station and exit lane — and a path for **QR
|
||||||
|
[[permit]]** credentials. On hand: variant **`-Q-W`** (QR scanner; Wiegand/RS-232/RS-485).
|
||||||
|
(See [[gee-qr-er80|datasheet summary]] / `raw/`.)
|
||||||
|
|
||||||
|
## What it is (and isn't)
|
||||||
|
|
||||||
|
- **Optical, not RFID-prox.** Earlier we *assumed* "ER80-EM" = a 125 kHz EM4100 card reader — the
|
||||||
|
datasheet corrects that: it's a **QR/barcode scanner**. The `-EM` in the original label was a
|
||||||
|
mis-id; the real model is **GEE-QR-ER80**. Optional `D`/`C` variants add ID/IC card, but the unit
|
||||||
|
on hand is **QR-only** (`-Q`).
|
||||||
|
- **Multi-interface** (Wiegand 26/34, RS-232, RS-485, USB, TCP/IP); the `-W` variant exposes
|
||||||
|
**Wiegand + RS-232/RS-485**.
|
||||||
|
|
||||||
|
## How it integrates — HTTP-GET push, server replies the verdict (confirmed via SDK)
|
||||||
|
|
||||||
|
The protocol is settled by the **[[qrcode-sdk|QRCode SDK v1.6.5]]** (not serial as first guessed).
|
||||||
|
The reader is configured (Windows tool `QRCode_v1_6_5.exe`) with a **server IP/port** and a "server
|
||||||
|
language" (only picks the URL path, e.g. `/qa/mcardsea.php`). **On each scan it HTTP-GETs the host:**
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /qa/mcardsea.php?cardid=<QR>&mjihao=<devId>&cjihao=<devSN>&status=<2 chars>&time=<utc>
|
||||||
|
```
|
||||||
|
`cardid` = scanned data; `status` low digit = **direction (1=in / 0=out)**. The host replies JSON
|
||||||
|
`{data:[{cardid,cjihao,mjihao,status,time,output}],code:0}` where reply **`status` 1=valid (beep
|
||||||
|
2×) / 0=invalid (beep 1×)**, **`output` 0=Access/1=WG26/2=WG34**, `time` syncs the clock.
|
||||||
|
|
||||||
|
This is **host-in-the-loop and SYNCHRONOUS**: the GET *is* the access query and **our reply is the
|
||||||
|
decision** — it drives the reader's beep + output. So unlike a fire-and-forget reader, the endpoint
|
||||||
|
must decide (valid/invalid, direction from `status`) and reply, then also emit a `DeviceReadEvent`
|
||||||
|
on the `read` bus for the entry/exit/permit flows ([[parking-session]], [[permit]]) to open the
|
||||||
|
barrier. ([[device-input-flow]] is the analogous push pattern; this one also returns a verdict.)
|
||||||
|
|
||||||
|
> **This explains the "no beep":** feedback comes from the server's JSON reply, not locally. A
|
||||||
|
> non-JSON / missing reply ⇒ no beep even though the scan worked. So "no beep" ≠ "didn't scan."
|
||||||
|
|
||||||
|
- Pushes over plain **HTTP** to our `10.0.10.x` host (on the device subnet); no serial wiring, no
|
||||||
|
Wiegand-decode hardware. Suits the host-in-the-loop model; autonomy is moot anyway
|
||||||
|
([[dingtian-relay]] has no onboard ACL).
|
||||||
|
- **Linux-supported**, 4–15 VDC, default IP `192.168.1.99` — fits the [[disk-os-hardening|appliance]].
|
||||||
|
|
||||||
|
## Resolved (2026-06-16)
|
||||||
|
|
||||||
|
- Protocol = HTTP GET poll + JSON verdict (above). The earlier "serial/Wiegand, find the baud"
|
||||||
|
open questions are **moot** — it's HTTP. Wiegand is the reader's *output line* on a valid read
|
||||||
|
(the reply `output` field), not the host transport.
|
||||||
|
|
||||||
|
## As-built (2026-06-16)
|
||||||
|
|
||||||
|
- **Endpoint** `GET/POST /qa/mcardsea.php` (`apps/server/src/routes/qr-reader.ts`, public — the
|
||||||
|
reader has no auth, sits on the device subnet). Parses `cardid/mjihao/cjihao/status/time`, runs
|
||||||
|
the scan through the **read dispatcher** (permit match → permit flow; else transient exit), and
|
||||||
|
replies the **SDK verdict**: `status` 1=valid(beep 2×)/0=invalid(beep 1×), `output` 0, `time`.
|
||||||
|
- The read flows were refactored to **return a `ReadOutcome` { accepted, direction, reason }** so the
|
||||||
|
endpoint's reply reflects the real accept/reject (the dispatcher decides AND opens the barrier via
|
||||||
|
the flows). A fire-and-forget reader ignores the outcome.
|
||||||
|
- **Lane mapping:** the endpoint keys the reader's `lane_devices` id off the device **serial
|
||||||
|
(`cjihao`)** for now — so assign the reader with `lane_devices.id = <serial>`. Refine when the
|
||||||
|
setup wizard models the reader's server-side identity properly.
|
||||||
|
- Verified via inject: valid permit QR → `status:1` + open; re-scan → permit exit (still valid);
|
||||||
|
unknown QR → `status:0`; reader on a barrier-less lane → `status:0`.
|
||||||
|
|
||||||
|
## Verified on hardware (2026-06-16)
|
||||||
|
|
||||||
|
Captured a real scan (vendor-emulator logger on :3000). The reader **does scan, send, and beep** —
|
||||||
|
the earlier "no beep" was simply that no server was answering on :3000 with valid JSON. Real GET:
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /qa/mcardsea.jsp?cardid=52020056&mjihao=1&cjihao=H05M2AFA&status=11&time=1781634494
|
||||||
|
from 10.0.10.7 (referer: http://www.fondvision.com — the OEM is Fondvision)
|
||||||
|
```
|
||||||
|
|
||||||
|
- **PATH carries the configured "server language" EXTENSION:** this unit is set to **JSP**, so it
|
||||||
|
GETs **`/qa/mcardsea.jsp`** — NOT `.php`. Our endpoint was registered at `.php` only → it would
|
||||||
|
have 404'd the real reader. **Fixed:** the route now registers `php/jsp/asp/aspx/cgi`.
|
||||||
|
- **`cjihao` = `H05M2AFA`** is the device **serial** — the value our endpoint keys the lane on. So
|
||||||
|
assign the reader with **`lane_devices.id = "H05M2AFA"`** (+ an access device on the same lane).
|
||||||
|
- **`mjihao` = 1** (device id). `cardid` = the scanned barcode (`52020056`). `status=11`.
|
||||||
|
- The reader **beeped on the vendor reply with `status:0`** — so it acts on the reply; `0` =
|
||||||
|
invalid/1-beep as documented. A matching permit/session will return `status:1` → 2-beep accept.
|
||||||
|
|
||||||
|
## Assignment (as-built 2026-06-16)
|
||||||
|
|
||||||
|
A dedicated **`gee-qr-reader`** driver ([[device-registry]], reader category) models the push reader:
|
||||||
|
its one config field is **`serial`** (the `cjihao` the device reports). The admin assigns it in the
|
||||||
|
[[first-run-setup|setup wizard]] like any device (normal UUID row id) and enters the serial. The QR
|
||||||
|
endpoint resolves the lane by **matching `config.serial` to the scan's `cjihao`** — not by row id —
|
||||||
|
so no DB hand-editing. Set the reader's server IP/port to this host in the **vendor tool**; assign +
|
||||||
|
enter its serial here.
|
||||||
|
|
||||||
|
- Verified via inject: assign `gee-qr-reader` {serial:"H05M2AFA"} on a lane w/ an access device →
|
||||||
|
a `.jsp` scan with that serial + a matching permit QR → `status:1` (2-beep accept) + open; re-scan
|
||||||
|
→ permit exit; unknown card → `status:0`; unassigned serial → `status:0` (no lane, graceful).
|
||||||
|
- Note `tcpip-reader` is the WRONG model for this device (host-connects-out, a stub) — use
|
||||||
|
`gee-qr-reader`.
|
||||||
|
|
||||||
|
## Open / next
|
||||||
|
|
||||||
|
- Re-test on hardware against the real app (now `.jsp`-aware + serial-resolved): scan → expect a
|
||||||
|
`status:1` 2-beep when the QR matches a permit/open session.
|
||||||
|
- `output` is replied as `0` (Access). Confirm on hardware whether the reader needs `1`/`2` (WG26/34)
|
||||||
|
to drive its access line, vs. `0`.
|
||||||
|
|
||||||
|
## ⚠️ Reply MUST set `Connection: close` (verified on hardware)
|
||||||
|
|
||||||
|
The reader sends `Connection: keep-alive` but **only acts on the verdict (beep/output) once the TCP
|
||||||
|
socket CLOSES**. Fastify's default keeps the connection alive → the reader waits out a **~10 s
|
||||||
|
keep-alive timeout before beeping**, even though the server replied in ~15 ms. Every vendor demo
|
||||||
|
replies **`Connection: close`** and shuts the socket. Fix: the endpoint sets
|
||||||
|
`reply.header("connection","close")`. Symptom if regressed: correct accept/reject but a ~10 s lag
|
||||||
|
before the beep. (The request arrives fast; the delay is entirely the reader waiting for close.)
|
||||||
+4
-1
@@ -7,7 +7,7 @@ updated: 2026-06-14
|
|||||||
# Index
|
# Index
|
||||||
|
|
||||||
Content catalog for the wiki. Start at [[overview]]. Maintained on every ingest.
|
Content catalog for the wiki. Start at [[overview]]. Maintained on every ingest.
|
||||||
Counts: 1 source · 18 entities · 24 concepts · 5 decision records.
|
Counts: 3 sources · 19 entities · 24 concepts · 5 decision records.
|
||||||
|
|
||||||
## Overview & navigation
|
## Overview & navigation
|
||||||
- [[overview]] — the top-level synthesis and entry point.
|
- [[overview]] — the top-level synthesis and entry point.
|
||||||
@@ -16,6 +16,8 @@ Counts: 1 source · 18 entities · 24 concepts · 5 decision records.
|
|||||||
|
|
||||||
## Sources
|
## Sources
|
||||||
- [[parking-system-architecture]] — design notes: stack, threat model, devices, UHPPOTE, ESP32, readers, BOM, open decisions.
|
- [[parking-system-architecture]] — design notes: stack, threat model, devices, UHPPOTE, ESP32, readers, BOM, open decisions.
|
||||||
|
- [[gee-qr-er80]] — datasheet: GEE QR access reader (QR/DM/1D; Wiegand/RS-232/485/USB/TCP; Linux).
|
||||||
|
- [[qrcode-sdk]] — QRCode SDK v1.6.5: the reader's HTTP-GET-poll protocol + JSON verdict (beep/output).
|
||||||
|
|
||||||
## Entities — technology stack
|
## Entities — technology stack
|
||||||
- [[technology-stack]] — the full stack table; all MIT/Apache/BSD, chosen to avoid lock-in.
|
- [[technology-stack]] — the full stack table; all MIT/Apache/BSD, chosen to avoid lock-in.
|
||||||
@@ -37,6 +39,7 @@ Counts: 1 source · 18 entities · 24 concepts · 5 decision records.
|
|||||||
- [[atecc608]] — secure element; non-extractable signing key (host events + controller auth).
|
- [[atecc608]] — secure element; non-extractable signing key (host events + controller auth).
|
||||||
- [[wiegand]] — reader standard feeding the controller directly (autonomous permit-holder path).
|
- [[wiegand]] — reader standard feeding the controller directly (autonomous permit-holder path).
|
||||||
- [[lpr-camera]] — edge-AI plate recognition; host-side casual-identity source.
|
- [[lpr-camera]] — edge-AI plate recognition; host-side casual-identity source.
|
||||||
|
- [[gee-qr-er80]] — QR access reader on hand; host-side serial → `read` bus (the QR-ticket scanner).
|
||||||
- [[zkteco-controller]] — ❌ rejected/historical; aux-input path was a contender, not pursued.
|
- [[zkteco-controller]] — ❌ rejected/historical; aux-input path was a contender, not pursued.
|
||||||
- [[dingtian-relay]] — ✅ CHOSEN access controller; decoupled inputs solve the button blocker (driver verified on hardware).
|
- [[dingtian-relay]] — ✅ CHOSEN access controller; decoupled inputs solve the button blocker (driver verified on hardware).
|
||||||
- [[rongta-printer]] — ✅ CHOSEN 80mm thermal printer; ESC/POS over raw TCP 9100; driver written, one unit reachable at 10.0.10.6.
|
- [[rongta-printer]] — ✅ CHOSEN 80mm thermal printer; ESC/POS over raw TCP 9100; driver written, one unit reachable at 10.0.10.6.
|
||||||
|
|||||||
+123
@@ -577,3 +577,126 @@ guarantee. Recorded in [[dingtian-relay]] (new Hardening section).
|
|||||||
create 403; update unbinds + REPLACES child rows (old cred gone); revoke→revoked; delete→204 then
|
create 403; update unbinds + REPLACES child rows (old cred gone); revoke→revoked; delete→204 then
|
||||||
404, children cleaned. Full build 5/5.
|
404, children cleaned. Full build 5/5.
|
||||||
- Updated [[permit]] (CRUD as-built).
|
- Updated [[permit]] (CRUD as-built).
|
||||||
|
|
||||||
|
## [2026-06-16] build | Shifts: open/close + signed Z-report (manned mode)
|
||||||
|
- Shift = two signed ledger events, NO mutable table: new `shift_open` event type + existing
|
||||||
|
`shift_z_report`. Operator = logged-in user (in event `identity`); open iff their latest shift
|
||||||
|
event is a `shift_open`. `apps/server/src/shift-service.ts`.
|
||||||
|
- Close sums `payment` events in the window by tender (cash/card, by payment time) → signed
|
||||||
|
`shift_z_report` (totals/counts/window) → prints via the NEW generic
|
||||||
|
`PrinterDevice.printReport(title, lines)` (Rongta ESC/POS text) to a booth-receipt printer.
|
||||||
|
Print is best-effort — failure doesn't undo the signed close (`printed:false` returned).
|
||||||
|
- Routes (`routes/shift.ts`, cashier/operator/admin): GET /api/shift/current, POST open (409 if
|
||||||
|
open), POST close (409 if none). UI `ShiftControl` in the shell (non-readonly): Start/End + Z totals.
|
||||||
|
- Added `printReport` to the PrinterDevice interface + Rongta driver (reusable for receipts later).
|
||||||
|
- VERIFIED: open→double-open 409→payments (cash+card; one dated outside the window excluded)→close
|
||||||
|
totals (cash 500/card 250/3)→close-again 409→re-open ok; readonly 403; verifyChain ok. Full build 5/5.
|
||||||
|
- Updated [[shift]] (as-built).
|
||||||
|
|
||||||
|
## [2026-06-16] build | Capacity / FULL gate (occupancy fold + transient refuse)
|
||||||
|
- Occupancy = fold over the ledger (entries−exits per identity; `apps/server/src/occupancy.ts`),
|
||||||
|
`getOccupancy` → {count, capacity, free, full}. Capacity = single-row `site_config` table (admin,
|
||||||
|
null=uncapped); migration 0001 (additive, no prompt).
|
||||||
|
- FULL gate in the TRANSIENT entry flow: occupancy.full → refuse (no ticket/entry/open) + signed
|
||||||
|
anomaly. Permit entry NOT gated (subscribers admitted past transient-full; their maxConcurrent
|
||||||
|
still applies) — occupancy can read over-capacity by design.
|
||||||
|
- Routes (`routes/site.ts`): GET /api/occupancy + GET /api/site-config (any role), PUT
|
||||||
|
/api/site-config (admin; non-neg int or null). UI `SiteSettings`: live occupancy + FULL badge
|
||||||
|
(all), capacity editor (admin).
|
||||||
|
- VERIFIED: fill to cap=2 → 3rd transient refused (anomaly, no open); permit still admitted (occ 3/2,
|
||||||
|
free −1); exit frees a slot; routes RBAC (op can't set, −5→400, set/clear ok); verifyChain ok.
|
||||||
|
Full build 5/5. Physical FULL-sign relay output deferred.
|
||||||
|
- Updated [[capacity-occupancy]] (as-built).
|
||||||
|
|
||||||
|
## [2026-06-16] ingest | GEE-QR-ER80 QR access reader datasheet
|
||||||
|
- User has the reader; ingested `raw/GEE-QR-ER80 QR Code Access Control Reader.pdf`.
|
||||||
|
- CORRECTION: earlier guessed "ER80-EM" = a 125 kHz EM4100 prox-card reader. WRONG — the datasheet
|
||||||
|
shows **GEE-QR-ER80**, a **QR / DataMatrix / 1D barcode** optical access reader (optional ID/IC
|
||||||
|
card). It's the [[ticket-encoding|QR ticket]] scanner the design already needed, not a card reader.
|
||||||
|
- Specs: interfaces Wiegand 26/34 · RS-232 · RS-485 · USB · TCP/IP; 4–15 VDC <800 mA; 360°;
|
||||||
|
Windows + **Linux**; wiring VCC/GND/D0/D1/TX(R+)/RX(R-)/LED/BEEP. On hand: **`-Q-W`** (QR scanner;
|
||||||
|
Wiegand/RS-232/RS-485).
|
||||||
|
- Fit: host-side reader → a serial `ReaderDevice` adapter emitting `read` events → consumed by the
|
||||||
|
already-built exit flow + QR-permit path. Prefer RS-232/485 (serial) over Wiegand (Wiegand can't
|
||||||
|
carry variable-length QR; autonomy moot since [[dingtian-relay]] has no onboard ACL).
|
||||||
|
- New: source [[gee-qr-er80]] summary + entity [[gee-qr-er80]]. Updated [[ticket-encoding]],
|
||||||
|
[[entry-exit-readers]], [[index]].
|
||||||
|
- OPEN (blocks the adapter): the RS-232/485 **frame + baud** — is a QR scan an ASCII CR/LF string
|
||||||
|
(expected) or framed? Datasheet omits it; resolve via vendor docs or by observing the port.
|
||||||
|
|
||||||
|
## [2026-06-16] ingest+test | ER80 protocol = HTTP GET poll + JSON verdict (SDK)
|
||||||
|
- Hardware bring-up: configured the reader via the vendor Windows tool (server IP/port + "server
|
||||||
|
language"). Moved it to 10.0.10.7. It pings (source-pin must be 10.0.10.203 — trap recurs). No
|
||||||
|
beep on scans — initially looked like "not scanning."
|
||||||
|
- Found the QRCode SDK v1.6.5 (`QRCode_sdk - QRCode_v1_6_5/sdk/`). Protocol SETTLED, supersedes the
|
||||||
|
serial guess in [[gee-qr-er80]]: reader does **HTTP GET** `/qa/mcardsea.php?cardid&mjihao&cjihao&
|
||||||
|
status&time` on each scan; server replies **JSON** `{data:[{...,status,output}],code:0}`. Reply
|
||||||
|
`status` 1=valid(beep 2×)/0=invalid(beep 1×); `output` 0=Access/1=WG26/2=WG34; `time` syncs clock.
|
||||||
|
`status` low digit in the GET = direction (1=in/0=out).
|
||||||
|
- KEY: feedback/beep is decided by the SERVER REPLY, not locally → the "no beep" was my catch-all
|
||||||
|
replying plain "OK" not the JSON verdict, NOT a scan failure. Host-in-the-loop + SYNCHRONOUS.
|
||||||
|
- "Server language" (JSP/PHP/C#/ASP/CGI) only selects the URL PATH; transport is plain HTTP.
|
||||||
|
- New source [[qrcode-sdk]]; updated [[gee-qr-er80]] (protocol resolved, serial open-Qs dropped),
|
||||||
|
[[index]]. SDK kept in place (bulky+binaries), not copied to raw/.
|
||||||
|
- NEXT: backend route — parse GET, DECIDE (reuse permit/exit lookup), reply JSON verdict, emit on
|
||||||
|
read bus. Refactor read flows to RETURN an outcome so the reply can reflect accept/reject.
|
||||||
|
|
||||||
|
## [2026-06-16] build+fix | QR reader endpoint + ReadOutcome refactor; dev-DB migrate fix
|
||||||
|
- DB FIX: dev server crashed `no such table: lane_devices`. Cause: server `.env` DATABASE_URL points
|
||||||
|
at `apps/server/parking.sqlite` (the old dev DB I'd moved aside during the ledger split; new
|
||||||
|
migrations added since). Applied `drizzle-kit migrate` to that path → all 14 tables present. Fresh
|
||||||
|
DB → needs `seed-admin` + device re-assignment (empty, expected).
|
||||||
|
- REFACTOR: read flows now RETURN a `ReadOutcome {accepted,direction,reason}` (device-events.ts).
|
||||||
|
`ReadDispatcher.dispatch`, `ExitFlow.handleAt`, `PermitFlow.run` updated. A synchronous reader can
|
||||||
|
answer the device; fire-and-forget readers ignore it.
|
||||||
|
- ENDPOINT: `routes/qr-reader.ts` — `GET/POST /qa/mcardsea.php` (public; reader has no auth, on the
|
||||||
|
device subnet). Parses the SDK GET, dispatches the scan, replies the SDK verdict (status 1/0 →
|
||||||
|
beep 2×/1×, output 0, time-sync). Reader's lane keyed off device serial (cjihao) as lane_devices.id
|
||||||
|
for now.
|
||||||
|
- VERIFIED via inject: valid permit QR→status:1+open; re-scan→permit exit; unknown→status:0; reader
|
||||||
|
on barrier-less lane→status:0. Full build 5/5.
|
||||||
|
- Updated [[gee-qr-er80]] (endpoint as-built + hardware open items).
|
||||||
|
|
||||||
|
## [2026-06-16] test+fix | QR reader VERIFIED on hardware; path is .jsp not .php
|
||||||
|
- Ran a verbatim-vendor logger on :3000 (replies like mcardsea.php: status:0/output:2). Reader
|
||||||
|
**beeped** → it scans, sends, and acts on the reply. Earlier "no beep" = nothing was answering :3000.
|
||||||
|
- Real GET captured: `/qa/mcardsea.jsp?cardid=52020056&mjihao=1&cjihao=H05M2AFA&status=11&time=...`
|
||||||
|
from 10.0.10.7 (OEM = Fondvision, per referer).
|
||||||
|
- KEY FIX: the "server language" setting selects the URL EXTENSION — this unit is JSP → posts
|
||||||
|
**`.jsp`**, but our route was `.php` only (would 404 the reader). Route now registers
|
||||||
|
php/jsp/asp/aspx/cgi. Build green.
|
||||||
|
- Real serial **cjihao=H05M2AFA** = the lane key → assign reader as lane_devices.id="H05M2AFA".
|
||||||
|
Reader beeped on status:0 (invalid/1-beep); a matching permit/session → status:1 (2-beep accept).
|
||||||
|
- Updated [[gee-qr-er80]] (verified-on-hardware).
|
||||||
|
|
||||||
|
## [2026-06-16] feature | gee-qr-reader driver — assign by serial, resolve lane by config
|
||||||
|
- The QR reader is a push device; setup wizard always assigns a random-UUID id, so "id = serial"
|
||||||
|
isn't possible via the UI. Clean fix instead: new **`gee-qr-reader`** driver (reader category) with
|
||||||
|
a single `serial` config field. Admin assigns it in the wizard (UUID id) + types the serial.
|
||||||
|
- QR endpoint now resolves the lane by **matching `lane_devices.config.serial` to the scan's
|
||||||
|
`cjihao`** (was: row id == cjihao). `qrReaderRoutes(app, db, dispatcher)`. Unassigned serial →
|
||||||
|
no lane → status:0 (graceful).
|
||||||
|
- `tcpip-reader` flagged as the WRONG model for this device (host-connects-out stub).
|
||||||
|
- VERIFIED via inject through the real /api/setup/assign: assign {serial:"H05M2AFA"} → .jsp scan +
|
||||||
|
matching permit → status:1 + open; re-scan → exit; unknown card → status:0; unassigned serial →
|
||||||
|
status:0. Full build 5/5.
|
||||||
|
- Updated [[gee-qr-er80]] (assignment as-built).
|
||||||
|
|
||||||
|
## [2026-06-16] feature | stub-access driver (bench-test the flows without a relay)
|
||||||
|
- Live QR scan reached the real app (.jsp, serial resolved) but rejected: "reader not on an
|
||||||
|
access-equipped lane" — lane 1 had the reader but no access device. The dispatcher requires an
|
||||||
|
access device on the same lane.
|
||||||
|
- Added a no-op **`stub-access`** driver (access category, no config): `pulseOpen` just logs, no
|
||||||
|
device I/O — stands in on a lane to test QR→permit→accept (incl. the beep) without the
|
||||||
|
[[dingtian-relay]] connected. NOT for production. Registered in the catalog.
|
||||||
|
- To get a live accept: assign Stub barrier to the reader's lane + a permit whose QR = the scanned
|
||||||
|
code → status:1 (2-beep) + logged pulseOpen.
|
||||||
|
|
||||||
|
## [2026-06-16] fix | QR reader 10s beep delay — reply must Connection: close
|
||||||
|
- Live accept worked (status:1, pulseOpen, 2 beeps) but the beep came ~10 s LATE. Server responded
|
||||||
|
in 14.7 ms; user confirmed request is fast, only the beep lags → delay is the READER, not us.
|
||||||
|
- Cause: reader sends `Connection: keep-alive` but only ACTS on the verdict once the socket CLOSES;
|
||||||
|
Fastify kept it alive → reader waited out a ~10 s keep-alive timeout. Every vendor demo replies
|
||||||
|
`Connection: close` + shuts the socket.
|
||||||
|
- Fix: endpoint sets `reply.header("connection","close")`. Verified the header is now sent.
|
||||||
|
- Updated [[gee-qr-er80]] (⚠️ Connection: close requirement).
|
||||||
|
|||||||
Binary file not shown.
@@ -0,0 +1,37 @@
|
|||||||
|
---
|
||||||
|
type: source
|
||||||
|
tags: [parking, hardware, readers, qr, datasheet]
|
||||||
|
sources: [gee-qr-er80]
|
||||||
|
updated: 2026-06-16
|
||||||
|
---
|
||||||
|
|
||||||
|
# Source: GEE-QR-ER80 QR Code Access Control Reader (datasheet)
|
||||||
|
|
||||||
|
Vendor datasheet (GEE NFC LIMITED, ©2007–2019) for the **GEE-QR-ER80** — a static
|
||||||
|
**QR-code access-control reader**, optional ID/IC card. The reader the project has
|
||||||
|
on hand for the [[ticket-encoding|QR ticket]] path. Raw:
|
||||||
|
`raw/GEE-QR-ER80 QR Code Access Control Reader.pdf` (3 pages). Entity: [[gee-qr-er80]].
|
||||||
|
|
||||||
|
## Key takeaways
|
||||||
|
|
||||||
|
- **Optical scanner**, not a prox-card reader: reads **QR, DataMatrix, 1D barcode** (static).
|
||||||
|
Optional add-ons for **IC card UID / ID card**.
|
||||||
|
- **Multi-interface:** **Wiegand 26/34, RS-232, RS-485, USB, TCP/IP** — selectable by variant.
|
||||||
|
- **Power:** 4–15 VDC, < 800 mA. **Read direction:** 360°. Built-in scanner LED.
|
||||||
|
- **OS:** Windows XP/7/8/10 **and Linux** (explicit) — fits the [[disk-os-hardening|Linux appliance]].
|
||||||
|
- **Wiring (Wiegand/RS-232/485 variant):** VCC(+12V), GND, **D0/D1** (Wiegand), **TX/R+ , RX/R-**
|
||||||
|
(RS-232 / RS-485), plus **LED** and **BEEP** control lines (host can drive feedback).
|
||||||
|
- **Order code** `GEE-QR-ER80-<scanner>-<interface>`: `Q`=QR scanner / `D`=ID reader / `C`=IC reader;
|
||||||
|
`W`=WG·RS232·RS485 / `U`=USB / `T`=RJ45 (TCP/IP). **On hand: `-Q-W`** (QR scanner; Wiegand/RS-232/RS-485).
|
||||||
|
|
||||||
|
## Section map
|
||||||
|
|
||||||
|
- p1 — overview, physical + feature table (interfaces, power, read direction).
|
||||||
|
- p2 — supported types (QR/DM/1D + optional IC/ID), OS, environment; **wire definition** (pin table).
|
||||||
|
- p3 — order-code breakdown, applications (access control / vacation rentals / time attendance).
|
||||||
|
|
||||||
|
## Not in this datasheet (open)
|
||||||
|
|
||||||
|
- The **RS-232/RS-485 data protocol**: baud rate, frame format, and whether a QR scan is emitted as
|
||||||
|
an **ASCII string** (expected) vs. some framed protocol. Decides the host-side adapter — see
|
||||||
|
[[gee-qr-er80]] open questions. Resolve by vendor docs or by observing the port on a scan.
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
---
|
||||||
|
type: source
|
||||||
|
tags: [parking, hardware, readers, qr, protocol, sdk]
|
||||||
|
sources: [qrcode-sdk]
|
||||||
|
updated: 2026-06-16
|
||||||
|
---
|
||||||
|
|
||||||
|
# Source: QRCode SDK v1.6.5 (GEE/Dingtian QR reader)
|
||||||
|
|
||||||
|
Vendor SDK for the QR access reader ([[gee-qr-er80]]; also branded Dingtian). Defines the
|
||||||
|
reader↔server **HTTP protocol** — the missing piece the datasheet omitted. Files at
|
||||||
|
`QRCode_sdk - QRCode_v1_6_5/sdk/` (config tool `QRCode_v1_6_5.exe`, demos in C#/PHP/VC++, protocol
|
||||||
|
docs `readme.txt`, `qrcode_HTTP_GET.txt`, `VC++/how to.txt`). **Not copied into `raw/`** — bulky +
|
||||||
|
binaries; this summary is the faithful capture. Entity: [[gee-qr-er80]].
|
||||||
|
|
||||||
|
## The protocol — HTTP GET poll, server replies the verdict
|
||||||
|
|
||||||
|
The reader is configured (via the Windows tool) with a **server IP/port + "server language"**
|
||||||
|
(JSP/PHP/C#/ASP/CGI — this only selects the URL path, e.g. `/qa/mcardsea.php`; transport is plain
|
||||||
|
HTTP either way). **On each scan** the reader sends:
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /qa/mcardsea.php?cardid=445D2C&mjihao=1&cjihao=HW256097&status=11&time=1540402036 HTTP/1.0
|
||||||
|
```
|
||||||
|
|
||||||
|
| Param | Meaning |
|
||||||
|
| --- | --- |
|
||||||
|
| `cardid` | **the scanned QR/barcode data** (or card id) |
|
||||||
|
| `mjihao` | device id (machine number) |
|
||||||
|
| `cjihao` | device serial number |
|
||||||
|
| `status` | **2 chars**: high = valid `1`/invalid `0` (reader's own pre-check), low = direction **`1`=in / `0`=out**. A 1-char status = fail. |
|
||||||
|
| `time` | UTC time |
|
||||||
|
|
||||||
|
**Server → reader reply (JSON) — this is the access DECISION and drives the beep + output:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"data":[{"cardid":"<echo>","cjihao":0,"mjihao":1,"status":1,"time":"<utc>","output":2}],"code":0,"message":""}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Reply field | Meaning (from the C# demo comments) |
|
||||||
|
| --- | --- |
|
||||||
|
| `status` | **`1` = valid → buzzer 2×; `0` = invalid → buzzer 1×** |
|
||||||
|
| `output` | **`0` = Access, `1` = WG26, `2` = WG34** — output line/format driven on a valid read |
|
||||||
|
| `time` | UTC — **can sync the device clock** |
|
||||||
|
| `code` | `0` = success |
|
||||||
|
|
||||||
|
> **Implication (explains the "no beep"):** the reader's beep/accept is decided by the **server's
|
||||||
|
> reply**, not locally. A non-JSON / missing reply ⇒ no valid feedback ⇒ no beep, even though the
|
||||||
|
> scan succeeded. So "no beep" ≠ "didn't scan" — it means the server didn't answer with the verdict.
|
||||||
|
|
||||||
|
## Integration consequence
|
||||||
|
|
||||||
|
This is **host-in-the-loop, synchronous**: the GET *is* the access query; our JSON reply *is* the
|
||||||
|
decision. So the backend endpoint must **decide (valid/invalid + direction) and reply** — richer
|
||||||
|
than a fire-and-forget read. Direction comes from the `status` low digit. See [[gee-qr-er80]] +
|
||||||
|
[[device-input-flow]].
|
||||||
|
|
||||||
|
## Defaults / misc
|
||||||
|
|
||||||
|
- Default device IP `192.168.1.99` (`readme.txt`).
|
||||||
|
- Demos: PHP `qa/mcardsea.php` (minimal echo, status 0), C# raw-socket server on :80 (full parse),
|
||||||
|
VC++ raw HTTP example. All show the same GET-in / JSON-out contract.
|
||||||
Reference in New Issue
Block a user