Skip to content

feat(TRA-1028): Gpo.Set on the reader mqtt-rpc contract#525

Open
mikestankavich wants to merge 19 commits into
mainfrom
feat/tra-1028-gpo-alarm-transport
Open

feat(TRA-1028): Gpo.Set on the reader mqtt-rpc contract#525
mikestankavich wants to merge 19 commits into
mainfrom
feat/tra-1028-gpo-alarm-transport

Conversation

@mikestankavich

@mikestankavich mikestankavich commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Drive a CS463 reader GPO as an alarm output, in the same shape as the Shelly plug. A hospital (Frederick) disabled WiFi, which rules out the WiFi Shelly. The reader is on wired ethernet, so the cloud is still reachable — the GPO replaces the plug, not the cloud. The geofence engine still decides; only the actuator changes.

Two axes: transport is reach, type is device

transport (http | mqtt) is how we reach a device; type (shelly_gen4 | csl_cs463_gpo) is what frame it speaks. Shelly and the CS463 both ride mqtt-rpc; only their frames differ. This supersedes the ticket's original TransportGPO idea, which conflated the two and would cost a migration per device model. It leaves room for an LLRP Speedway wrapper later as a third transport or another type, no schema churn.

Shelly Reader GPO
topic <commandTopic>/rpc <base>/rpc
method Switch.Set Gpo.Set
params {id, on, toggle_after} {port, on, pulse_ms}

Reader side (mqtt-rpc daemon)

  • readerrpc gains GpoSetParams/GpoSetResult (MethodGpoSet was already reserved), synced across both copies of the contract.
  • readerd.Adapter.GpoSet + a Gpo.Set dispatch case.
  • CS463 adapter: a sessionless directIOOutput call (bypasses the single-root-session lock — verified on hardware while the web UI held the session; an alarm must not fail because a browser is open), and Gpo.Set moved from Unsupported to Supports.
  • Reader-side pulse timer. pulse_ms > 0 arms a one-shot: drive on now, release after the delay from a timer on the reader host, mirroring Shelly's device-side toggle_after. auto_off_seconds maps straight onto it (× 1000), so the OFF edge never depends on a second message arriving. Timers are held per port under a mutex — a new Gpo.Set supersedes any pending release for that port rather than racing it, and the release's OFF write is gated by a pointer-identity check under the port lock so a stale timer can never drive a port OFF after a superseding ON. Same-port calls serialize across the reader HTTP round-trip (bounded by the release/RPC timeouts); different ports don't block each other.

Cloud side

  • readercontrol.Notify — fire-and-forget publish alongside the existing reply-correlating call, plus a typed GpoSet wrapper. Gpo.Set rides readercontrol (which already owns the readerrpc frame format and <base>/rpc convention), not a generalized alarm.MQTTPublisher. The alarm path must never block on a reader round trip.
  • Fire-and-forget is deliberate, consistent with how RFID is sold: reads and alarms are best-effort. A broker-accepted publish is success. A reader-side error reply (rejected port, /API unreachable, a backwards-wired GPO) used to be dropped at Debug without reading resp.Error — it now logs at Warn with id/code/message, so a silent no-actuation is greppable.
  • One branch in alarm.Dispatcher.Set — routes on transport, then type. A GPO device on a non-mqtt transport is rejected up front.
  • Geofence engine unchanged — every tuning tier, RSSI gate, latch/re-arm and auto_off_seconds behaviour carries over.

Reader addressed by a scan_device_id FK (not a free-text topic)

A GPO output device is addressed by a nullable output_devices.scan_device_id FK to scan_devices (migration 000033), not by a topic string. The reader's RPC base topic is derived server-side from that reader's publish_topic at fire time (the fire and test-fire reads LEFT JOIN under the org's RLS), single-sourced — retopic the reader and the alarm follows.

This closes a cross-org actuation path a review found: the earlier free-text command_topic was published to with the shared backend broker credential, so an org could have fired another tenant's reader. Now:

  • Write-time validation requires a GPO device's scan_device_id to reference a live csl_cs463 reader in the org that has a publish_topic — otherwise a clear 400 (distinct messages for not-your-reader / not-a-cs463-reader / no-publish-topic), instead of a device that validates but silently never fires.
  • At fire time a cross-org or deleted reader does not resolve (RLS), so the dispatcher gets an empty base topic and refuses to fire — defense in depth behind the write-time check.
  • The frontend replaces the command-topic text field with a reader picker (filtered to csl_cs463) for GPO devices, and defaults the GPO port to 1 (min=1) so the "1–4" field isn't pre-seeded with an invalid 0.

switch_id is reused as the output-channel index — 0-based Shelly relay channel, 1-based CS463 GPO port (1–4) — documented per-type on the column and the model. Shelly (http and mqtt, including switch_id 0) is unchanged throughout.

Verification

go build, go vet, go test (incl. real-DB integration for the FK validation and topic resolution) green across the backend; go test -race green across the reader-side pulse logic (the per-port supersession race is covered by a test that fails against the pre-fix code and passes after); frontend touched-file suites green single-threaded. The only full-suite frontend failure is the pre-existing useReportHydration flake (passes 5/5 in isolation), in a package this branch doesn't touch.

Hardware + end-to-end verified on preview (2026-07-23). The replacement SSR carries a real load (a flasher ran under current, not just open-circuit voltage — the failure mode that killed the first module). And the full path fires: with the branch deployed to preview and the new mqtt-rpcd on cs463-212, a csl_cs463_gpo output device bound to the same boundary + presence rule as the existing Shelly "Portal Alarm" fires in sync with it on the same carousel reads — visually confirmed, both strobes flashing together. The deterministic path is also proven via the test-fire endpoint (gpio205 driven HIGH then released). Remaining hardware step is cosmetic: swapping the vehicle-strobe stand-in for the real Honeywell/System Sensor horn-strobe (24 V-only, non-Temporal-3) — a load-agnostic wiring swap, no code change.

Bringup notes for whoever deploys this: (1) sync-preview filters draft PRs and doesn't trigger on ready_for_review, so a PR marked ready-from-draft won't reach preview until its next push (re-run the workflow, or see the one-line fix below). (2) A presence-mode alarm added while tags are already present won't fire until presence next cycles — no fresh ON edge otherwise. (3) The reader daemon doesn't log directIOOutput at INFO; verify by reading the GPO pin state, not the journal.

The GPO is a polarized switch. Current enters GPO(+) pin 4 and exits GPO(−) pin 14. Wired backwards, an internal body diode conducts continuously — even with the reader powered off — and the obvious checks are blind to it (continuity reads correctly, a manual jumper works, the SSR tests fine standalone). The diagnostic that finds it is measuring with the reader unplugged. Captured in the code comments and docs/cs463-reader-references.md.

Deferred (follow-ups, not blockers)

  • GPO safe-state on daemon start. The pulse timer lives in daemon memory, so a daemon restart mid-pulse leaves a port latched on until the next Gpo.Set. Worth a startup drive-all-low; deferred to its own ticket.
  • Confirm the CS463 firmware actually emits resp.Error on rejected-port / /API-unreachable conditions — the Warn logging is in place cloud-side, but whether the reply carries an error in those cases is a hardware-confirmation item.
  • Converting an existing Shelly device to GPO in the UI needs the reader set first (the inline switch_id/reader edits are row-owned per TRA-940); non-obvious but recoverable.
  • scan_device_id FK has no ON DELETE — soft-delete + the fire-time deleted_at IS NULL join means soft-deleting a referenced reader disables its GPO alarms fail-closed, consistent with the design.
  • sync-preview trigger gap (surfaced deploying this PR) — the workflow filters draft PRs but doesn't list ready_for_review in its pull_request types, so marking a PR ready never syncs it to preview. One-line fix (add ready_for_review to the trigger). Separate small PR.

Refs TRA-1028.

🤖 Generated with Claude Code

Mike Stankavich and others added 19 commits July 22, 2026 12:10
Adds the reader-side half of driving a CS463 GPO as an alarm output, in the
same shape as the Shelly plug. The platform keeps the decision; the GPO
replaces the actuator.

The frame shapes already matched — readerrpc was modeled on Shelly Gen4
JSON-RPC — so this is a new method on an existing path, not a new protocol:

  Shelly      Switch.Set  {id, on, toggle_after}  -> <commandTopic>/rpc
  Reader GPO  Gpo.Set     {port, on, pulse_ms}    -> <base>/rpc

- readerrpc: GpoSetParams / GpoSetResult (MethodGpoSet was already reserved),
  synced across both copies of the contract
- readerd: Adapter.GpoSet + a Gpo.Set dispatch case
- cs463: Client.DirectIOOutput transport, Adapter.GpoSet, and Gpo.Set moved
  from Unsupported to Supports

pulse_ms > 0 arms a reader-side one-shot: drive on now, release after the
delay from a timer on the reader. That mirrors Shelly's device-side
toggle_after, so an output's auto_off_seconds maps straight onto it and the
OFF edge never depends on a second message arriving. The release runs on a
background context so it outlives the RPC deadline, and the caller is acked as
soon as the port is energised rather than waiting out the pulse.

directIOOutput is deliberately sessionless — it bypasses the reader's
single-root-session lock, verified on hardware while the web UI held the
session. An alarm must not fail because someone left a browser open.

Also documents the hardware finding that cost an afternoon: the GPO is a
POLARIZED switch. Current enters GPO(+) and exits GPO(-). Wired backwards an
internal body diode conducts continuously, even with the reader powered off,
and continuity tests cannot see it because the meter's test voltage sits below
the diode's forward threshold.

Verified: build, vet, go test -race all green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Reference for the reader's /API surface (V1.4). Section 8 "IO Management"
documents the GPIO commands the Gpo.Set adapter drives — directIOOutput and
directIOInput are the sessionless variants, which bypass the reader's
single-root-session lock.

Checked in because the CS463 work repeatedly needed command-level detail that
is not reproduced anywhere in the codebase, and the vendor's download URLs
have moved before.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Points at CSL's upstream manuals rather than checking in a 21 MB user manual
for two chapters. Records the facts that are expensive to rediscover:

- GPO polarity, and why continuity tests and manual jumpers cannot detect a
  reversed connection (the body diode conducts even with the reader powered off)
- directIOOutput/directIOInput are sessionless and bypass the single-session lock
- the verified sysfs GPIO mapping on cs463-212, which gives a programmatic
  signal for testing without hardware indicators

Linked from the cs463 package doc so it is discoverable from the code that
depends on it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The whole CSL manuals tree is the canonical source:
https://github.com/cslrfid/CS463-CS203X-Product-Downloads/tree/main/Manuals

Fetch from there on demand rather than checking manuals in — the User Manual
alone is ~21 MB and we need a chapter at a time. Only the HTTP API PDF stays
in-repo, because CS463 work needs it constantly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds the CS108 and CS710S downloads repos alongside CS463/CS203X.

Corrects an earlier claim in this file that handheld support would be new work:
CS108 is already supported, in frontend/src/worker/cs108/ — a binary packet
protocol running in a browser web worker, entirely separate from the fixed-reader
path. Fixed and handheld share no transport, adapter, or protocol code, so
neither side's work carries over to the other. CS710S would extend the worker
side alongside cs108/, not this package.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds the type axis for a GPO output. transport stays reach (http | mqtt); type
becomes what frame the device speaks, so Shelly and the CS463 can share
mqtt-rpc without a migration per device model.

switch_id is overloaded as the output-channel index rather than moved to
metadata: addressing is required and a wrong value fires the wrong relay, so it
wants a column constraint, not an optional jsonb key. Base and range are
type-specific (0-based Shelly relay, 1-based GPO port) and documented on both
the column and the struct field.

Refs TRA-1028.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The down migration was over-reversing switch_id (setting to NULL instead of
restoring the original comment from 000016), and under-reversing type (failing to
clear the comment added in this migration's up).

Fixed by:
1. Restoring switch_id comment to the original: 'Shelly relay channel passed as Switch.Set id=.'
2. Adding COMMENT ON COLUMN output_devices.type IS NULL to clear the stale CS463 GPO text

Verified: migrate-down and migrate-up both succeed.

Refs TRA-1028.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Gpo.Set belongs here rather than in a generalized alarm.MQTTPublisher: this
package already owns the readerrpc frame format, the <base>/rpc convention and
the typed method wrappers, and splitting reader-frame knowledge across two
packages would rot.

What it lacked was a non-blocking path — call correlates a reply and blocks up
to 10s, which an alarm must never do. Notify builds the identical frame, real id
and src included, but registers no pending entry and returns on broker ack. The
daemon needs no change: its reply lands on our wildcard and deliver already
drops unknown ids.

Refs TRA-1028.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Set now branches on both axes: transport picks the reach, type picks the frame.
A GPO device publishes Gpo.Set via readercontrol; Shelly is untouched on either
transport.

auto_off_seconds maps onto pulse_ms as seconds*1000 in exactly one place. The
reader implements the one-shot itself, so as with Shelly's toggle_after the OFF
edge survives a backend restart and never depends on a second message arriving.
Presence mode already passes 0 because the engine owns that edge, so no mode
awareness leaks into the dispatcher.

The port range is re-checked here even though the handler validates on write: a
row predating that validation, or edited in psql, must error rather than fire
the wrong port. The command_topic check moved above the type branch since it
now guards both mqtt device types.

NewDispatcher gained a third parameter (gpoSetter); every existing call site is
updated, including serve_test.go and router_entitlement_integration_test.go
which the plan didn't originally name.

Refs TRA-1028.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
transportFieldsError becomes deviceFieldsError and gains the type dimension: a
GPO device is mqtt-only, needs command_topic as the reader base topic, and must
carry a GPO port of 1-4 in switch_id. Shelly rules are untouched, including a
switch_id of 0, which stays valid for a single-relay Gen4.

Update merges type and switch_id over the stored row before validating, for the
same reason transport already did: a PATCH that flips type without resending
switch_id must be checked against the effective state, not skipped.

Spec regenerated from the widened oneof tags; no git-visible diff since
output-devices is internal-only (excluded from the public spec) and the
internal/embedded spec files are gitignored.

Refs TRA-1028.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The device-type dropdown had exactly one option, so without this a GPO output
was creatable only by curl or psql. Selecting it locks transport to mqtt rather
than validating after the fact, and relabels switch_id as the GPO port with the
1-4 rule and command_topic as the reader base topic.

The rule also lands in the inline row cell: TRA-940 moved switch_id editing out
of the expander form, so validating only in the form would enforce the port
range on create and silently bypass it on edit.

Refs TRA-1028.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…nditional

The conditional branch for csl_cs463_gpo help text introduced in commit 4c87015
inadvertently collapsed the Shelly branch from full JSX markup (with <code> element
and firewall note) to a bare string. Restored the original complete guidance text
for Shelly devices while keeping the GPO wording, ensuring both branches now render
correctly.

Refs TRA-1028.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds output_devices.scan_device_id (nullable FK to scan_devices) and derives a
GPO device's reader RPC base topic from that reader's publish_topic at fire
time, single-sourced. The fire and test-fire reads LEFT JOIN scan_devices under
the org RLS GUC and populate a transient ReaderBaseTopic; a cross-org reader
does not join, so the topic comes back empty and the dispatcher (Task 8) refuses
to fire. ScanDeviceExistsInOrg backs the write-time check.

Refs TRA-1028.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The dispatcher now fires a GPO on the reader base topic resolved from the FK
(Task 7), refusing to fire when it is unresolved — closing the cross-org
actuation path the free-text command_topic left open. Write-time validation
requires a GPO device's scan_device_id to reference a live reader in the org.
Also rejects a GPO device on a non-mqtt transport before the http branch.

Refs TRA-1028.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replaces the free-text command topic with a reader picker for csl_cs463_gpo
devices, matching the backend FK: the base topic is derived server-side from the
chosen reader. Also defaults the GPO port to 1 (min=1) so the field is not
pre-seeded with an invalid 0 under a "1-4" label.

Refs TRA-1028.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Each pulsed Gpo.Set spawned an independent release goroutine with no per-port
state, so two tags exiting within one auto_off window let the first timer cut
the second alarm short. Hold a per-port timer and stop any pending release
before arming a new one — Shelly's toggle_after replaces rather than races.

Refs TRA-1028.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…rt locks

The prior commit narrowed the race but a stale, already-firing release could
still land its OFF write after a superseding command's ON: the callback wrote
OFF before checking whether it had been superseded, and that write wasn't
serialized against a concurrent GpoSet at all (the identity check only
guarded the map cleanup afterwards). Reviewer reproduced it 200/200 with a
short pulse followed by an immediate superseding latch.

Replace the single adapter-wide gpoMu + map[int]*time.Timer with a per-port
portGPO{mu, timer}, lazily created per port. GpoSet and the release callback
now both take that SAME per-port lock, and the callback checks timer identity
BEFORE writing OFF (not just for its own cleanup) — so the write and any
superseding GpoSet's stop+drive are atomic with respect to each other:
whichever gets the lock first completes fully before the other proceeds, so
OFF-then-ON or ON-only are the only possible outcomes, never OFF-after-ON.
This also fixes a second issue: the old global lock serialized ALL ports
against each other during the reader HTTP call; per-port locks mean a slow
call on one GPO port can no longer block Gpo.Set on any other port.

Refs TRA-1028.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A Notify reply carrying a reader-side failure (rejected port, /API unreachable,
backwards-wired GPO) has no pending entry, so deliver dropped it at Debug
without reading resp.Error — every GPO alarm returned nil and looked successful.
Log such error replies at Warn so a silent no-actuation becomes greppable.

Refs TRA-1028.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… time

Final review found two write-time validation gaps on the GPO alarm
transport path (a scan_device_id FK the dispatcher resolves to the
reader's mqtt-rpc base topic at fire time):

1. A csl_cs463 reader can exist with a NULL/empty publish_topic (it's
   nullable). A GPO device pointed at one passed validation (201), but
   at fire time the derived base topic is "" and the dispatcher
   fail-closed refuses to fire — a valid-looking device that silently
   never fires, with no signal at config time.

2. The existence check accepted ANY in-org, non-deleted reader. The UI
   filters its picker to csl_cs463, but an API client bypassing the UI
   could point scan_device_id at a gl_s10 or csl_cs108 and fire
   Gpo.Set at a reader with no CS463 daemon listening.

Both previously failed closed only at fire time; this closes them at
write time instead with a 400.

storage.ScanDeviceExistsInOrg (a single bool) is replaced with
CheckGPOReader, returning a GPOReaderCheck{Found, IsCS463,
HasPublishTopic} so the handler can produce three distinct 400
messages (not-your-reader / not-a-cs463-reader / no-publish-topic) —
an operator needs to know which failed. Still one query under
WithOrgTx(orgID); RLS remains the org boundary.

The dispatcher's fail-closed empty-topic refusal is untouched — this
is defense in depth in front of it, not a replacement for it.

Refs TRA-1028.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@mikestankavich
mikestankavich marked this pull request as ready for review July 23, 2026 12:13
@github-actions

Copy link
Copy Markdown

🚀 Preview Deployment Update

✅ This PR has been successfully merged into the preview branch.

The preview environment will update shortly at: https://app.preview.trakrf.id

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant