dd0f6e483a
Two genuinely-offline QR readers showed GREEN: the adapter's healthCheck was
hardcoded to { ready, "stub" } and never probed. These are PUSH devices (scan →
GET our backend, resolve by serial) with NO TCP port, so a connect probe has
nothing to hit — the stub "solved" that by lying. False-healthy is the worst
failure for a status bar.
- Optional reader IP field (monitor-ONLY; scans still resolve by serial,
operation unchanged).
- Unprivileged ICMP ping (drivers/icmp.ts): shells /bin/ping -c1, exit-0 = reply.
No native dep, no CAP_NET_RAW. docker-compose.prod.yml sets
net.ipv4.ping_group_range so it works for the non-root container user.
- healthCheck: replies → ready, no reply → offline, NO IP → degraded
("set IP to monitor") — never a false green.
Verified on hardware: readers (10.0.10.7/.8) answer ICMP on the device VLAN;
UI Test connection → "● ready — ping 10.0.10.7". Tests: reader.test.ts (4).
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
110 lines
4.6 KiB
TypeScript
110 lines
4.6 KiB
TypeScript
import type { DeviceHealth, ReaderDevice, ReaderEvent } from "../interfaces.js";
|
|
import type { DeviceConfig, ReaderDriver } from "../registry.js";
|
|
import { hostField, portField, stubLog } from "./common.js";
|
|
import { icmpPing } from "./icmp.js";
|
|
|
|
// Reader drivers (RF / optical). Two integration paths: Wiegand reads reach the
|
|
// access controller directly (autonomous); TCP-IP readers are seen host-side.
|
|
// See wiki/concepts/entry-exit-readers.md. STUBS only.
|
|
|
|
class StubReader implements ReaderDevice {
|
|
#cb: ((r: ReaderEvent) => void) | null = null;
|
|
constructor(
|
|
readonly driverId: string,
|
|
protected readonly config: DeviceConfig,
|
|
) {}
|
|
async connect(): Promise<void> {
|
|
stubLog(this.driverId, "connect");
|
|
}
|
|
async disconnect(): Promise<void> {
|
|
stubLog(this.driverId, "disconnect");
|
|
}
|
|
/**
|
|
* Liveness. These readers PUSH (scan → GET our backend) and expose no TCP port, so
|
|
* there's nothing to connect-probe. If the admin gave the reader's IP we ICMP-ping
|
|
* it (powered + on-network); a reply → ready, no reply → offline. With NO IP we
|
|
* report `degraded` ("set IP to monitor") rather than a false `ready` — a push
|
|
* device that's silent is indistinguishable from a dead one, so claiming `ready`
|
|
* unconditionally (the old behaviour) hid offline readers behind a green dot.
|
|
*/
|
|
async healthCheck(): Promise<DeviceHealth> {
|
|
const host = this.config.host ? String(this.config.host) : "";
|
|
if (!host) {
|
|
return { status: "degraded", detail: "push device — set IP to monitor" };
|
|
}
|
|
const alive = await icmpPing(host);
|
|
return alive
|
|
? { status: "ready", detail: `ping ${host}` }
|
|
: { status: "offline", detail: `no ping reply from ${host}` };
|
|
}
|
|
onRead(cb: (r: ReaderEvent) => void): void {
|
|
this.#cb = cb;
|
|
stubLog(this.driverId, "onRead handler registered");
|
|
}
|
|
/** Test hook for stubs — real drivers emit from hardware events. */
|
|
protected emit(r: ReaderEvent): void {
|
|
this.#cb?.(r);
|
|
}
|
|
}
|
|
|
|
export const wiegandReaderDriver: ReaderDriver = {
|
|
id: "wiegand-reader",
|
|
category: "reader",
|
|
label: "Wiegand reader (into controller)",
|
|
description:
|
|
"RF/optical reader wired Wiegand 26/34 into the access controller's reader port. Autonomous offline decisions.",
|
|
transports: ["wiegand"],
|
|
configFields: [
|
|
{ key: "door", label: "Controller reader port / door", type: "number", required: true, default: 1 },
|
|
{ key: "format", label: "Wiegand format", type: "select", required: true, default: "26", options: [
|
|
{ value: "26", label: "Wiegand 26" },
|
|
{ value: "34", label: "Wiegand 34" },
|
|
] },
|
|
],
|
|
create: (c) => new StubReader("wiegand-reader", c),
|
|
};
|
|
|
|
export const tcpipReaderDriver: ReaderDriver = {
|
|
id: "tcpip-reader",
|
|
category: "reader",
|
|
label: "TCP/IP reader (host-side)",
|
|
description:
|
|
"Network RF/optical reader seen only by the host; host decides and commands the relay.",
|
|
transports: ["tcp-ip"],
|
|
configFields: [hostField, portField(9000)],
|
|
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.",
|
|
},
|
|
{
|
|
// OPTIONAL: the reader pushes by serial (operation needs no IP), but giving its
|
|
// IP lets the status monitor ICMP-ping it for a real online/offline dot instead
|
|
// of an always-green stub. Leave blank to skip monitoring (shows "set IP").
|
|
...hostField,
|
|
required: false,
|
|
help: "Optional: the reader's IP, used ONLY to monitor it (ping). Scans still resolve by serial. Leave blank to skip liveness monitoring.",
|
|
},
|
|
],
|
|
create: (c) => new StubReader("gee-qr-reader", c),
|
|
};
|