Skip to content

feat(p987): add ingest service for the 987 live data path - #226

Merged
BK1031 merged 3 commits into
mainfrom
bk1031/p987-ingest
Aug 29, 2026
Merged

feat(p987): add ingest service for the 987 live data path#226
BK1031 merged 3 commits into
mainfrom
bk1031/p987-ingest

Conversation

@BK1031

@BK1031 BK1031 commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

Mapache had no p987 anything, so the TCM-987 relay has been publishing into the void. This adds the cloud-side ingest for the live data path, mirroring gr26.

  • p987/ service: subscribe p987/# (shared subscription) → validate upload key → decode → ClickHouse → republish decoded signals to query/live/{vehicle}/{signal} for live
  • Ping/pong handler so the relay's TCM Status mapache_ok bit and RTT work
  • p987_can ClickHouse table, same shape as gr26_can; signals go to the shared signal table
  • API: /p987/ping, /p987/messages/:id, /p987/signals/:id
  • Wired into docker-compose.yaml (port 7020), kerbecs.yaml, and a p987 build workflow

Signal definitions

Declared as mp.Message values the same way gr26 does, split by ECU the way gr26 splits by node — sccm.go, dme.go, psm.go, pdk.go, gateway.go, pas.go, klima.go. 30 messages, 214 signals, transcribed from cayman_987.dbc in the TCM-987 repo.

Stock Porsche signals don't sit on byte boundaries, so a field is the contiguous run of bytes its signals occupy and each signal is shifted and masked out of the field value — the same shape as gr26's TCMStatus bitfield, used more widely. SCCM_SteeringAngle (13 bits at bit 2, ×0.175) becomes a shift of 2 inside the 2-byte field covering bytes 0–1.

The transcription was checked against a DBC-driven decoder before that code was removed: every message, 200 random payloads each, 39,600 signal values compared, all matching.

Things worth knowing

  • Four 64-bit signals export nothingECU_ID1_Bytes, ECU_Coding_Bytes, GW_Config_Data, GW_Network_Data. They're opaque identification/config blobs, and 64 bits fits neither the int a field decodes into nor the float64 a signal carries. Bytes stay in the stored frame. Same treatment gr26 gives its ULID field.
  • 12 of 214 signals aren't decoded — the multiplexed ones on DME2/DME3. The DBC declares them (m0m3) but never declares the multiplexer switch, so there's no way to know which variant a frame carries. Worth fixing in the DBC.
  • Lookup is keyed on bus as well as id. Shelter injects at 0x210/0x211 through the relay's virtual CAN port, and 0x210 is also SCCM2 on the car's own bus — same id, different message, told apart only by which bus it arrived on.
  • Frame length must match exactly. FillFromBytes requires it, so a frame whose DLC differs from the declared message length records decode_error and decodes nothing. Stricter than the DBC decoder was.
  • Bus label replaces gr26's node segment, in the topic and in node_id. On stock CAN the sender is implied by the arbitration id — the bus is the only routing fact the id can't carry, and two buses have independent 11-bit id spaces, so it's part of the natural key. Signals are bus-prefixed (pcan_SCCM_SteeringAngle), which keeps the signal→frame join working.
  • 0x200/0x201 are declared here, not on the car's bus. 0x201 follows TCM-987's 29-byte Pi layout (4 cores, no GPU/power rails, throttle byte), not TCM-26's 44-byte Jetson layout. Depends on feat: add unified relay service TCM-987#1.

Undecodable frames are still persisted with a status in metadata (unknown_can_id, decode_error, invalid_timestamp) so an unknown id can be reverse-engineered later.

Verified end to end

Ran the actual TCM-987 relay against nanomq and this service:

  • tcm_mapache_ok = 1, tcm_mapache_ping = 2 — the relay only sets that bit after receiving a fresh pong, so the round trip works in both directions
  • pcan_SCCM_SteeringAngle = 175 from a hand-computed frame, matching the expected 13-bit extraction and ×0.175 scaling
  • All 24 signals of the 29-byte 0x201 layout decoded correctly, including throttle flags
  • An id absent from the registry produced no signals and no crash

Re-run after the refactor with identical results.

Not included

Job/batch handlers, Foreman workers, and the Epic Shelter cold-storage ingest — live path only, per scope.

Nothing consumed the p987 topic namespace, so the TCM-987 relay was
publishing into the void. This mirrors gr26's live path — subscribe,
validate, decode, persist, republish — for the Porsche 987.

Decoding works differently, and that is the substance of this service.
gr26 describes each frame as a list of whole-byte fields via mapache-go's
Message type, which is workable when the CAN layout and the decoder were
designed together. Stock Porsche CAN is not ours to design: signals start
at arbitrary bit offsets, run arbitrary bit lengths, and carry a scale and
offset (SCCM_SteeringAngle is 13 bits starting at bit 2, x0.175 deg). So
p987 decodes from the DBC directly, embedded in the binary so a malformed
file fails at startup rather than silently decoding nothing.

Multiplexed signals are skipped. Resolving them needs the message's
multiplexer switch signal, and the 987 DBC declares multiplexed signals
without ever declaring the switch — there is no correct way to know which
variant a frame carries, and guessing emits three wrong values per right
one. 12 of 214 signals are affected, all on DME2/DME3.

The bus label takes gr26's node segment, in the topic and in node_id. On
stock CAN the sender is implied by the arbitration id; the bus is the only
routing fact the id cannot carry, and two buses have independent 11-bit id
spaces, so it belongs in the natural key.

TCM housekeeping frames are decoded here rather than from the DBC, since
they never touched a physical bus. 0x201 follows TCM-987's 29-byte Pi
layout — 4 cores, no GPU or power rails, plus the throttle byte — not
TCM-26's 44-byte Jetson layout.

Frames that cannot be decoded are still persisted with a status in
metadata (unknown_can_id, short_frame, invalid_timestamp), which is how an
unknown id gets reverse-engineered later.

Adds /p987/dbc and /p987/dbc/:id so the decoder registry can be inspected
without waiting for a frame to arrive, and a signal-level trace on the
frame endpoints showing bit placement and scaling.

Excludes the job/batch and shelter cold-storage path — this is the live
path only.
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Aug 29, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-08-29T16:38:21.412040Z 8e5f121 PR opened
ℹ️ 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" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

Matches how the rest of the services are set up.

@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: 8e5f121c79

ℹ️ 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 p987/service/message.go
logger.SugarLogger.Infof("[MQ] Received invalid can id: %s, ignoring", canID)
return
}
go HandleMessage(vehicleID, bus, int(canIDInt), payload)

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 Bound concurrent frame processing

Every valid CAN topic starts a new goroutine, while each handler can block on upload-key HTTP validation, ClickHouse inserts, and MQTT publishing. During normal high-rate CAN traffic, any period where one of those downstream systems processes frames more slowly than they arrive causes goroutines and retained payloads to accumulate without limit, eventually exhausting memory and dropping the entire live feed. Dispatch frames through a bounded worker pool or queue so ingestion has an explicit concurrency limit.

Useful? React with 👍 / 👎.

Comment thread p987/service/vehicle.go
return mapache.Vehicle{}, false
}

resp, err := http.Get(upstreamURL)

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 Set a deadline on vehicle lookups

When authentication is enabled and the vehicle service accepts a connection but never returns headers, http.Get uses the default client without a request timeout and can block forever. Because the cache is only populated after this call finishes, subsequent frames for the same vehicle start additional stuck lookups, compounding the live-ingest outage and resource leak. Use a client with a finite timeout or a request context deadline.

Useful? React with 👍 / 👎.

…sing a DBC

Replaces the runtime DBC parser with hand-declared mp.Message definitions,
matching how gr26 describes its messages.

Stock Porsche signals do not sit on byte boundaries, so a field here is
the contiguous run of bytes its signals occupy and each signal is shifted
and masked out of the field value — the same shape as gr26's TCMStatus
bitfield, just used more widely. SCCM_SteeringAngle, 13 bits starting at
bit 2 with a 0.175 scale, becomes a shift of 2 inside the two-byte field
covering bytes 0-1.

All 30 messages and 214 signals are declared, split by ECU the way gr26
splits by node. The transcription was checked against the previous DBC
decoder before that code was deleted: every message, 200 random payloads
each, 39,600 signal values compared, all matching.

Four 64-bit signals (ECU_ID1_Bytes, ECU_Coding_Bytes, GW_Config_Data,
GW_Network_Data) are declared as fields that export nothing. They are
opaque identification and configuration blobs, not physical quantities,
and 64 bits fits neither the int a field decodes into nor the float64 a
signal carries. The bytes remain in the stored frame, which is where you
would read them anyway. Same treatment gr26 gives its ULID field.

Multiplexed signals on DME2 and DME3 are still not decoded, for the same
reason as before: the DBC declares them without ever declaring the
multiplexer switch.

Message lookup is keyed on bus as well as id. Shelter injects frames at
0x210 and 0x211 through the relay's virtual CAN port, and 0x210 is also
SCCM2 on the car's own bus — same id, different message, told apart only
by which bus it arrived on.

Drops the /p987/dbc endpoints and restores gr26's field-level trace on the
frame endpoints.
@BK1031
BK1031 merged commit d161fca into main Aug 29, 2026
22 checks passed
@BK1031
BK1031 deleted the bk1031/p987-ingest 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