refactor(setup): unify controller I/O — event-driven relays[] + generic inputs[]
The controller new/edit modal hardcoded both its outputs and its inputs, so an
operator could neither add a generic event-driven relay nor a free-standing input
(e.g. a second radar at the exit). This unifies both into symmetric, first-class
lists. Behaviour for existing booths is unchanged (back-compat, no DB migration).
Outputs — one event→action relays[] list:
- A relay is "when EVENT X happens, do its action": entry/exit/both pulse a
barrier; a new `radarAlert` event drives a non-barrier alert lamp (blink while
its trigger input is active, SOLID once the camera confirms a car).
- Dropped the separate config.buttonLight block — the lamp is just a relays[] row
with direction:"radarAlert" (triggerInput + blink cadence). `alertRelaysOf()`
replaces `buttonLightOf()`; ButtonLightController keeps its proven 3-state
machine (serialized UDP, fail-OFF, hot-reload), now keyed per controllerId:relay
so several alert lamps on one controller run independently. Every barrier
resolver skips radarAlert rows (no auto-open; barrier-not-a-door intact).
Inputs — one first-class config.inputs[] list (the twin of relays[]):
- Each row is { input, role, relay?, kind?, activeLow?, cooldownSec? } with a
"+ Add input" button. role ∈ button | presence | alertTrigger; button/presence
name the relay they serve. An exit radar is just another presence row.
- Keystone `inputsOf(row)`: returns config.inputs[] or SYNTHESIZES it from the
legacy relays[].button/presenceInput/... fields, so relayForButton /
relayForPresence resolve identically from either shape — zero-downtime, no
migration. entry-flow.ts is unchanged (resolves through the same functions).
- Fixed a latent bug this exposed: the alert lamp's camera lock was hardcoded to
the ENTRY camera. Added relays[].lockLane ("entry"|"exit", default entry); the
lamp now locks on its own lane's camera, so an exit radar's lamp tracks the exit
camera. button-light tracks both #entryBusy/#exitBusy.
- Driver: extracted activeLowFrom(config) — merges inputs[] activeLow, legacy
relays[].presenceActiveLow, and the inputActiveLow escape hatch.
UI: the relay dropdown gained a "Radar alert" option (reveals trigger/lock/blink
inputs); InputEditor is rewritten to a generic list (role select folds loop/radar);
i18n sq+en kept at type-parity.
Tests: new device-resolve.test.ts (inputs[] resolution + legacy fallback identical
+ exit-radar resolves to the exit relay); button-light gains a two-independent-
alert-relays case and an exit-lamp lockLane case; access-dingtian gains
activeLowFrom cases. Full workspace build/lint/test green (i18n parity included).
Wiki + memory updated (button-light-indicator, entry-double-press, dingtian-relay).
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
+278
-187
@@ -15,13 +15,15 @@ import {
|
||||
type PrintTestResult,
|
||||
type Assignment,
|
||||
type BackendIpCandidate,
|
||||
type ButtonLightSpec,
|
||||
type Catalog,
|
||||
type CatalogEntry,
|
||||
type DeviceCategory,
|
||||
type DeviceConfig,
|
||||
type Direction,
|
||||
type DiscoveredDevice,
|
||||
type InputRole,
|
||||
type InputSpec,
|
||||
type RelayEvent,
|
||||
type RelaySpec,
|
||||
type TestResult,
|
||||
} from "./api.js";
|
||||
@@ -49,13 +51,63 @@ const BOUND: { key: DeviceCategory; titleKey: string; nounKey: string }[] = [
|
||||
{ key: "printer", titleKey: "setup.catPrinters", nounKey: "setup.nounPrinter" },
|
||||
];
|
||||
|
||||
// Translated direction label (relay direction / inherited binding).
|
||||
const DIRECTION_KEYS: Record<Direction, string> = {
|
||||
// Translated relay-event label (barrier direction, inherited binding, or alert).
|
||||
const DIRECTION_KEYS: Record<RelayEvent, string> = {
|
||||
entry: "setup.dirEntry",
|
||||
exit: "setup.dirExit",
|
||||
both: "setup.dirBoth",
|
||||
radarAlert: "setup.eventRadarAlert",
|
||||
};
|
||||
|
||||
// The input-role dropdown folds presence `kind` into the choice: one select offers Button,
|
||||
// Presence (loop), Presence (radar), Alert trigger. Each maps to a {role, kind} pair.
|
||||
type InputChoice = "button" | "presenceLoop" | "presenceRadar" | "alertTrigger";
|
||||
const INPUT_CHOICE_KEYS: Record<InputChoice, string> = {
|
||||
button: "setup.roleButton",
|
||||
presenceLoop: "setup.rolePresenceLoop",
|
||||
presenceRadar: "setup.rolePresenceRadar",
|
||||
alertTrigger: "setup.roleAlertTrigger",
|
||||
};
|
||||
function choiceOf(i: InputSpec): InputChoice {
|
||||
if (i.role === "button") return "button";
|
||||
if (i.role === "alertTrigger") return "alertTrigger";
|
||||
return i.kind === "radar" ? "presenceRadar" : "presenceLoop";
|
||||
}
|
||||
function applyChoice(choice: InputChoice): { role: InputRole; kind?: "loop" | "radar" } {
|
||||
switch (choice) {
|
||||
case "button":
|
||||
return { role: "button" };
|
||||
case "alertTrigger":
|
||||
return { role: "alertTrigger" };
|
||||
case "presenceLoop":
|
||||
return { role: "presence", kind: "loop" };
|
||||
case "presenceRadar":
|
||||
return { role: "presence", kind: "radar" };
|
||||
}
|
||||
}
|
||||
|
||||
/** Synthesize an inputs[] list from the LEGACY per-relay button/presence fields, so an
|
||||
* existing controller (saved before inputs[]) opens with its inputs populated. Mirrors the
|
||||
* server's `inputsOf()` back-compat fold. */
|
||||
function synthInputsFromRelays(relays: RelaySpec[]): InputSpec[] {
|
||||
const out: InputSpec[] = [];
|
||||
for (const r of relays) {
|
||||
if (typeof r.button === "number") {
|
||||
out.push({ input: r.button, role: "button", relay: r.relay, cooldownSec: r.entryCooldownSec });
|
||||
}
|
||||
if (typeof r.presenceInput === "number") {
|
||||
out.push({
|
||||
input: r.presenceInput,
|
||||
role: "presence",
|
||||
relay: r.relay,
|
||||
kind: r.presenceKind ?? "loop",
|
||||
activeLow: r.presenceActiveLow,
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function SetupWizard() {
|
||||
const { t } = useTranslation();
|
||||
const [catalog, setCatalog] = useState<Catalog | null>(null);
|
||||
@@ -82,7 +134,7 @@ export function SetupWizard() {
|
||||
return (
|
||||
<section className="px-4 py-6">
|
||||
<h2 className="mb-1 text-h4 font-semibold text-term-text">{t("setup.title")}</h2>
|
||||
<p className="hint mb-4 max-w-prose">{t("setup.intro")}</p>
|
||||
<p className="hint mb-4 max-w">{t("setup.intro")}</p>
|
||||
|
||||
<CategorySection
|
||||
category={CONTROLLER.key}
|
||||
@@ -273,24 +325,25 @@ function DeviceSummary({ assignment, controllers }: { assignment: Assignment; co
|
||||
if (assignment.category === "access") {
|
||||
const relays = Array.isArray(cfg.relays) ? (cfg.relays as RelaySpec[]) : [];
|
||||
if (relays.length === 0) return <em className="text-term-amber">{t("setup.noRelaysSet")}</em>;
|
||||
const bl = cfg.buttonLight as ButtonLightSpec | undefined;
|
||||
// Effective inputs: config.inputs[] if present, else synthesized from legacy relay fields.
|
||||
const inputs = Array.isArray(cfg.inputs) ? (cfg.inputs as InputSpec[]) : synthInputsFromRelays(relays);
|
||||
return (
|
||||
<span className="flex flex-wrap gap-1.5">
|
||||
{relays.map((r) => {
|
||||
const presence = r.presenceInput
|
||||
? `·${r.presenceKind === "radar" ? "radar" : "loop"}${r.presenceInput}`
|
||||
: "";
|
||||
return (
|
||||
<DirectionBadge
|
||||
key={r.relay}
|
||||
direction={r.direction}
|
||||
label={`R${r.relay}${r.button ? `·btn${r.button}` : ""}${presence}`}
|
||||
/>
|
||||
);
|
||||
// Alert relay: trigger input + lock lane. Barrier: its button + presence inputs.
|
||||
let wiring = "";
|
||||
if (r.direction === "radarAlert") {
|
||||
if (r.triggerInput) wiring += `·trig${r.triggerInput}`;
|
||||
if (r.lockLane === "exit") wiring += "·lockExit";
|
||||
} else {
|
||||
const served = inputs.filter((x) => x.relay === r.relay);
|
||||
const btn = served.find((x) => x.role === "button");
|
||||
const pres = served.find((x) => x.role === "presence");
|
||||
if (btn) wiring += `·btn${btn.input}`;
|
||||
if (pres) wiring += `·${pres.kind === "radar" ? "radar" : "loop"}${pres.input}`;
|
||||
}
|
||||
return <DirectionBadge key={r.relay} direction={r.direction} label={`R${r.relay}${wiring}`} />;
|
||||
})}
|
||||
{bl?.relay != null && (
|
||||
<DirectionBadge direction="both" label={`lamp·R${bl.relay}`} />
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -365,14 +418,19 @@ function DeviceForm({
|
||||
}
|
||||
return out;
|
||||
});
|
||||
// Controllers: the relay map (which relay = entry/exit/both, + entry button terminal).
|
||||
// Controllers: the unified relay map. Each relay reacts to an EVENT — entry/exit/both
|
||||
// (pulse a barrier) or radarAlert (drive an alert lamp). Alert relays carry a trigger
|
||||
// input + blink cadence; barriers carry no input wiring (that lives in `inputs` below).
|
||||
const [relays, setRelays] = useState<RelaySpec[]>(() =>
|
||||
Array.isArray(editCfg?.relays) ? (editCfg!.relays as RelaySpec[]) : [{ relay: 1, direction: "both" }],
|
||||
);
|
||||
// Controller-level button-lamp output (a spare relay), driven by the radar + camera.
|
||||
const [buttonLight, setButtonLight] = useState<ButtonLightSpec | null>(() => {
|
||||
const bl = editCfg?.buttonLight as ButtonLightSpec | undefined;
|
||||
return bl && typeof bl.relay === "number" ? bl : null;
|
||||
// Controller INPUTS — a first-class list (button / presence / alertTrigger), each naming
|
||||
// the relay it serves. Seed from config.inputs[] if present, else SYNTHESIZE from the
|
||||
// legacy per-relay button/presence fields so an existing controller opens populated.
|
||||
const [inputs, setInputs] = useState<InputSpec[]>(() => {
|
||||
const stored = editCfg?.inputs;
|
||||
if (Array.isArray(stored) && stored.length > 0) return stored as InputSpec[];
|
||||
return synthInputsFromRelays(Array.isArray(editCfg?.relays) ? (editCfg!.relays as RelaySpec[]) : []);
|
||||
});
|
||||
// Bound devices: which controller + relay this device sits at.
|
||||
const [controllerId, setControllerId] = useState<string>(
|
||||
@@ -487,23 +545,31 @@ function DeviceForm({
|
||||
function mergedConfig(): DeviceConfig {
|
||||
const out: DeviceConfig = { ...mergedScalarConfig() };
|
||||
if (isController) {
|
||||
out.relays = relays.map((r) => ({
|
||||
relay: r.relay,
|
||||
direction: r.direction,
|
||||
...(r.button ? { button: r.button } : {}),
|
||||
...(r.presenceInput ? { presenceInput: r.presenceInput } : {}),
|
||||
...(r.presenceInput && r.presenceKind ? { presenceKind: r.presenceKind } : {}),
|
||||
...(r.presenceInput && r.presenceActiveLow ? { presenceActiveLow: true } : {}),
|
||||
...(r.entryCooldownSec ? { entryCooldownSec: r.entryCooldownSec } : {}),
|
||||
}));
|
||||
// Button-lamp output (a spare relay), persisted only when a relay is chosen.
|
||||
if (buttonLight && buttonLight.relay) {
|
||||
out.buttonLight = {
|
||||
relay: buttonLight.relay,
|
||||
...(buttonLight.blinkOnMs ? { blinkOnMs: buttonLight.blinkOnMs } : {}),
|
||||
...(buttonLight.blinkOffMs ? { blinkOffMs: buttonLight.blinkOffMs } : {}),
|
||||
};
|
||||
}
|
||||
// Relays carry ONLY the event (+ alert fields). Input wiring lives in out.inputs.
|
||||
out.relays = relays.map((r) =>
|
||||
r.direction === "radarAlert"
|
||||
? {
|
||||
// Alert lamp: trigger input + lock lane + blink cadence.
|
||||
relay: r.relay,
|
||||
direction: r.direction,
|
||||
...(r.triggerInput ? { triggerInput: r.triggerInput } : {}),
|
||||
...(r.lockLane && r.lockLane !== "entry" ? { lockLane: r.lockLane } : {}),
|
||||
...(r.blinkOnMs ? { blinkOnMs: r.blinkOnMs } : {}),
|
||||
...(r.blinkOffMs ? { blinkOffMs: r.blinkOffMs } : {}),
|
||||
}
|
||||
: { relay: r.relay, direction: r.direction },
|
||||
);
|
||||
// Inputs: a button/presence row needs its relay; alertTrigger may be standalone.
|
||||
out.inputs = inputs
|
||||
.filter((i) => typeof i.input === "number" && i.input > 0)
|
||||
.map((i) => ({
|
||||
input: i.input,
|
||||
role: i.role,
|
||||
...(typeof i.relay === "number" ? { relay: i.relay } : {}),
|
||||
...(i.role === "presence" && i.kind ? { kind: i.kind } : {}),
|
||||
...(i.role === "presence" && i.activeLow ? { activeLow: true } : {}),
|
||||
...(i.role === "button" && i.cooldownSec ? { cooldownSec: i.cooldownSec } : {}),
|
||||
}));
|
||||
} else if (controllerId && boundRelay !== "") {
|
||||
out.controllerId = controllerId;
|
||||
out.relay = boundRelay;
|
||||
@@ -749,13 +815,11 @@ function DeviceForm({
|
||||
),
|
||||
)}
|
||||
|
||||
{/* CONTROLLER — OUTPUTS: the relays (barriers + the button lamp) + pulse time. */}
|
||||
{/* CONTROLLER — OUTPUTS: the unified relays (barriers pulse, alert relays blink). */}
|
||||
{isController && (
|
||||
<OutputEditor
|
||||
relays={relays}
|
||||
onChange={setRelays}
|
||||
buttonLight={buttonLight}
|
||||
onButtonLightChange={setButtonLight}
|
||||
pulseMs={config.pulseMs as number | undefined}
|
||||
onPulseMsChange={(v) => {
|
||||
setConfig((c) => ({ ...c, pulseMs: v }));
|
||||
@@ -764,12 +828,13 @@ function DeviceForm({
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* CONTROLLER — INPUTS: the terminals (entry button, presence/radar), each bound
|
||||
to the output relay it drives. Separated from the outputs above. */}
|
||||
{/* CONTROLLER — INPUTS: a generic terminal list (button / presence / alert trigger),
|
||||
each naming the relay it serves. Separated from the outputs above. */}
|
||||
{isController && (
|
||||
<InputEditor
|
||||
inputs={inputs}
|
||||
onChange={setInputs}
|
||||
relays={relays}
|
||||
onChange={setRelays}
|
||||
inputsIdleHigh={config.inputRestingHigh as boolean | undefined}
|
||||
onInputsIdleHighChange={(v) => {
|
||||
setConfig((c) => ({ ...c, inputRestingHigh: v }));
|
||||
@@ -1008,24 +1073,21 @@ function DeviceForm({
|
||||
}
|
||||
|
||||
// ── Controller OUTPUTS (relays) ────────────────────────────────────────────
|
||||
// A relay is an OUTPUT: it opens a barrier (or drives the button lamp). This section
|
||||
// owns relay number + direction, the pulse-open time (relay hold ms), and the lamp
|
||||
// relay. The INPUT terminals wired to these relays live in InputEditor below — the two
|
||||
// are deliberately separated (a controller's inputs and outputs are distinct things).
|
||||
// A relay is an OUTPUT reacting to an EVENT: entry/exit/both PULSE a barrier; radarAlert
|
||||
// BLINKS an indicator lamp (and a camera-confirmed car locks it solid). This section owns
|
||||
// the relay number + event, the pulse-open hold time (barriers), and — for alert relays —
|
||||
// the trigger input + blink cadence. The barrier INPUT terminals (entry button, presence)
|
||||
// live in InputEditor below; the two are deliberately separated.
|
||||
|
||||
/** Relays = outputs (barriers + lamp) + the pulse-open hold time. */
|
||||
/** Relays = the unified event→action outputs + the pulse-open hold time. */
|
||||
function OutputEditor({
|
||||
relays,
|
||||
onChange,
|
||||
buttonLight,
|
||||
onButtonLightChange,
|
||||
pulseMs,
|
||||
onPulseMsChange,
|
||||
}: {
|
||||
relays: RelaySpec[];
|
||||
onChange: (r: RelaySpec[]) => void;
|
||||
buttonLight: ButtonLightSpec | null;
|
||||
onButtonLightChange: (v: ButtonLightSpec | null) => void;
|
||||
pulseMs: number | undefined;
|
||||
onPulseMsChange: (v: number) => void;
|
||||
}) {
|
||||
@@ -1040,7 +1102,6 @@ function OutputEditor({
|
||||
function remove(i: number) {
|
||||
onChange(relays.filter((_, idx) => idx !== i));
|
||||
}
|
||||
const barrierRelays = new Set(relays.map((r) => r.relay));
|
||||
|
||||
return (
|
||||
<div className="my-2 rounded-term border border-term-border bg-term-bg p-2">
|
||||
@@ -1060,7 +1121,8 @@ function OutputEditor({
|
||||
/>
|
||||
</label>
|
||||
|
||||
{/* Barrier relays: number + direction. (Input terminals are in the Inputs section.) */}
|
||||
{/* Each relay: number + event. radarAlert reveals its trigger input + blink cadence;
|
||||
barriers pulse (their button/presence terminals are in the Inputs section). */}
|
||||
{relays.map((r, i) => (
|
||||
<div key={i} className="my-1 flex flex-wrap items-center gap-2">
|
||||
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted">
|
||||
@@ -1073,13 +1135,68 @@ function OutputEditor({
|
||||
onChange={(e) => update(i, { relay: Number(e.target.value) })}
|
||||
/>
|
||||
</label>
|
||||
<select className="select input-sm w-auto" value={r.direction} onChange={(e) => update(i, { direction: e.target.value as Direction })}>
|
||||
{(["entry", "exit", "both"] as Direction[]).map((d) => (
|
||||
<select
|
||||
className="select input-sm w-auto"
|
||||
value={r.direction}
|
||||
onChange={(e) => update(i, { direction: e.target.value as RelayEvent })}
|
||||
>
|
||||
{(["entry", "exit", "both", "radarAlert"] as RelayEvent[]).map((d) => (
|
||||
<option key={d} value={d}>
|
||||
{t(DIRECTION_KEYS[d])}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{/* Alert relay: which input fires the blink + the blink cadence. */}
|
||||
{r.direction === "radarAlert" && (
|
||||
<>
|
||||
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted" title={t("setup.triggerInputHint")}>
|
||||
{t("setup.triggerInput")}
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={r.triggerInput ?? ""}
|
||||
placeholder="—"
|
||||
className="input input-sm w-16"
|
||||
onChange={(e) => update(i, { triggerInput: e.target.value === "" ? undefined : Number(e.target.value) })}
|
||||
/>
|
||||
</label>
|
||||
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted" title={t("setup.lockLaneHint")}>
|
||||
{t("setup.lockLane")}
|
||||
<select
|
||||
className="select input-sm w-auto"
|
||||
value={r.lockLane ?? "entry"}
|
||||
onChange={(e) => update(i, { lockLane: e.target.value as "entry" | "exit" })}
|
||||
>
|
||||
<option value="entry">{t("setup.lockLaneEntry")}</option>
|
||||
<option value="exit">{t("setup.lockLaneExit")}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted">
|
||||
{t("setup.blinkOnMs")}
|
||||
<input
|
||||
type="number"
|
||||
min={50}
|
||||
value={r.blinkOnMs ?? ""}
|
||||
placeholder="500"
|
||||
className="input input-sm w-20"
|
||||
onChange={(e) => update(i, { blinkOnMs: e.target.value === "" ? undefined : Number(e.target.value) })}
|
||||
/>
|
||||
</label>
|
||||
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted">
|
||||
{t("setup.blinkOffMs")}
|
||||
<input
|
||||
type="number"
|
||||
min={50}
|
||||
value={r.blinkOffMs ?? ""}
|
||||
placeholder="500"
|
||||
className="input input-sm w-20"
|
||||
onChange={(e) => update(i, { blinkOffMs: e.target.value === "" ? undefined : Number(e.target.value) })}
|
||||
/>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
|
||||
{relays.length > 1 && (
|
||||
<button type="button" className="btn btn-ghost btn-sm" onClick={() => remove(i)}>
|
||||
✕
|
||||
@@ -1090,92 +1207,47 @@ function OutputEditor({
|
||||
<button type="button" className="btn btn-sm mt-1" onClick={add}>
|
||||
{t("setup.addRelay")}
|
||||
</button>
|
||||
|
||||
{/* Button-lamp output (a spare relay) — an OUTPUT, so it lives here. Driven by the
|
||||
radar + camera (blink = radar-only, solid = car confirmed, off otherwise). */}
|
||||
<div className="mt-3 flex flex-wrap items-center gap-3 border-t border-term-border pt-2">
|
||||
<span className="text-[12px] text-term-muted" title={t("setup.buttonLightHint")}>
|
||||
{t("setup.buttonLight")}
|
||||
</span>
|
||||
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted">
|
||||
{t("setup.buttonLightRelay")}
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={buttonLight?.relay ?? ""}
|
||||
placeholder="—"
|
||||
className="input input-sm w-16"
|
||||
onChange={(e) =>
|
||||
onButtonLightChange(e.target.value === "" ? null : { ...buttonLight, relay: Number(e.target.value) })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
{buttonLight?.relay != null && barrierRelays.has(buttonLight.relay) && (
|
||||
<span className="text-[11px] text-term-amber">{t("setup.buttonLightBarrierWarn")}</span>
|
||||
)}
|
||||
{buttonLight?.relay != null && (
|
||||
<>
|
||||
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted">
|
||||
{t("setup.blinkOnMs")}
|
||||
<input
|
||||
type="number"
|
||||
min={50}
|
||||
value={buttonLight.blinkOnMs ?? ""}
|
||||
placeholder="500"
|
||||
className="input input-sm w-20"
|
||||
onChange={(e) =>
|
||||
onButtonLightChange({ ...buttonLight, blinkOnMs: e.target.value === "" ? undefined : Number(e.target.value) })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted">
|
||||
{t("setup.blinkOffMs")}
|
||||
<input
|
||||
type="number"
|
||||
min={50}
|
||||
value={buttonLight.blinkOffMs ?? ""}
|
||||
placeholder="500"
|
||||
className="input input-sm w-20"
|
||||
onChange={(e) =>
|
||||
onButtonLightChange({ ...buttonLight, blinkOffMs: e.target.value === "" ? undefined : Number(e.target.value) })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Controller INPUTS (terminals) ──────────────────────────────────────────
|
||||
// An input is a TERMINAL the host READS: the entry button, the presence/radar sensor.
|
||||
// Each input belongs to an entry barrier (it triggers/gates that relay's entry), so we
|
||||
// render one block per entry/both relay, labelled with the output relay it drives. The
|
||||
// button never SETS a pulse — its electrical pulse is the device's to report — so no
|
||||
// timing field lives here (pulse-open is an OUTPUT setting, in OutputEditor).
|
||||
// An input is a TERMINAL the host READS. It's a first-class list (the twin of the relays
|
||||
// list above): each row is a terminal + a ROLE (entry button / presence loop / presence
|
||||
// radar / alert trigger) + the relay it serves. Adding an exit radar = adding a row. The
|
||||
// button never SETS a pulse — its electrical pulse is the device's to report — so no timing
|
||||
// field lives here (pulse-open is an OUTPUT setting, in OutputEditor).
|
||||
|
||||
/** Per-entry-relay input terminals: the entry button + the presence/radar sensor. */
|
||||
/** Generic controller-input list: terminal + role + the relay it serves. */
|
||||
function InputEditor({
|
||||
relays,
|
||||
inputs,
|
||||
onChange,
|
||||
relays,
|
||||
inputsIdleHigh,
|
||||
onInputsIdleHighChange,
|
||||
}: {
|
||||
inputs: InputSpec[];
|
||||
onChange: (v: InputSpec[]) => void;
|
||||
relays: RelaySpec[];
|
||||
onChange: (r: RelaySpec[]) => void;
|
||||
inputsIdleHigh: boolean | undefined;
|
||||
onInputsIdleHighChange: (v: boolean) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
function update(i: number, patch: Partial<RelaySpec>) {
|
||||
onChange(relays.map((r, idx) => (idx === i ? { ...r, ...patch } : r)));
|
||||
function update(i: number, patch: Partial<InputSpec>) {
|
||||
onChange(inputs.map((row, idx) => (idx === i ? { ...row, ...patch } : row)));
|
||||
}
|
||||
// Inputs only matter for entry/both relays (transient entry). Keep each row's real
|
||||
// index so updates target the right relay.
|
||||
const entryRelays = relays
|
||||
.map((r, i) => ({ r, i }))
|
||||
.filter(({ r }) => r.direction === "entry" || r.direction === "both");
|
||||
function add() {
|
||||
const firstEntry = relays.find((r) => r.direction === "entry" || r.direction === "both");
|
||||
onChange([...inputs, { input: 1, role: "button", relay: firstEntry?.relay }]);
|
||||
}
|
||||
function remove(i: number) {
|
||||
onChange(inputs.filter((_, idx) => idx !== i));
|
||||
}
|
||||
// Barrier relays an input can serve (button/presence gate a barrier; alert triggers don't).
|
||||
const barrierRelays = relays.filter((r) => r.direction !== "radarAlert");
|
||||
// A button row shows its cooldown fallback only if no presence row serves the same relay.
|
||||
const hasPresenceFor = (relay?: number) =>
|
||||
relay != null && inputs.some((x) => x.role === "presence" && x.relay === relay);
|
||||
|
||||
return (
|
||||
<div className="my-2 rounded-term border border-term-border bg-term-bg p-2">
|
||||
@@ -1196,77 +1268,91 @@ function InputEditor({
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{entryRelays.length === 0 ? (
|
||||
<p className="hint">{t("setup.inputsNoEntryRelay")}</p>
|
||||
) : (
|
||||
entryRelays.map(({ r, i }) => (
|
||||
{inputs.map((row, i) => {
|
||||
const choice = choiceOf(row);
|
||||
const isPresence = row.role === "presence";
|
||||
const isButton = row.role === "button";
|
||||
return (
|
||||
<div key={i} className="my-1 flex flex-wrap items-center gap-2 border-t border-term-border pt-2">
|
||||
<span className="text-[11px] uppercase tracking-wider text-term-amber">
|
||||
{t("setup.inputsForRelay", { relay: r.relay })}
|
||||
</span>
|
||||
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted">
|
||||
{t("setup.entryButtonTerminal")}
|
||||
{t("setup.inputTerminal")}
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={r.button ?? ""}
|
||||
placeholder="—"
|
||||
value={row.input}
|
||||
className="input input-sm w-16"
|
||||
onChange={(e) => update(i, { button: e.target.value === "" ? undefined : Number(e.target.value) })}
|
||||
onChange={(e) => update(i, { input: Number(e.target.value) })}
|
||||
/>
|
||||
</label>
|
||||
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted" title={t("setup.presenceInputHint")}>
|
||||
{t("setup.presenceInput")}
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={r.presenceInput ?? ""}
|
||||
placeholder="—"
|
||||
className="input input-sm w-16"
|
||||
onChange={(e) => update(i, { presenceInput: e.target.value === "" ? undefined : Number(e.target.value) })}
|
||||
/>
|
||||
</label>
|
||||
{/* Sensor kind + active-level — only once a presence terminal is set. */}
|
||||
{!!r.presenceInput && (
|
||||
<>
|
||||
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted">
|
||||
{t("setup.presenceKind")}
|
||||
<select
|
||||
value={r.presenceKind ?? "loop"}
|
||||
className="input input-sm w-24"
|
||||
onChange={(e) => update(i, { presenceKind: e.target.value as "loop" | "radar" })}
|
||||
>
|
||||
<option value="loop">{t("setup.presenceKindLoop")}</option>
|
||||
<option value="radar">{t("setup.presenceKindRadar")}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted" title={t("setup.presenceActiveLowHint")}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!r.presenceActiveLow}
|
||||
onChange={(e) => update(i, { presenceActiveLow: e.target.checked || undefined })}
|
||||
/>
|
||||
{t("setup.presenceActiveLow")}
|
||||
</label>
|
||||
</>
|
||||
<select
|
||||
className="select input-sm w-auto"
|
||||
value={choice}
|
||||
onChange={(e) => update(i, applyChoice(e.target.value as InputChoice))}
|
||||
>
|
||||
{(["button", "presenceLoop", "presenceRadar", "alertTrigger"] as InputChoice[]).map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{t(INPUT_CHOICE_KEYS[c])}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{/* Which barrier this input serves — button/presence only (alert triggers a lamp). */}
|
||||
{row.role !== "alertTrigger" && (
|
||||
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted">
|
||||
{t("setup.inputServesRelay")}
|
||||
<select
|
||||
className="select input-sm w-auto"
|
||||
value={row.relay ?? ""}
|
||||
onChange={(e) => update(i, { relay: e.target.value === "" ? undefined : Number(e.target.value) })}
|
||||
>
|
||||
<option value="" disabled>
|
||||
{t("setup.choose")}
|
||||
</option>
|
||||
{barrierRelays.map((r) => (
|
||||
<option key={r.relay} value={r.relay}>
|
||||
{t("setup.relayLabel", { relay: r.relay, direction: t(DIRECTION_KEYS[r.direction]) })}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
{/* Cooldown fallback only when no presence sensor is wired. */}
|
||||
{!r.presenceInput && (
|
||||
|
||||
{/* Presence: active-low (a radar wired opposite the button). */}
|
||||
{isPresence && (
|
||||
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted" title={t("setup.activeLowHint")}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!row.activeLow}
|
||||
onChange={(e) => update(i, { activeLow: e.target.checked || undefined })}
|
||||
/>
|
||||
{t("setup.activeLow")}
|
||||
</label>
|
||||
)}
|
||||
|
||||
{/* Button cooldown fallback — only when no presence sensor serves this relay. */}
|
||||
{isButton && !hasPresenceFor(row.relay) && (
|
||||
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted" title={t("setup.entryCooldownHint")}>
|
||||
{t("setup.entryCooldown")}
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
value={r.entryCooldownSec ?? ""}
|
||||
value={row.cooldownSec ?? ""}
|
||||
placeholder="—"
|
||||
className="input input-sm w-16"
|
||||
onChange={(e) => update(i, { entryCooldownSec: e.target.value === "" ? undefined : Number(e.target.value) })}
|
||||
onChange={(e) => update(i, { cooldownSec: e.target.value === "" ? undefined : Number(e.target.value) })}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
|
||||
<button type="button" className="btn btn-ghost btn-sm" onClick={() => remove(i)}>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
);
|
||||
})}
|
||||
<button type="button" className="btn btn-sm mt-1" onClick={add}>
|
||||
{t("setup.addInput")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1325,11 +1411,14 @@ function BindingPicker({
|
||||
<option value="" disabled>
|
||||
{t("setup.choose")}
|
||||
</option>
|
||||
{relays.map((r) => (
|
||||
<option key={r.relay} value={r.relay}>
|
||||
{t("setup.relayLabel", { relay: r.relay, direction: t(DIRECTION_KEYS[r.direction]) })}
|
||||
</option>
|
||||
))}
|
||||
{/* Only barrier relays are bindable — an alert lamp opens nothing. */}
|
||||
{relays
|
||||
.filter((r) => r.direction !== "radarAlert")
|
||||
.map((r) => (
|
||||
<option key={r.relay} value={r.relay}>
|
||||
{t("setup.relayLabel", { relay: r.relay, direction: t(DIRECTION_KEYS[r.direction]) })}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{chosen && <DirectionBadge direction={chosen.direction} label={t("setup.inherits", { direction: t(DIRECTION_KEYS[chosen.direction]) })} />}
|
||||
@@ -1341,14 +1430,16 @@ function BindingPicker({
|
||||
);
|
||||
}
|
||||
|
||||
function DirectionBadge({ direction, label }: { direction: Direction; label?: string }) {
|
||||
// entry=green, exit=amber, both=muted — aligned to the terminal accent palette.
|
||||
function DirectionBadge({ direction, label }: { direction: RelayEvent; label?: string }) {
|
||||
// entry=green, exit=amber, radarAlert=red (an alert), both=muted — terminal accents.
|
||||
const cls =
|
||||
direction === "entry"
|
||||
? "border-term-green text-term-green"
|
||||
: direction === "exit"
|
||||
? "border-term-amber text-term-amber"
|
||||
: "border-term-muted text-term-muted";
|
||||
: direction === "radarAlert"
|
||||
? "border-term-red text-term-red"
|
||||
: "border-term-muted text-term-muted";
|
||||
return (
|
||||
<span className={`rounded-term border px-1.5 text-[10px] font-semibold uppercase tracking-wider ${cls}`}>
|
||||
{label ?? direction}
|
||||
|
||||
+40
-21
@@ -275,30 +275,49 @@ export type DeviceConfig = Record<string, ConfigValue>;
|
||||
/** Direction a barrier/relay (or a device bound to it) serves. */
|
||||
export type Direction = "entry" | "exit" | "both";
|
||||
|
||||
/** One relay on an access controller: which barrier it opens, in which direction,
|
||||
* and (optionally) the input terminal its entry button is wired to. */
|
||||
export interface RelaySpec {
|
||||
relay: number;
|
||||
direction: Direction;
|
||||
/** Input terminal of the entry button that fires this relay (transient entry). */
|
||||
button?: number;
|
||||
/** Anti-double-press (one car = one ticket). PRESENCE: input terminal of a vehicle
|
||||
* loop/barrier-feedback signal; a press prints only with a car present + re-arms when
|
||||
* it clears. COOLDOWN (fallback, no feedback): suppress repeat presses for N seconds. */
|
||||
presenceInput?: number;
|
||||
/** Sensor on the presence input: induction LOOP or a RADAR (label only). */
|
||||
presenceKind?: "loop" | "radar";
|
||||
/** The presence terminal is active-LOW (idles HIGH) — e.g. a radar wired opposite
|
||||
* the button. Maps to the driver's per-input active-level override. */
|
||||
presenceActiveLow?: boolean;
|
||||
entryCooldownSec?: number;
|
||||
/** The EVENT a relay reacts to. entry/exit/both → pulse a barrier; `radarAlert` → drive a
|
||||
* non-barrier alert lamp (blink while its trigger input is active, SOLID once the camera
|
||||
* confirms a car). The action is implied by the event. */
|
||||
export type RelayEvent = Direction | "radarAlert";
|
||||
|
||||
/** What a controller input terminal means: a transient-entry `button`, a one-car-one-ticket
|
||||
* `presence` sensor (loop/radar), or an `alertTrigger` for a radarAlert lamp. */
|
||||
export type InputRole = "button" | "presence" | "alertTrigger";
|
||||
|
||||
/** One INPUT terminal the host reads (the twin of RelaySpec). An exit radar is just another
|
||||
* `presence` row serving the exit relay. */
|
||||
export interface InputSpec {
|
||||
input: number;
|
||||
role: InputRole;
|
||||
/** The barrier relay this input serves (required for button/presence; optional for
|
||||
* alertTrigger). */
|
||||
relay?: number;
|
||||
/** presence only — induction LOOP or RADAR (label only). */
|
||||
kind?: "loop" | "radar";
|
||||
/** This terminal idles HIGH / is active-LOW (e.g. a radar wired opposite the button). */
|
||||
activeLow?: boolean;
|
||||
/** button only — presence-less fallback cooldown (seconds). */
|
||||
cooldownSec?: number;
|
||||
}
|
||||
|
||||
/** A non-barrier indicator lamp wired to a spare relay (e.g. the entry button light),
|
||||
* driven by the radar input vs. the camera lane status. */
|
||||
export interface ButtonLightSpec {
|
||||
/** 1-based spare relay the lamp is on. */
|
||||
/** One relay on an access controller: the event it reacts to. Input wiring lives in
|
||||
* `config.inputs[]`; the legacy per-relay button/presence fields are still read for
|
||||
* back-compat but no longer written. */
|
||||
export interface RelaySpec {
|
||||
relay: number;
|
||||
/** The event this relay reacts to (UI label: "Event"). */
|
||||
direction: RelayEvent;
|
||||
// ── legacy input fields (read-only back-compat; superseded by config.inputs[]) ──
|
||||
button?: number;
|
||||
presenceInput?: number;
|
||||
presenceKind?: "loop" | "radar";
|
||||
presenceActiveLow?: boolean;
|
||||
entryCooldownSec?: number;
|
||||
// ── radarAlert-only ──
|
||||
/** Input terminal whose active edge starts the blink (the radar). */
|
||||
triggerInput?: number;
|
||||
/** Which lane's camera locks this lamp SOLID (default entry). An exit radar locks on exit. */
|
||||
lockLane?: "entry" | "exit";
|
||||
/** Blink cadence (ms on / ms off) for the radar-only state. Default 500/500. */
|
||||
blinkOnMs?: number;
|
||||
blinkOffMs?: number;
|
||||
|
||||
+19
-16
@@ -369,27 +369,30 @@ export const en: Catalog = {
|
||||
"Inputs are TERMINALS the host READS: the entry button and the presence/radar sensor. Each belongs to an entry barrier — it triggers or gates that relay.",
|
||||
inputsIdleHigh: "Inputs idle HIGH",
|
||||
inputsIdleHighHint: "This board idles inputs HIGH (status 1111); a press pulls LOW.",
|
||||
inputsForRelay: "For relay {{relay}}",
|
||||
inputsNoEntryRelay: "No entry relay — add an 'Entry' or 'Entry + exit' relay in Outputs to assign terminals.",
|
||||
relay: "Relay",
|
||||
entryButtonTerminal: "Entry button on terminal",
|
||||
presenceInput: "Presence sensor (terminal)",
|
||||
presenceInputHint:
|
||||
"Input terminal the vehicle-presence sensor (induction loop or radar) is wired to. When set, exactly ONE ticket issues per car: the button prints only while a car is present, and no second ticket issues until the sensor clears (the car drove in) and a new car re-occupies it. Preferred mode.",
|
||||
// Generic input rows: terminal + role + the relay it serves.
|
||||
inputTerminal: "Terminal",
|
||||
inputServesRelay: "Serves relay",
|
||||
roleButton: "Entry button",
|
||||
rolePresenceLoop: "Presence (loop)",
|
||||
rolePresenceRadar: "Presence (radar)",
|
||||
roleAlertTrigger: "Alert trigger",
|
||||
addInput: "+ Add input",
|
||||
entryCooldown: "Cooldown after ticket (s)",
|
||||
entryCooldownHint:
|
||||
"When there's no presence sensor: repeat button presses are suppressed for this many seconds after a ticket. A fallback (not a guarantee) — a determined abuser can wait it out.",
|
||||
presenceKind: "Kind",
|
||||
presenceKindLoop: "Loop",
|
||||
presenceKindRadar: "Radar",
|
||||
presenceActiveLow: "Active-low",
|
||||
presenceActiveLowHint:
|
||||
activeLow: "Active-low",
|
||||
activeLowHint:
|
||||
"Tick if the presence sensor (e.g. a radar) idles HIGH and goes LOW on detection — the opposite of the button. This inverts that terminal's reading so 'present' is read correctly.",
|
||||
buttonLight: "Button light (spare relay)",
|
||||
buttonLightRelay: "Relay",
|
||||
buttonLightHint:
|
||||
"The button's 12 V light on a spare relay. Blinks when the radar detects but the camera doesn't confirm a car; solid on when both confirm; off otherwise.",
|
||||
buttonLightBarrierWarn: "This relay is used by a barrier — pick a spare relay.",
|
||||
eventRadarAlert: "Radar alert (lamp)",
|
||||
triggerInput: "Trigger input",
|
||||
triggerInputHint:
|
||||
"The input terminal (the radar) that starts this relay blinking. Blinks while the trigger is active but the camera doesn't confirm a car; solid on once the camera confirms; off otherwise.",
|
||||
lockLane: "Lock from",
|
||||
lockLaneHint:
|
||||
"Which camera locks the lamp solid: the entry or the exit camera. An exit radar must lock on the EXIT camera.",
|
||||
lockLaneEntry: "Entry camera",
|
||||
lockLaneExit: "Exit camera",
|
||||
blinkOnMs: "Blink on (ms)",
|
||||
blinkOffMs: "Blink off (ms)",
|
||||
addRelay: "+ Add relay",
|
||||
|
||||
+19
-16
@@ -378,27 +378,30 @@ export const sq = {
|
||||
"Hyrjet janë TERMINALE që hosti i LEXON: butoni i hyrjes dhe sensori i pranisë/radari. Secila i përket një barriere hyrëse — e gateron ose e nis atë rele.",
|
||||
inputsIdleHigh: "Hyrjet në pushim HIGH",
|
||||
inputsIdleHighHint: "Kjo pllakë i mban hyrjet HIGH në pushim (statusi 1111); një shtypje e ul në LOW.",
|
||||
inputsForRelay: "Për rele {{relay}}",
|
||||
inputsNoEntryRelay: "Asnjë rele hyrëse — shto një rele 'Hyrje' ose 'Hyrje + dalje' te Daljet që të caktosh terminalet.",
|
||||
relay: "Rele",
|
||||
entryButtonTerminal: "Butoni i hyrjes në terminalin",
|
||||
presenceInput: "Sensori i pranisë (terminali)",
|
||||
presenceInputHint:
|
||||
"Terminali hyrës ku është lidhur sensori/laku i pranisë së automjetit. Kur vendoset, lëshohet vetëm NJË biletë për automjet: butoni printon vetëm kur ka makinë, dhe nuk lëshon biletë të dytë derisa laku të lirohet (makina hyri) dhe një makinë e re ta zërë. Mënyra e preferuar.",
|
||||
// Generic input rows: terminal + role + the relay it serves.
|
||||
inputTerminal: "Terminali",
|
||||
inputServesRelay: "I shërben reles",
|
||||
roleButton: "Butoni i hyrjes",
|
||||
rolePresenceLoop: "Prania (lak induktiv)",
|
||||
rolePresenceRadar: "Prania (radar)",
|
||||
roleAlertTrigger: "Trigger alarmi",
|
||||
addInput: "+ Shto hyrje",
|
||||
entryCooldown: "Pritje pas biletës (sek)",
|
||||
entryCooldownHint:
|
||||
"Kur nuk ka sensor pranie: shtypjet e përsëritura të butonit shtypen për kaq sekonda pas një bilete. Zgjidhje rezervë (jo garanci) — një abuzues mund ta presë afatin.",
|
||||
presenceKind: "Lloji",
|
||||
presenceKindLoop: "Lak",
|
||||
presenceKindRadar: "Radar",
|
||||
presenceActiveLow: "Aktiv-ulët",
|
||||
presenceActiveLowHint:
|
||||
activeLow: "Aktiv-ulët",
|
||||
activeLowHint:
|
||||
"Shëno nëse sensori i pranisë (p.sh. radari) qëndron HIGH në pushim dhe shkon LOW kur detekton — e kundërta e butonit. Kjo përmbys leximin e atij terminali që 'prania' të lexohet saktë.",
|
||||
buttonLight: "Drita e butonit (rele rezervë)",
|
||||
buttonLightRelay: "Rele",
|
||||
buttonLightHint:
|
||||
"Drita 12V e butonit e lidhur në një rele rezervë. Pulson kur radari detekton por kamera s'konfirmon makinë; ndizet fiks kur të dy konfirmojnë; përndryshe fiket.",
|
||||
buttonLightBarrierWarn: "Kjo rele përdoret nga një barrierë — zgjidh një rele rezervë.",
|
||||
eventRadarAlert: "Alarm radar (dritë)",
|
||||
triggerInput: "Trigger input",
|
||||
triggerInputHint:
|
||||
"Terminali i hyrjes (radari) që nis pulsimin e kësaj rele. Pulson kur Trigger input është aktiv por kamera s'konfirmon makinë; ndizet fiks kur kamera konfirmon; përndryshe fiket.",
|
||||
lockLane: "Bllokimi nga",
|
||||
lockLaneHint:
|
||||
"Cila kamerë e ndez dritën fiks: hyrja apo dalja. Një radar i daljes duhet të bllokohet nga kamera e DALJES.",
|
||||
lockLaneEntry: "Kamera e hyrjes",
|
||||
lockLaneExit: "Kamera e daljes",
|
||||
blinkOnMs: "Pulsim ndezur (ms)",
|
||||
blinkOffMs: "Pulsim fikur (ms)",
|
||||
addRelay: "+ Shto rele",
|
||||
|
||||
Reference in New Issue
Block a user