Skip to content

feat: add unified relay service - #1

Merged
BK1031 merged 11 commits into
mainfrom
bk1031/relay
Aug 29, 2026
Merged

feat: add unified relay service#1
BK1031 merged 11 commits into
mainfrom
bk1031/relay

Conversation

@BK1031

@BK1031 BK1031 commented Aug 18, 2026

Copy link
Copy Markdown
Contributor
  • Add relay/ Go service: reads socketcan interfaces directly (raw AF_CAN, one reader per interface with reopen backoff) and publishes to local + cloud MQTT brokers
  • Topic scheme p987/{vehicle_id}/{bus}/0x{can_id} with bus labels from CAN_INTERFACES (can0:pcan); virtual/housekeeping frames publish under tcm
  • Keep TCM-26 wire format for CAN messages (u64 BE µs timestamp | u16 BE upload key | raw CAN bytes), QoS 0, per-bus+ID throttling (20ms local / 100ms cloud)
  • Buffer every frame to SQLite (WAL, pure-Go driver, shelter-compatible schema p987_message with partial unsynced index)
  • Keep 72-byte UDP virtual CAN ports for shelter injection, minus the SPI byte-swap path
  • Make local broker optional (empty LOCAL_MQTT_HOST disables, matching cloud behavior)
  • Shrink DB queue default to 50k slots and add hourly RETENTION_HOURS purge for synced messages + old pings (Pi Zero 2 W RAM/SD constraints)
  • Port ping/pong RTT, TCM status 0x200, clock plausibility
  • Add dev docker-compose (relay + nanomq), example.env, multi-arch GHCR workflow (tcm-987/relay), scripts/release.sh, scripts/setup-can.sh (listen-only bring-up, vcan support)

BREAKING — resource metrics 0x201 is now a 29-byte Pi-native layout, not TCM-26's 44 bytes:

  • 4 cores instead of 6; GPU util/freq/temp and the voltage/current/power rails are gone (a Pi Zero 2 W reports none of them)
  • Adds a throttle byte: under-voltage and thermal throttling, live and since-boot, from the firmware get_throttled word with the rpi_volt hwmon alarm as fallback
  • Any decoder written against the 44-byte layout needs updating. Nothing decodes p987 today, so nothing breaks yet

Review fixes:

  • Retry transient SQLite failures instead of discarding the batch; close the queue under the write lock so a concurrent sender can't hit a closed channel
  • WORKDIR /data so the default DATABASE_PATH resolves inside the declared volume
  • Mutex around the MQTT subscription registry (concurrent map iteration/write on a fast connect)
  • Compare ping timestamps in microseconds — the staleness warning could never fire

Also in this branch:

  • Flush the write queue and close the database on SIGTERM; docker stop previously dropped up to a full batch
  • Replace the goroutine-per-frame publish with a bounded per-broker queue, so a slow uplink can't stall the socketcan reader and drop frames in the kernel
  • Route paho's errors into zap (they were going to NOOPLogger, so a misconfigured cloud broker retried forever in silence); validate and log both broker endpoints
  • Silence GORM record not found, which wrote a colorized SQL dump to the SD card every 2.3s while the car was offline
  • Reject VEHICLE_ID / bus labels containing /, +, # — they break the 4-segment topic shape the ingest requires and the message is dropped server-side with no signal back

Verified by running the arm64 image: 5.8 MiB RSS steady state, 41.4 MB image, database lands at /data/relay.db, and a SIGTERM 3ms after injecting frames persists all of them.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 76a78fece7

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread relay/service/dbqueue.go Outdated
Comment on lines +104 to +105
if result.Error != nil {
utils.SugarLogger.Errorf("[DB] Failed to batch insert %d messages: %v", len(batch), result.Error)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve batches when SQLite writes fail

When SQLite returns a transient error—such as database is locked while the documented shelter process accesses the shared file—this branch only logs it; the worker immediately resets the batch after writeBatch returns, permanently discarding every frame in it. Requeue or retry failed batches so a temporary database problem does not corrupt telemetry capture.

Useful? React with 👍 / 👎.

Comment thread relay/utils/config.go
Comment on lines +22 to +24
if config.DatabasePath == "" {
config.DatabasePath = "relay.db"
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Put the default database inside the declared volume

When the production image is run without DATABASE_PATH, this relative fallback resolves to /relay.db because the final Docker stage has no WORKDIR; the image's declared /data volume is therefore unused. Recreating or upgrading the container then deletes all buffered telemetry despite mounting /data, so the container default should resolve inside that volume.

Useful? React with 👍 / 👎.

Comment thread relay/mqtt/mqtt.go
utils.SugarLogger.Warnf("[MQ][cloud] Cannot subscribe to %s: CLOUD_MQTT_HOST not configured", topic)
return
}
subscribedTopics[topic] = handler

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Synchronize subscription registry access

When the cloud connection completes while the SubscribePong startup goroutine is registering its handler, this map write can overlap onConnectFn iterating the same map. Native Go maps do not permit concurrent iteration and writes, so an ordinary fast broker connection can race or terminate the relay with concurrent map iteration and map write; protect the registry with a mutex or finish registration before connecting.

Useful? React with 👍 / 👎.

Comment thread relay/service/ping.go Outdated
warnAfter := config.PingInterval * 2
for {
lastPing := FindLastSuccessfulPing()
ageMs := time.Now().UnixMilli() - int64(lastPing.Ping)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Compare ping timestamps in microseconds

After the first successful pong, lastPing.Ping contains the UnixMicro value written by PublishPing, but this subtracts it from UnixMilli. The resulting age is a large negative number, so the warning condition never becomes true during subsequent Mapache outages; use matching timestamp units before comparing with warnAfter.

Useful? React with 👍 / 👎.

BK1031 added 9 commits August 29, 2026 09:02
PublishPing stores the ping as UnixMicro, but the staleness watchdog
subtracted it from UnixMilli. The resulting age was a large negative
number, so the warning never fired during a Mapache outage — the exact
condition it exists to report.

Rate-limit the warning to once a minute now that it can actually fire.
The check itself stays on its old cadence so TCM Status reacts quickly,
but a road car sits unreachable for days at a time and logging every
poll would put tens of thousands of lines a day onto the SD card.
writeBatch only logged the error and the worker reset the batch
immediately after, so a single transient failure discarded every frame in
it — up to DB_BATCH_SIZE of them. "database is locked" is expected in
normal operation once shelter holds the same file, so this would put
holes in the telemetry capture rather than being a rare edge case.

Retry with a short backoff before giving up. The insert is a single
transaction, so a failed attempt wrote nothing and a retry cannot
duplicate rows.

Also close the queue under the write lock. QueueDBWrite checked the
stopped flag and then sent on the channel, so a sender that passed the
check before StopDBQueue closed it would panic on a closed channel.
The final image stage has no WORKDIR, so the built-in relative
DATABASE_PATH default resolved to /relay.db and the declared /data volume
went unused. Recreating or upgrading the container then discarded all
buffered telemetry despite /data being mounted.
Every CAN frame spawned a goroutine to publish itself, which puts the
whole bus through the scheduler on a single-board target. Publishing
inline instead is only safe if publishing cannot block: paho's Publish
waits on an internal channel bounded by MessageChannelDepth and gives up
only after WriteTimeout (30s by default), so a broker that is connected
but slow — a degrading cell uplink rather than a dropped one — would
stall the socketcan reader. That stalls unix.Read, overruns the kernel
CAN buffer, and drops frames before the relay ever sees them.

Give each broker its own queue drained by its own worker, so a slow cloud
uplink cannot block a local consumer, and shed with a periodic count
rather than a line per frame. Skip the throttle bookkeeping and the
payload allocation entirely when no broker wants the frame, which is the
common case under the publish intervals.

Guard the subscription registry with a mutex. Subscribe registers from
the SubscribePong goroutine while onConnect ranges over the same map, so
a fast broker connection could terminate the relay with "concurrent map
iteration and map write".

Handle SIGINT/SIGTERM so the write queue flushes and the database closes
on the way out. Previously docker stop discarded whatever was buffered,
up to a full batch of frames.
The 0x201 layout was inherited from TCM-26's Jetson and carried fields
the Pi cannot produce: two extra CPU cores, GPU utilization, frequency
and temperature, and three power-rail readings. Sharing the Mapache
decoder was the stated reason to keep them, but no p987 decoder exists
yet, so the compatibility is with nothing.

Cut the layout to what the board actually reports — 4 cores, no GPU, no
power rails — and spend one of the freed bytes on the failure mode this
hardware really has. Under-voltage on a Pi corrupts SD cards and is
invisible in every other metric.

The firmware get_throttled word is the only place the live throttle bits
are exposed, since the Pi throttles in firmware rather than through the
kernel thermal governor. That attribute is deprecated upstream, so fall
back to the rpi_volt hwmon alarm, which carries the sticky under-voltage
bit only (BIT(16)) and nothing thermal.

Saturate the encoded fields rather than truncating: a bogus reading
should pin a field, not alias to a plausible small number on a dashboard.

BREAKING CHANGE: the 0x201 payload is now 29 bytes with a new field
order. Any decoder written against the 44-byte TCM-26 layout must be
updated.
paho's loggers default to NOOPLogger, and the cloud client sets
ConnectRetry, so a wrong host, port, or credential retried forever in
complete silence — the only symptom was pongs that never arrived. Route
paho's errors into zap, dropping the stacktrace (always the same paho
internals) and sampling to one line per message per minute, since an
unreachable broker retries every 5s indefinitely.

Fail fast when a broker host is set without a port, which previously
produced a "tcp://host:" URL, and log both endpoints at startup so the
configured target is visible rather than inferred.

Log the database path resolved rather than raw. The default is relative
and lands wherever the workdir points, which is what made the volume bug
above hard to see.

Silence GORM's "record not found", which is a normal result here: the
ping watchdog polls for a successful pong every couple of seconds and
finds none until the car first reaches Mapache. At the default level that
wrote a colorized SQL dump to the SD card on every poll, forever, while
the car was offline. Drop the ANSI color outside DEV too.
Vehicle identity travels in the topic, not the payload, and Mapache's
ingest splits on "/" and requires exactly four segments, reading the
vehicle from segment 1. A VEHICLE_ID or bus label containing a slash
shifts every field and the message is dropped on arrival — silently,
because we publish at QoS 0 and never learn it was rejected. The car
would buffer to SQLite and upload nothing. MQTT also forbids wildcards
in a topic being published to.

Refuse to start rather than sanitizing: a silently renamed vehicle
scatters data under the wrong id, which is worse than not booting.
The encoders and parsers are pure functions over bytes and are decoded
downstream by fixed offset, so they are the parts worth pinning. Covers
the shared message header, the 0x200 and 0x201 payloads, the virtual CAN
and socketcan frame parsers, the publish throttle, interface and port
parsing, and the topic-shape guard.

Includes cases for the two bugs that motivated the parsers being
extracted: that parsed frame data is copied out of a reused read buffer,
and that a CAN FD DLC on a classic socket is clamped to 8 bytes.
Gate the image build on the tests so a tree that fails its own suite
cannot be pushed to GHCR.
Matches how the rest of the services are set up.
@BK1031
BK1031 merged commit 374c80b into main Aug 29, 2026
3 checks passed
@BK1031
BK1031 deleted the bk1031/relay branch August 29, 2026 16:47
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