feat(TRA-1028): Gpo.Set on the reader mqtt-rpc contract#525
Open
mikestankavich wants to merge 19 commits into
Open
feat(TRA-1028): Gpo.Set on the reader mqtt-rpc contract#525mikestankavich wants to merge 19 commits into
mikestankavich wants to merge 19 commits into
Conversation
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
marked this pull request as ready for review
July 23, 2026 12:13
|
🚀 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
transportis reach,typeis devicetransport(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 originalTransportGPOidea, 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.<commandTopic>/rpc<base>/rpcSwitch.SetGpo.Set{id, on, toggle_after}{port, on, pulse_ms}Reader side (mqtt-rpc daemon)
readerrpcgainsGpoSetParams/GpoSetResult(MethodGpoSetwas already reserved), synced across both copies of the contract.readerd.Adapter.GpoSet+ aGpo.Setdispatch case.directIOOutputcall (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), andGpo.Setmoved fromUnsupportedtoSupports.pulse_ms > 0arms a one-shot: drive on now, release after the delay from a timer on the reader host, mirroring Shelly's device-sidetoggle_after.auto_off_secondsmaps straight onto it (× 1000), so the OFF edge never depends on a second message arriving. Timers are held per port under a mutex — a newGpo.Setsupersedes 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-correlatingcall, plus a typedGpoSetwrapper.Gpo.Setridesreadercontrol(which already owns thereaderrpcframe format and<base>/rpcconvention), not a generalizedalarm.MQTTPublisher. The alarm path must never block on a reader round trip./APIunreachable, a backwards-wired GPO) used to be dropped at Debug without readingresp.Error— it now logs at Warn with id/code/message, so a silent no-actuation is greppable.alarm.Dispatcher.Set— routes on transport, then type. A GPO device on a non-mqtt transport is rejected up front.auto_off_secondsbehaviour carries over.Reader addressed by a
scan_device_idFK (not a free-text topic)A GPO output device is addressed by a nullable
output_devices.scan_device_idFK toscan_devices(migration 000033), not by a topic string. The reader's RPC base topic is derived server-side from that reader'spublish_topicat 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_topicwas published to with the shared backend broker credential, so an org could have fired another tenant's reader. Now:scan_device_idto reference a livecsl_cs463reader in the org that has apublish_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.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_idis 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, includingswitch_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 -racegreen 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-existinguseReportHydrationflake (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-rpcdoncs463-212, acsl_cs463_gpooutput 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.The GPO is a polarized switch. Current enters
GPO(+)pin 4 and exitsGPO(−)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 anddocs/cs463-reader-references.md.Deferred (follow-ups, not blockers)
Gpo.Set. Worth a startup drive-all-low; deferred to its own ticket.resp.Erroron 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.switch_id/reader edits are row-owned per TRA-940); non-obvious but recoverable.scan_device_idFK has noON DELETE— soft-delete + the fire-timedeleted_at IS NULLjoin means soft-deleting a referenced reader disables its GPO alarms fail-closed, consistent with the design.sync-previewtrigger gap (surfaced deploying this PR) — the workflow filters draft PRs but doesn't listready_for_reviewin itspull_requesttypes, so marking a PR ready never syncs it to preview. One-line fix (addready_for_reviewto the trigger). Separate small PR.Refs TRA-1028.
🤖 Generated with Claude Code