Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
146 changes: 146 additions & 0 deletions .claude/skills/sync-grcan/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
---
name: sync-grcan
description: Sync gr26/model CAN definitions with the Firmware GRCAN autogen artifacts. Use when CAN message IDs or signal layouts changed in Gaucho-Racing/Firmware and the gr26 Go models (message.go + per-node model files) need to catch up. Fetches the latest ID headers + DBC files, diffs against the committed snapshot, auto-applies confident changes, and opens a PR flagging everything that needs human judgment.
---

# sync-grcan

Keep `gr26/model/` in step with the CAN definitions generated by
`Gaucho-Racing/Firmware` (`Autogen/CAN/`). The source of truth is **not** the
231 KB `GRCAN.CANdo` file — it is the small generated artifacts:

- `Inc/GRCAN_MSG_ID.h`, `Inc/GRCAN_CUSTOM_ID.h` — base/custom CAN IDs (the join
keys + values that drive `model/message.go`'s `messageMap`).
- `Doc/GRCAN_Primary.dbc`, `GRCAN_Data.dbc`, `GRCAN_Charger.dbc` — signal
layouts: start bit, length, endianness, signedness, scale, offset, unit.

A committed snapshot of these lives in `gr26/tools/grcan/snapshot/`. The diff
between that snapshot and the latest firmware tells you *exactly* what changed,
so you never read the whole CANdo file.

## Tooling

- `gr26/tools/grcan/fetch.sh <dir> [ref]` — download the 7 artifacts via `gh`.
- `gr26/tools/grcan/grcan_sync.py` — stdlib parser/differ (use system
`python3`, **not** `gr26/tools/.venv`, which is stale):
- `parse <dir>` → normalized JSON model.
- `diff <old_dir> <new_dir>` → change report + Go scaffolds (`--pretty` for
human-readable). The report has: `ids_added`, `ids_changed`, `ids_removed`,
`ids_added_without_layout`, `layout_changed`, and `scaffolds` (each with a
`go` literal and a `flags` list of judgment items).
- `reconcile <model_dir> <fw_dir>` → audit the *current Go models* against the
firmware directly (models-vs-firmware, not snapshot-vs-firmware). Reports
`in_sync`, `diverged` (each split into `structural` issues — byteLen /
endianness / signedness / field count / presence — and lower-priority
`naming` issues, plus a `scaffold`), `generated_skipped` (factory/dynamic
vars like `BCUCellData*`, `ECUPingingRTT` that need manual review),
`messagemap_id_without_layout` (mapped IDs with no DBC layout, e.g.
`TCM_STATUS`), and `untracked_firmware_messages` (informational).

Join is by CAN ID: the messageMap gives ID→Go-var, the firmware gives ID→layout.
The DBC composite encodes the base ID at `(id >> 8) & 0x7FF`; matching falls back
to that when the DBC's logical name omits the node prefix (`Dash_Panel_Status` vs
`DASH_STATUS`). Custom messages (DTI) match by name instead.

The tool only extracts data and scaffolds Go. **Translating scaffolds into
idiomatic, hand-tuned Go is your job** — guided by the `flags`.

## How the model maps

| Source | gr26 target |
|---|---|
| `GRCAN_<NAME> = 0xNNN` (MSG_ID.h) | `0xNNN: <Name>,` in `message.go` `messageMap` |
| `<NAME>_CAN_ID = 0xNNN` (CUSTOM_ID.h) | entry under the `// custom CAN IDs` block |
| DBC message `<Sender>_<Name>_to_<Rx>` | `var <Name> = mp.Message{...}` in the per-node file |
| DBC signal `s : start|len@order± (scale,offset)` | one `mp.NewField(...)` |

Canonical join key = enum minus `GRCAN_`/`_CAN_ID` = DBC name minus the
`<sender>_` prefix and `_to_<rx>` suffix (all uppercased). E.g.
`GRCAN_ECU_STATUS_1` ↔ `ECU_ECU_Status_1_to_ALL` ↔ `ECUStatus1`.

Per-node model files: `ecu.go`, `bcu.go`, `inverter.go`, `fan.go`, `dash.go`,
`tcm.go`, `gps.go`, `dti.go`, `ping.go`. Put a new message in the file matching
its node/sender.

DSL mapping: `byteLen = len/8`; `@1`→`mp.LittleEndian`, `@0`→`mp.BigEndian`;
`+`→`mp.Unsigned`, `-`→`mp.Signed`; `Value: float64(raw)*scale (+offset)`;
sub-byte / multiple-signals-per-byte → one field whose closure emits each
`Signal` via mask/shift.

## Workflow

### Phase 0 — bootstrap / audit (`reconcile`)

The snapshot-diff below only catches drift *since the last snapshot*. The
committed snapshot was seeded from `main`, and the models are known to predate
it — so a plain `diff` reports "in sync" while real divergence exists. Before
relying on incremental diffs, and any time you suspect the models are stale,
run a full audit:

```sh
python3 gr26/tools/grcan/grcan_sync.py reconcile gr26/model gr26/tools/grcan/snapshot --pretty
```

Treat `diverged[*].structural` as the actionable list (endianness, byteLen,
signedness, missing/extra fields). `naming` differences are mostly intentional
abbreviations (`ts_voltage` vs `tractive_system_voltage`) — surface but don't
churn them unless asked. Apply fixes + flag judgment items exactly as in Phase
1 below, and open the same kind of PR. Known standing divergences to expect:
the DTI messages (model uses BigEndian + scaling; DBC says LittleEndian) and
IEEE-float GPS fields — confirm intent with the firmware team rather than
blindly conforming the models to the DBC.

### Phase 1 — sync and open a PR (default)

1. **Branch.** From an up-to-date `main`, create `jake/grcan-sync-<date>` (do
not work on `main`).
2. **Fetch latest** into a temp dir: `bash gr26/tools/grcan/fetch.sh /tmp/grcan_new`.
3. **Diff:** `python3 gr26/tools/grcan/grcan_sync.py diff gr26/tools/grcan/snapshot /tmp/grcan_new --pretty`.
If empty, stop and report "already in sync" — do not open a PR.
4. **Auto-apply the confident changes:**
- `ids_added` / `ids_changed` / `ids_removed` → edit `message.go` `messageMap`
(add, repoint, or remove the entry; keep the node-grouped comment layout).
- `layout_changed` and new IDs that have a layout → use the scaffold to add
or update the `var <Name> = mp.Message{...}` in the right model file.
- **Preserve existing hand-tuned details on *changed* messages**: keep the
current field/signal names (the DBC uses verbose names like
`Tractive_System_Voltage` where the model uses `ts_voltage`), keep
IEEE-float decoders (`math.Float64frombits`) and exact scale fractions
(`* 20.0 / 51.0`). Only apply the structural delta the diff shows.
5. **Flag everything that needs judgment** — do NOT silently resolve these.
Insert a `// TODO(grcan-sync): <reason>` comment at each spot and collect
them for the PR body. Always-flag cases:
- Every entry in each scaffold's `flags` list (bit-packed groupings + field
naming, rounded-fraction scales, possible IEEE floats, DLC overflow).
- `ids_added_without_layout` — custom IDs with no DBC layout (e.g. the
third-party DTI inverter, charger, IMD). Add the `messageMap` entry but
leave a stub/TODO for the field definitions; do not invent a layout.
- `ids_removed` — decide deprecate vs delete; default to flagging, not
deleting, unless the user said otherwise.
- Any divergence where the existing Go disagrees with the DBC on endianness
or scale (the DTI messages are known to differ — surface, don't overwrite).
6. **Build:** `cd gr26 && go build ./...` (and `go vet ./model/` if quick). Fix
compile errors from your edits. Do **not** touch the snapshot yet.
7. **Open the PR** with `gh`. The body must contain a checklist of every
`TODO(grcan-sync)` flag, grouped by message, each as `- [ ] <file:sym> —
<reason>`. Title: `chore(gr26): sync CAN models with firmware GRCAN`. Tell
the user the branch/PR and that you'll wait for their review.

### Phase 2 — finish (`/sync-grcan --finish`, or re-invoke pointing at the PR)

1. Read the PR review comments and the current state of the `TODO(grcan-sync)`
markers (the user may have resolved some by editing directly).
2. Apply the remaining decisions: resolve/replace each TODO, incorporate review
comments. Remove the `TODO(grcan-sync)` markers as you close them.
3. **Update the snapshot last:** `bash gr26/tools/grcan/fetch.sh gr26/tools/grcan/snapshot`
so the committed baseline now equals what you synced to. (Sanity:
`grcan_sync.py diff` of the new snapshot vs `/tmp/grcan_new` should be empty.)
4. `go build ./...`, commit, push. Leave the merge to the user.

## Notes

- Requires authenticated `gh`. Default firmware ref is `main`; pass a ref to
`fetch.sh` to pin a tag/commit.
- Never edit `gr26/tools/.venv` — unrelated stale venv.
- If `gh` can't reach the Firmware repo, report it; don't fall back to scraping
the CANdo file.
27 changes: 27 additions & 0 deletions gr26/model/brake.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package model

import mp "github.com/gaucho-racing/mapache/mapache-go/v3"

// TODO(grcan-sync): DLC is 16 (CAN-FD); confirm decoder handles >8 bytes.
var BrakeTemp = mp.Message{
mp.NewField("temp", 2, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal {
return []mp.Signal{
{Name: "temp", Value: float64(f.Value), RawValue: f.Value},
}
}),
mp.NewField("_reserved", 14, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal {
return nil
}),
}

// TODO(grcan-sync): DLC is 16 (CAN-FD); confirm decoder handles >8 bytes.
var WheelSpeed = mp.Message{
mp.NewField("speed", 2, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal {
return []mp.Signal{
{Name: "speed", Value: float64(f.Value) * 0.125, RawValue: f.Value},
}
}),
mp.NewField("_reserved", 14, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal {
return nil
}),
}
11 changes: 11 additions & 0 deletions gr26/model/dti.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@ package model

import mp "github.com/gaucho-racing/mapache/mapache-go/v3"

// TODO(grcan-sync): the firmware DBC describes all DTI_Data frames as
// LittleEndian with most fields unsigned and scale 1, whereas these models use
// BigEndian + signed + scaling. The DTI is a third-party inverter, so the
// hand-tuned values here are probably the correct ones and the DBC may be a
// placeholder - do NOT conform to the DBC without confirming against the DTI
// datasheet / a live frame capture. (Affects DTIData1-5.)
var DTIData1 = mp.Message{
mp.NewField("erpm", 4, mp.Signed, mp.BigEndian, func(f mp.Field) []mp.Signal {
return []mp.Signal{
Expand Down Expand Up @@ -70,6 +76,11 @@ var DTIData4 = mp.Message{
}),
}

// TODO(grcan-sync): beyond the endianness note above, the DBC defines a richer
// DTI_Data_5 layout than this model: digital_io as 8 discrete digital_input/
// output bits, limit_flags as 8 named limit bits (1 byte, not 2), three
// rpm/power limit values where this model has reserved bytes, and a trailing
// can_version byte. Reconcile against the DTI datasheet before expanding.
var DTIData5 = mp.Message{
mp.NewField("throttle", 1, mp.Unsigned, mp.BigEndian, func(f mp.Field) []mp.Signal {
return []mp.Signal{
Expand Down
8 changes: 8 additions & 0 deletions gr26/model/ecu.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,14 @@ var ECUStatus3 = mp.Message{
{Name: "rr_wheel_rpm", Value: float64(f.Value)*0.1 - 3276.8, RawValue: f.Value},
}
}),
// Added by grcan sync: firmware ECU_Status_3 (dlc 5) carries Relay_States
// at byte 4. We expose the whole byte; the DBC documents bit 0 only.
// TODO(grcan-sync): confirm the full relay bit layout with firmware.
mp.NewField("relay_states", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal {
return []mp.Signal{
{Name: "relay_states", Value: float64(f.Value), RawValue: f.Value},
}
}),
}

var ECUAnalogData = mp.Message{
Expand Down
115 changes: 115 additions & 0 deletions gr26/model/em.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
package model

import mp "github.com/gaucho-racing/mapache/mapache-go/v3"

// Energy Meter (EM) frames - external Charger-bus device logging current,
// voltage, accumulated energy and pack temperatures.

// TODO(grcan-sync): current: 32-bit raw - if firmware sends IEEE float, decode with math.Float32frombits instead.
// TODO(grcan-sync): voltage: 32-bit raw - if firmware sends IEEE float, decode with math.Float32frombits instead.
var EMMeas = mp.Message{
mp.NewField("current", 4, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal {
return []mp.Signal{
{Name: "current", Value: float64(f.Value), RawValue: f.Value},
}
}),
mp.NewField("voltage", 4, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal {
return []mp.Signal{
{Name: "voltage", Value: float64(f.Value), RawValue: f.Value},
}
}),
}

// TODO(grcan-sync): violation_logging: bit-packed field (2 signals share bytes 0-0); review masks/shifts and pick a field name.
// TODO(grcan-sync): energy: bit-packed field (1 signals share bytes 1-1); review masks/shifts and pick a field name.
var EMStatus = mp.Message{
mp.NewField("violation_logging", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal {
return []mp.Signal{
{Name: "violation", Value: float64(f.Value & 0x1), RawValue: f.Value & 0x1},
{Name: "logging", Value: float64((f.Value >> 1) & 0xF), RawValue: (f.Value >> 1) & 0xF},
}
}),
mp.NewField("energy", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal {
return []mp.Signal{
{Name: "energy", Value: float64(f.Value & 0xF), RawValue: f.Value & 0xF},
}
}),
mp.NewField("_reserved", 6, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal {
return nil
}),
}

// TODO(grcan-sync): team_signal_1: 32-bit raw - if firmware sends IEEE float, decode with math.Float32frombits instead.
// TODO(grcan-sync): team_signal_2: 32-bit raw - if firmware sends IEEE float, decode with math.Float32frombits instead.
var EMTeamData1 = mp.Message{
mp.NewField("team_signal_1", 4, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal {
return []mp.Signal{
{Name: "team_signal_1", Value: float64(f.Value), RawValue: f.Value},
}
}),
mp.NewField("team_signal_2", 4, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal {
return []mp.Signal{
{Name: "team_signal_2", Value: float64(f.Value), RawValue: f.Value},
}
}),
}

// TODO(grcan-sync): team_signal_3: 32-bit raw - if firmware sends IEEE float, decode with math.Float32frombits instead.
// TODO(grcan-sync): team_signal_4: 32-bit raw - if firmware sends IEEE float, decode with math.Float32frombits instead.
var EMTeamData2 = mp.Message{
mp.NewField("team_signal_3", 4, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal {
return []mp.Signal{
{Name: "team_signal_3", Value: float64(f.Value), RawValue: f.Value},
}
}),
mp.NewField("team_signal_4", 4, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal {
return []mp.Signal{
{Name: "team_signal_4", Value: float64(f.Value), RawValue: f.Value},
}
}),
}

// TODO(grcan-sync): mux_signal_num_sensors: bit-packed field (2 signals share bytes 0-0); review masks/shifts and pick a field name.
var EMTemp = mp.Message{
mp.NewField("mux_signal_num_sensors", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal {
return []mp.Signal{
{Name: "mux_signal", Value: float64(f.Value & 0x1), RawValue: f.Value & 0x1},
{Name: "num_sensors", Value: float64((f.Value >> 3) & 0xF), RawValue: (f.Value >> 3) & 0xF},
}
}),
mp.NewField("min_temp", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal {
return []mp.Signal{
{Name: "min_temp", Value: float64(f.Value), RawValue: f.Value},
}
}),
mp.NewField("max_temp", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal {
return []mp.Signal{
{Name: "max_temp", Value: float64(f.Value), RawValue: f.Value},
}
}),
mp.NewField("temp_5n", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal {
return []mp.Signal{
{Name: "temp_5n", Value: float64(f.Value), RawValue: f.Value},
}
}),
mp.NewField("temp_5n1", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal {
return []mp.Signal{
{Name: "temp_5n1", Value: float64(f.Value), RawValue: f.Value},
}
}),
mp.NewField("temp_5n2", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal {
return []mp.Signal{
{Name: "temp_5n2", Value: float64(f.Value), RawValue: f.Value},
}
}),
mp.NewField("temp_5n3", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal {
return []mp.Signal{
{Name: "temp_5n3", Value: float64(f.Value), RawValue: f.Value},
}
}),
mp.NewField("temp_5n4", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal {
return []mp.Signal{
{Name: "temp_5n4", Value: float64(f.Value), RawValue: f.Value},
}
}),
}
Loading
Loading