diff --git a/apps/web/src/SetupWizard.tsx b/apps/web/src/SetupWizard.tsx
index d92b6db..3eb600a 100644
--- a/apps/web/src/SetupWizard.tsx
+++ b/apps/web/src/SetupWizard.tsx
@@ -496,7 +496,7 @@ function DeviceForm({
setTestError(null);
setTested(null);
try {
- setTested(await testDevice(selected.id, mergedScalarConfig()));
+ setTested(await testDevice(selected.id, mergedScalarConfig(), editing?.id));
} catch (e) {
setTestError((e as Error).message);
} finally {
@@ -598,7 +598,12 @@ function DeviceForm({
)}
- {selected.configFields.map((f) =>
+ {selected.configFields
+ // pulseMs + inputRestingHigh are surfaced in the Outputs / Inputs model
+ // sections below (a relay setting and an input setting, respectively), so
+ // skip them here to avoid rendering them twice. See OutputEditor/InputEditor.
+ .filter((f) => !(isController && (f.key === "pulseMs" || f.key === "inputRestingHigh")))
+ .map((f) =>
f.type === "boolean" ? (
// Boolean config field → a real checkbox (stores a true/false boolean, not
// the string "true"). The label sits beside the box, with the help below.
@@ -657,12 +662,33 @@ function DeviceForm({
),
)}
- {/* CONTROLLER: the relay map — which relay opens which direction + entry button. */}
- {isController && }
-
- {/* CONTROLLER: optional button-lamp output on a spare relay (radar + camera driven). */}
+ {/* CONTROLLER — OUTPUTS: the relays (barriers + the button lamp) + pulse time. */}
{isController && (
-
+ {
+ setConfig((c) => ({ ...c, pulseMs: v }));
+ resetStatus();
+ }}
+ />
+ )}
+
+ {/* CONTROLLER — INPUTS: the terminals (entry button, presence/radar), each bound
+ to the output relay it drives. Separated from the outputs above. */}
+ {isController && (
+ {
+ setConfig((c) => ({ ...c, inputRestingHigh: v }));
+ resetStatus();
+ }}
+ />
)}
{/* BOUND device: which controller + relay it sits at. */}
@@ -794,9 +820,28 @@ function DeviceForm({
);
}
-/** Controller relay map editor: each row = a relay + its direction + (optional)
- * the input terminal its entry button is wired to. */
-function RelayEditor({ relays, onChange }: { relays: RelaySpec[]; onChange: (r: RelaySpec[]) => void }) {
+// ── 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).
+
+/** Relays = outputs (barriers + lamp) + 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;
+}) {
const { t } = useTranslation();
function update(i: number, patch: Partial) {
onChange(relays.map((r, idx) => (idx === i ? { ...r, ...patch } : r)));
@@ -808,11 +853,27 @@ function RelayEditor({ relays, onChange }: { relays: RelaySpec[]; onChange: (r:
function remove(i: number) {
onChange(relays.filter((_, idx) => idx !== i));
}
+ const barrierRelays = new Set(relays.map((r) => r.relay));
return (
- {t("setup.relaysTitle")}
-
{t("setup.relaysHint")}
+ {t("setup.outputsTitle")}
+
{t("setup.outputsHint")}
+
+ {/* Pulse-open time applies to every barrier relay (how long it's held open). */}
+
+
+ {/* Barrier relays: number + direction. (Input terminals are in the Inputs section.) */}
{relays.map((r, i) => (
+ ))}
+
+
+ {/* 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). */}
+
+ );
+}
+
+// ── 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).
+
+/** Per-entry-relay input terminals: the entry button + the presence/radar sensor. */
+function InputEditor({
+ relays,
+ onChange,
+ inputsIdleHigh,
+ onInputsIdleHighChange,
+}: {
+ relays: RelaySpec[];
+ onChange: (r: RelaySpec[]) => void;
+ inputsIdleHigh: boolean | undefined;
+ onInputsIdleHighChange: (v: boolean) => void;
+}) {
+ const { t } = useTranslation();
+ function update(i: number, patch: Partial) {
+ onChange(relays.map((r, idx) => (idx === i ? { ...r, ...patch } : r)));
+ }
+ // 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");
+
+ return (
+
+ {t("setup.inputsTitle")}
+
{t("setup.inputsHint")}
+
+ {/* Board-wide resting level (idle HIGH vs LOW) — an input property. */}
+
+
+ {entryRelays.length === 0 ? (
+
- );
-}
-
-/** Button-lamp output: the entry button's 12 V light on a SPARE relay, driven by the
- * radar + camera (blink = radar-only, solid = car confirmed, off otherwise). Optional.
- * The relay picker offers every relay number on this controller; the operator picks a
- * spare one (not a barrier relay). See wiki/concepts/button-light-indicator.md. */
-function ButtonLightEditor({
- relays,
- value,
- onChange,
-}: {
- relays: RelaySpec[];
- value: ButtonLightSpec | null;
- onChange: (v: ButtonLightSpec | null) => void;
-}) {
- const { t } = useTranslation();
- // Relay numbers in use as barriers — shown as a hint so the operator avoids them.
- const barrierRelays = new Set(relays.map((r) => r.relay));
- return (
-
+ ))
)}
);
diff --git a/apps/web/src/lib/i18n/en.ts b/apps/web/src/lib/i18n/en.ts
index b6d8d66..61e0516 100644
--- a/apps/web/src/lib/i18n/en.ts
+++ b/apps/web/src/lib/i18n/en.ts
@@ -359,6 +359,18 @@ export const en: Catalog = {
relaysTitle: "Relays on this controller",
relaysHint:
"Each relay opens one barrier. Set its direction; for transient entry, set which input terminal the entry button is wired to.",
+ outputsTitle: "Outputs — relays (barriers + lamp)",
+ outputsHint:
+ "Relays are OUTPUTS: each opens a barrier (or drives the button lamp). Set the relay number and direction. The input terminals (button, sensor) are in the Inputs section below.",
+ pulseOpenMs: "Pulse open (ms)",
+ pulseOpenHint: "How long a barrier relay is held open (jog). Applies to all barrier relays.",
+ inputsTitle: "Inputs — terminals (button, sensor)",
+ inputsHint:
+ "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)",
diff --git a/apps/web/src/lib/i18n/sq.ts b/apps/web/src/lib/i18n/sq.ts
index f568dfb..075b06a 100644
--- a/apps/web/src/lib/i18n/sq.ts
+++ b/apps/web/src/lib/i18n/sq.ts
@@ -368,6 +368,18 @@ export const sq = {
relaysTitle: "Relet në këtë kontrollues",
relaysHint:
"Çdo rele hap një barrierë. Cakto drejtimin e saj; për hyrje kalimtare, cakto në cilin terminal hyrës është lidhur butoni i hyrjes.",
+ outputsTitle: "Daljet — relet (barrierat + drita)",
+ outputsHint:
+ "Relet janë DALJE: secila hap një barrierë (ose ndez dritën e butonit). Cakto numrin e relesë dhe drejtimin. Terminalet hyrëse (butoni, sensori) janë te seksioni Hyrjet më poshtë.",
+ pulseOpenMs: "Kohëzgjatja e hapjes (ms)",
+ pulseOpenHint: "Sa kohë mbahet rele e barrierës e hapur (jog). Vlen për të gjitha relet e barrierave.",
+ inputsTitle: "Hyrjet — terminalet (buton, sensor)",
+ inputsHint:
+ "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)",