From a997749a2003ec207518381447fbd2f1e3a70c78 Mon Sep 17 00:00:00 2001 From: Bharat Kathi Date: Thu, 7 May 2026 18:02:23 -0700 Subject: [PATCH 1/8] feat(gr26): persist incoming CAN frames and link to decoded signals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds two gr26-local tables. gr26_can stores the raw CAN frame (vehicle, node, can id, post-MQTT-header bytes, timestamp) with a composite unique key on (vehicle_id, node_id, timestamp). gr26_can_signal is a 1-N join keyed on signal_id that maps each persisted signal back to the frame it was decoded from. Both tables are gr26-only so the schema can change year-to-year without touching the shared signal table or mapache-go. HandleMessage now upserts the gr26_can row first (returning the stored ulid), persists the decoded signals, then bulk-inserts the join rows. The signal upsert in CreateSignals stops overwriting id on conflict and uses Returning so signals[i].ID reflects the actually-stored value across retransmits — the join table assumes signal ids are stable. WS publish moves to after the DB insert so subscribers see the persistent id rather than the freshly-generated-then-discarded one. --- gr26/database/db.go | 3 ++- gr26/model/can.go | 37 +++++++++++++++++++++++++++++++ gr26/service/can.go | 49 +++++++++++++++++++++++++++++++++++++++++ gr26/service/message.go | 32 ++++++++++++++++++++++++--- gr26/service/signal.go | 18 +++++++++------ 5 files changed, 128 insertions(+), 11 deletions(-) create mode 100644 gr26/model/can.go create mode 100644 gr26/service/can.go diff --git a/gr26/database/db.go b/gr26/database/db.go index d6ad7f4..48be14a 100644 --- a/gr26/database/db.go +++ b/gr26/database/db.go @@ -5,6 +5,7 @@ import ( "time" "github.com/gaucho-racing/mapache/gr26/config" + "github.com/gaucho-racing/mapache/gr26/model" "github.com/gaucho-racing/mapache/gr26/pkg/logger" "github.com/gaucho-racing/mapache/mapache-go/v3" @@ -30,7 +31,7 @@ func Init() { } } else { logger.SugarLogger.Infoln("Connected to database") - db.AutoMigrate(&mapache.Signal{}, &mapache.Ping{}) + db.AutoMigrate(&mapache.Signal{}, &mapache.Ping{}, &model.CAN{}, &model.CANSignal{}) logger.SugarLogger.Infoln("AutoMigration complete") DB = db } diff --git a/gr26/model/can.go b/gr26/model/can.go new file mode 100644 index 0000000..20d4241 --- /dev/null +++ b/gr26/model/can.go @@ -0,0 +1,37 @@ +package model + +import "time" + +// CAN is a stored record of a single decoded CAN frame received from a +// gr26 vehicle. The composite (vehicle_id, node_id, timestamp) tuple +// uniquely identifies a frame; ulid is the surrogate key used by the +// gr26_can_signal join table. +type CAN struct { + ID string `json:"id" gorm:"primaryKey"` + VehicleID string `json:"vehicle_id" gorm:"uniqueIndex:idx_gr26_can_unique"` + NodeID string `json:"node_id" gorm:"uniqueIndex:idx_gr26_can_unique"` + Timestamp int `json:"timestamp" gorm:"uniqueIndex:idx_gr26_can_unique"` + CANID int `json:"can_id"` + Bytes []byte `json:"bytes" gorm:"type:bytea"` + UploadKey int `json:"upload_key"` + ProducedAt time.Time `json:"produced_at" gorm:"precision:6"` + CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime;precision:6"` +} + +func (CAN) TableName() string { + return "gr26_can" +} + +// CANSignal joins each persisted signal back to the CAN frame it was +// decoded from. SignalID is the primary key because the relationship is +// one-to-many (one frame, many signals; each signal traces to exactly +// one frame). +type CANSignal struct { + SignalID string `json:"signal_id" gorm:"primaryKey"` + CANMessageID string `json:"can_message_id" gorm:"index"` + CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime;precision:6"` +} + +func (CANSignal) TableName() string { + return "gr26_can_signal" +} diff --git a/gr26/service/can.go b/gr26/service/can.go new file mode 100644 index 0000000..5adaad6 --- /dev/null +++ b/gr26/service/can.go @@ -0,0 +1,49 @@ +package service + +import ( + "github.com/gaucho-racing/mapache/gr26/database" + "github.com/gaucho-racing/mapache/gr26/model" + + ulid "github.com/gaucho-racing/ulid-go" + "gorm.io/gorm/clause" +) + +// CreateCAN inserts (or upserts) a CAN frame record and returns it with +// the stored id populated. Conflicts on (vehicle_id, node_id, timestamp) +// update the payload columns and leave the id stable. +func CreateCAN(can model.CAN) (model.CAN, error) { + can.ID = ulid.Make().Prefixed("can") + result := database.DB.Clauses(clause.OnConflict{ + Columns: []clause.Column{ + {Name: "vehicle_id"}, + {Name: "node_id"}, + {Name: "timestamp"}, + }, + DoUpdates: clause.AssignmentColumns([]string{"can_id", "bytes", "upload_key", "produced_at"}), + }, clause.Returning{Columns: []clause.Column{{Name: "id"}}}).Create(&can) + if result.Error != nil { + return model.CAN{}, result.Error + } + return can, nil +} + +// CreateCANSignals links each signal id to the CAN frame it was decoded +// from. Conflicts on signal_id update the can_message_id so the link +// always points at the most recent frame that produced the signal. +func CreateCANSignals(canMessageID string, signalIDs []string) error { + if len(signalIDs) == 0 { + return nil + } + rows := make([]model.CANSignal, len(signalIDs)) + for i, sid := range signalIDs { + rows[i] = model.CANSignal{ + SignalID: sid, + CANMessageID: canMessageID, + } + } + result := database.DB.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "signal_id"}}, + DoUpdates: clause.AssignmentColumns([]string{"can_message_id"}), + }).Create(&rows) + return result.Error +} diff --git a/gr26/service/message.go b/gr26/service/message.go index f0a124e..68c69d0 100644 --- a/gr26/service/message.go +++ b/gr26/service/message.go @@ -62,7 +62,8 @@ func HandleMessage(vehicleID string, nodeID string, canID int, message []byte) { uploadKey := message[8:10] data := message[10:] - if !ValidateUploadKey(vehicleID, int(binary.BigEndian.Uint16(uploadKey))) { + uploadKeyInt := int(binary.BigEndian.Uint16(uploadKey)) + if !ValidateUploadKey(vehicleID, uploadKeyInt) { logger.SugarLogger.Infof("Upload key validation failed for vehicle %s, ignoring", vehicleID) return } @@ -79,17 +80,42 @@ func HandleMessage(vehicleID string, nodeID string, canID int, message []byte) { return } - signals := messageStruct.ExportSignals() ts := int(binary.BigEndian.Uint64(timestamp)) + producedAt := time.UnixMicro(int64(ts)) + + can, err := CreateCAN(model.CAN{ + VehicleID: vehicleID, + NodeID: nodeID, + Timestamp: ts, + CANID: canID, + Bytes: data, + UploadKey: uploadKeyInt, + ProducedAt: producedAt, + }) + if err != nil { + logger.SugarLogger.Infof("Error creating CAN record: %s", err) + return + } + + signals := messageStruct.ExportSignals() now := time.Now().Truncate(time.Microsecond) for i := range signals { signals[i].Name = fmt.Sprintf("%s_%s", nodeID, signals[i].Name) signals[i].Timestamp = ts signals[i].VehicleID = vehicleID - signals[i].ProducedAt = time.UnixMicro(int64(ts)) + signals[i].ProducedAt = producedAt signals[i].CreatedAt = now } if err := CreateSignals(signals); err != nil { logger.SugarLogger.Infof("Error creating signals: %s", err) + return + } + + signalIDs := make([]string, len(signals)) + for i, s := range signals { + signalIDs[i] = s.ID + } + if err := CreateCANSignals(can.ID, signalIDs); err != nil { + logger.SugarLogger.Infof("Error creating CAN-signal links: %s", err) } } diff --git a/gr26/service/signal.go b/gr26/service/signal.go index dc8c6c7..477867c 100644 --- a/gr26/service/signal.go +++ b/gr26/service/signal.go @@ -66,19 +66,23 @@ func CreateSignals(signals []mapache.Signal) error { } signals[i].ID = ulid.Make().Prefixed("sgnl") } - if config.EnableSignalWS { - for _, signal := range signals { - Hub.Publish(signal) - } - } if config.EnableSignalDB { + // Preserve the existing id on conflict so the join table in + // gr26_can_signal stays valid across retransmits. Returning id + // rewrites our locally-generated ULID with the actually-stored + // one when a row already existed. result := database.DB.Clauses(clause.OnConflict{ Columns: []clause.Column{{Name: "timestamp"}, {Name: "vehicle_id"}, {Name: "name"}}, - DoUpdates: clause.AssignmentColumns([]string{"id", "value", "raw_value", "produced_at"}), - }).Create(&signals) + DoUpdates: clause.AssignmentColumns([]string{"value", "raw_value", "produced_at"}), + }, clause.Returning{Columns: []clause.Column{{Name: "id"}}}).Create(&signals) if result.Error != nil { return result.Error } } + if config.EnableSignalWS { + for _, signal := range signals { + Hub.Publish(signal) + } + } return nil } From 5ba5459e3801456f48ba7bffe9fe4036c1b8e42c Mon Sep 17 00:00:00 2001 From: Bharat Kathi Date: Thu, 7 May 2026 19:00:01 -0700 Subject: [PATCH 2/8] feat(gr26): persist raw frames even when they can't be decoded MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move CreateCAN ahead of the messageStruct lookup and FillFromBytes step so unknown can ids and decode failures still produce a gr26_can row. The frame ends up in the database with no signals or join rows, which is the right shape for "what bytes did we receive that we couldn't parse" debugging — same dataset answers both "trace this signal" and "why did this signal never appear." Length and upload-key checks still bail before any persistence: a frame shorter than the MQTT envelope can't be sliced safely, and an invalid upload key means the data isn't trusted to belong to the vehicle. --- gr26/service/message.go | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/gr26/service/message.go b/gr26/service/message.go index 68c69d0..56d249f 100644 --- a/gr26/service/message.go +++ b/gr26/service/message.go @@ -68,21 +68,12 @@ func HandleMessage(vehicleID string, nodeID string, canID int, message []byte) { return } - messageStruct := model.GetMessage(canID) - if messageStruct == nil { - logger.SugarLogger.Infof("Received unknown message id: %d, ignoring", canID) - return - } - - err := messageStruct.FillFromBytes(data) - if err != nil { - logger.SugarLogger.Infof("Error deserializing message: %s", err) - return - } - ts := int(binary.BigEndian.Uint64(timestamp)) producedAt := time.UnixMicro(int64(ts)) + // Persist the raw frame before attempting to decode so unknown can ids + // and failed deserializations are still captured for debugging. The + // frame just won't have any signals or join rows linked to it. can, err := CreateCAN(model.CAN{ VehicleID: vehicleID, NodeID: nodeID, @@ -97,6 +88,17 @@ func HandleMessage(vehicleID string, nodeID string, canID int, message []byte) { return } + messageStruct := model.GetMessage(canID) + if messageStruct == nil { + logger.SugarLogger.Infof("Received unknown message id: %d, frame stored without signals", canID) + return + } + + if err := messageStruct.FillFromBytes(data); err != nil { + logger.SugarLogger.Infof("Error deserializing message id %d, frame stored without signals: %s", canID, err) + return + } + signals := messageStruct.ExportSignals() now := time.Now().Truncate(time.Microsecond) for i := range signals { From c040ac5e2936668d15a9d98ff05ada23183f9ba7 Mon Sep 17 00:00:00 2001 From: Bharat Kathi Date: Thu, 7 May 2026 19:02:58 -0700 Subject: [PATCH 3/8] feat(gr26): record decode anomalies in gr26_can.metadata MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a nullable jsonb metadata column on gr26_can. HandleMessage now attempts the decode first, then writes the frame in a single insert with the outcome stamped in: - decode succeeded -> metadata is null - unknown can id -> {"status":"unknown_can_id"} - decode error -> {"status":"decode_error","error":"..."} Single write per frame instead of insert-then-update, and the upsert's DoUpdates picks up the new column so retransmits with different outcomes overwrite the old metadata. Queries: "frames we couldn't decode" is now a one-liner — WHERE metadata IS NOT NULL — without the LEFT JOIN against gr26_can_signal. --- gr26/model/can.go | 3 +++ gr26/service/can.go | 2 +- gr26/service/message.go | 49 ++++++++++++++++++++++++++++++----------- 3 files changed, 40 insertions(+), 14 deletions(-) diff --git a/gr26/model/can.go b/gr26/model/can.go index 20d4241..4f0fe3f 100644 --- a/gr26/model/can.go +++ b/gr26/model/can.go @@ -14,6 +14,9 @@ type CAN struct { CANID int `json:"can_id"` Bytes []byte `json:"bytes" gorm:"type:bytea"` UploadKey int `json:"upload_key"` + // Metadata captures any decode anomaly: unknown can id, deserializer + // error, etc. Null when the frame decoded cleanly. + Metadata []byte `json:"metadata,omitempty" gorm:"type:jsonb"` ProducedAt time.Time `json:"produced_at" gorm:"precision:6"` CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime;precision:6"` } diff --git a/gr26/service/can.go b/gr26/service/can.go index 5adaad6..d17c512 100644 --- a/gr26/service/can.go +++ b/gr26/service/can.go @@ -19,7 +19,7 @@ func CreateCAN(can model.CAN) (model.CAN, error) { {Name: "node_id"}, {Name: "timestamp"}, }, - DoUpdates: clause.AssignmentColumns([]string{"can_id", "bytes", "upload_key", "produced_at"}), + DoUpdates: clause.AssignmentColumns([]string{"can_id", "bytes", "upload_key", "metadata", "produced_at"}), }, clause.Returning{Columns: []clause.Column{{Name: "id"}}}).Create(&can) if result.Error != nil { return model.CAN{}, result.Error diff --git a/gr26/service/message.go b/gr26/service/message.go index 56d249f..bcc176c 100644 --- a/gr26/service/message.go +++ b/gr26/service/message.go @@ -2,6 +2,7 @@ package service import ( "encoding/binary" + "encoding/json" "fmt" "strconv" "strings" @@ -11,6 +12,8 @@ import ( "github.com/gaucho-racing/mapache/gr26/mqtt" "github.com/gaucho-racing/mapache/gr26/pkg/logger" + mapache "github.com/gaucho-racing/mapache/mapache-go/v3" + mq "github.com/eclipse/paho.mqtt.golang" ) @@ -71,9 +74,27 @@ func HandleMessage(vehicleID string, nodeID string, canID int, message []byte) { ts := int(binary.BigEndian.Uint64(timestamp)) producedAt := time.UnixMicro(int64(ts)) - // Persist the raw frame before attempting to decode so unknown can ids - // and failed deserializations are still captured for debugging. The - // frame just won't have any signals or join rows linked to it. + // Attempt to decode first. If anything goes wrong, capture why in + // metadata so a "what bytes did we get that we couldn't parse" view + // has the answer alongside the raw frame. + var ( + signals []mapache.Signal + meta []byte + ) + messageStruct := model.GetMessage(canID) + switch { + case messageStruct == nil: + logger.SugarLogger.Infof("Received unknown message id: %d, frame stored without signals", canID) + meta = mustJSON(map[string]any{"status": "unknown_can_id"}) + default: + if err := messageStruct.FillFromBytes(data); err != nil { + logger.SugarLogger.Infof("Error deserializing message id %d, frame stored without signals: %s", canID, err) + meta = mustJSON(map[string]any{"status": "decode_error", "error": err.Error()}) + } else { + signals = messageStruct.ExportSignals() + } + } + can, err := CreateCAN(model.CAN{ VehicleID: vehicleID, NodeID: nodeID, @@ -81,6 +102,7 @@ func HandleMessage(vehicleID string, nodeID string, canID int, message []byte) { CANID: canID, Bytes: data, UploadKey: uploadKeyInt, + Metadata: meta, ProducedAt: producedAt, }) if err != nil { @@ -88,18 +110,9 @@ func HandleMessage(vehicleID string, nodeID string, canID int, message []byte) { return } - messageStruct := model.GetMessage(canID) - if messageStruct == nil { - logger.SugarLogger.Infof("Received unknown message id: %d, frame stored without signals", canID) - return - } - - if err := messageStruct.FillFromBytes(data); err != nil { - logger.SugarLogger.Infof("Error deserializing message id %d, frame stored without signals: %s", canID, err) + if len(signals) == 0 { return } - - signals := messageStruct.ExportSignals() now := time.Now().Truncate(time.Microsecond) for i := range signals { signals[i].Name = fmt.Sprintf("%s_%s", nodeID, signals[i].Name) @@ -121,3 +134,13 @@ func HandleMessage(vehicleID string, nodeID string, canID int, message []byte) { logger.SugarLogger.Infof("Error creating CAN-signal links: %s", err) } } + +func mustJSON(v any) []byte { + b, err := json.Marshal(v) + if err != nil { + // json.Marshal on a map of strings/strings can't fail in practice; + // fall back to a literal so callers always get a valid jsonb value. + return []byte(`{"status":"marshal_error"}`) + } + return b +} From 61b4c2e74dc7584d88c5c60193969f496d06409d Mon Sep 17 00:00:00 2001 From: Bharat Kathi Date: Thu, 7 May 2026 19:03:49 -0700 Subject: [PATCH 4/8] feat(gr26): add note field to gr26_can.metadata Pairs the categorical status field with a human-readable note. Renames the prior decode_error 'error' field to 'note' so both states share a schema: {"status":"unknown_can_id","note":"no decoder registered for can id 0x999"} {"status":"decode_error","note":""} --- gr26/service/message.go | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/gr26/service/message.go b/gr26/service/message.go index bcc176c..9bc30b9 100644 --- a/gr26/service/message.go +++ b/gr26/service/message.go @@ -85,11 +85,17 @@ func HandleMessage(vehicleID string, nodeID string, canID int, message []byte) { switch { case messageStruct == nil: logger.SugarLogger.Infof("Received unknown message id: %d, frame stored without signals", canID) - meta = mustJSON(map[string]any{"status": "unknown_can_id"}) + meta = mustJSON(map[string]any{ + "status": "unknown_can_id", + "note": fmt.Sprintf("no decoder registered for can id 0x%X", canID), + }) default: if err := messageStruct.FillFromBytes(data); err != nil { logger.SugarLogger.Infof("Error deserializing message id %d, frame stored without signals: %s", canID, err) - meta = mustJSON(map[string]any{"status": "decode_error", "error": err.Error()}) + meta = mustJSON(map[string]any{ + "status": "decode_error", + "note": err.Error(), + }) } else { signals = messageStruct.ExportSignals() } From 2d6ae2288146a6909ed79b8799a937516916c6af Mon Sep 17 00:00:00 2001 From: Bharat Kathi Date: Thu, 7 May 2026 19:04:27 -0700 Subject: [PATCH 5/8] feat(gr26): stamp status=ok on successfully decoded frames Successful decodes now write {"status":"ok"} into gr26_can.metadata instead of leaving it null. Every frame has a populated metadata field, so the decode-outcome filter is metadata->>'status' across the board. --- gr26/model/can.go | 5 +++-- gr26/service/message.go | 1 + 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/gr26/model/can.go b/gr26/model/can.go index 4f0fe3f..84f9d69 100644 --- a/gr26/model/can.go +++ b/gr26/model/can.go @@ -14,8 +14,9 @@ type CAN struct { CANID int `json:"can_id"` Bytes []byte `json:"bytes" gorm:"type:bytea"` UploadKey int `json:"upload_key"` - // Metadata captures any decode anomaly: unknown can id, deserializer - // error, etc. Null when the frame decoded cleanly. + // Metadata always carries a {"status": ...} field describing the + // decode outcome (ok, unknown_can_id, decode_error) and an optional + // "note" with the human-readable detail. Metadata []byte `json:"metadata,omitempty" gorm:"type:jsonb"` ProducedAt time.Time `json:"produced_at" gorm:"precision:6"` CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime;precision:6"` diff --git a/gr26/service/message.go b/gr26/service/message.go index 9bc30b9..2d4947d 100644 --- a/gr26/service/message.go +++ b/gr26/service/message.go @@ -98,6 +98,7 @@ func HandleMessage(vehicleID string, nodeID string, canID int, message []byte) { }) } else { signals = messageStruct.ExportSignals() + meta = mustJSON(map[string]any{"status": "ok"}) } } From ad182c2754a49dee67f2b0d6a5cec5c0c94b2008 Mon Sep 17 00:00:00 2001 From: Bharat Kathi Date: Fri, 8 May 2026 15:59:32 -0700 Subject: [PATCH 6/8] feat(gr26): GET /gr26/messages/:id endpoint with field-level trace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Returns the stored CAN frame plus three things derived on demand: - signals: every persisted signal currently linked to this frame via gr26_can_signal, fetched in a single join. - fields: the gr26 decoder is re-run over the stored bytes so the response carries per-field name, byte offset, size, sign, endian, the byte slice that contributed, the raw decoded integer, and the full signal names produced (with the node prefix). Returns nil fields if the can id has no decoder or the bytes don't fit — both cases already captured in can.metadata. - bytes: hex-encoded so the dashboard can render the hex grid without re-decoding the JSON's default base64. Route is registered under /gr26/messages/:id and rincon's existing /gr26/** wildcard already exposes it through kerbecs, so no gateway change. --- gr26/api/api.go | 1 + gr26/api/can.go | 146 ++++++++++++++++++++++++++++++++++++++++++++ gr26/service/can.go | 28 +++++++++ 3 files changed, 175 insertions(+) create mode 100644 gr26/api/can.go diff --git a/gr26/api/api.go b/gr26/api/api.go index bca039b..9d62d84 100644 --- a/gr26/api/api.go +++ b/gr26/api/api.go @@ -36,4 +36,5 @@ func InitializeRouter() *gin.Engine { func InitializeRoutes(router *gin.Engine) { router.GET("/gr26/ping", Ping) router.GET("/gr26/live", GetLatestSignalWebSocket) + router.GET("/gr26/messages/:id", GetCANMessage) } diff --git a/gr26/api/can.go b/gr26/api/can.go new file mode 100644 index 0000000..fc155ca --- /dev/null +++ b/gr26/api/can.go @@ -0,0 +1,146 @@ +package api + +import ( + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "net/http" + + "github.com/gaucho-racing/mapache/gr26/model" + "github.com/gaucho-racing/mapache/gr26/service" + + mapache "github.com/gaucho-racing/mapache/mapache-go/v3" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +// canMessageResponse is the GET /gr26/messages/:id wire shape. We +// hex-encode the bytes (rather than the default base64 for []byte) so the +// dashboard can render the hex grid without re-encoding. +type canMessageResponse struct { + ID string `json:"id"` + VehicleID string `json:"vehicle_id"` + NodeID string `json:"node_id"` + Timestamp int `json:"timestamp"` + CANID int `json:"can_id"` + Bytes string `json:"bytes"` + UploadKey int `json:"upload_key"` + Metadata map[string]any `json:"metadata,omitempty"` + ProducedAt string `json:"produced_at"` + CreatedAt string `json:"created_at"` + Fields []canFieldTrace `json:"fields"` + Signals []mapache.Signal `json:"signals"` +} + +// canFieldTrace describes one field of a decoded CAN frame: its byte +// range within the frame, decoded raw value, and which signal names it +// produces. +type canFieldTrace struct { + Name string `json:"name"` + Offset int `json:"offset"` + Size int `json:"size"` + Sign string `json:"sign"` + Endian string `json:"endian"` + Bytes string `json:"bytes"` + RawValue int `json:"raw_value"` + SignalNames []string `json:"signal_names"` +} + +func GetCANMessage(c *gin.Context) { + id := c.Param("id") + if id == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "id is required"}) + return + } + + can, err := service.GetCAN(id) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "can message not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + signals, err := service.GetSignalsForCAN(can.ID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + fields := decodeFieldTrace(can) + + var meta map[string]any + if len(can.Metadata) > 0 { + _ = json.Unmarshal(can.Metadata, &meta) + } + + c.JSON(http.StatusOK, canMessageResponse{ + ID: can.ID, + VehicleID: can.VehicleID, + NodeID: can.NodeID, + Timestamp: can.Timestamp, + CANID: can.CANID, + Bytes: hex.EncodeToString(can.Bytes), + UploadKey: can.UploadKey, + Metadata: meta, + ProducedAt: can.ProducedAt.UTC().Format("2006-01-02T15:04:05.000000Z"), + CreatedAt: can.CreatedAt.UTC().Format("2006-01-02T15:04:05.000000Z"), + Fields: fields, + Signals: signals, + }) +} + +// decodeFieldTrace re-runs the gr26 decoder over the stored bytes so the +// response carries per-field metadata (offset, size, sign, endian, the +// bytes that contributed, the raw value, and the signal names produced). +// Returns nil if the can id has no registered decoder or if the bytes +// don't fit the field layout — both of those are already captured in +// can.Metadata. +func decodeFieldTrace(can model.CAN) []canFieldTrace { + messageStruct := model.GetMessage(can.CANID) + if messageStruct == nil { + return nil + } + if err := messageStruct.FillFromBytes(can.Bytes); err != nil { + return nil + } + + out := make([]canFieldTrace, 0, len(messageStruct)) + offset := 0 + for _, f := range messageStruct { + signalNames := make([]string, 0) + for _, s := range f.ExportSignals() { + signalNames = append(signalNames, fmt.Sprintf("%s_%s", can.NodeID, s.Name)) + } + out = append(out, canFieldTrace{ + Name: f.Name, + Offset: offset, + Size: f.Size, + Sign: signMode(f.Sign), + Endian: endian(f.Endian), + Bytes: hex.EncodeToString(f.Bytes), + RawValue: f.Value, + SignalNames: signalNames, + }) + offset += f.Size + } + return out +} + +func signMode(s mapache.SignMode) string { + if s == mapache.Signed { + return "signed" + } + return "unsigned" +} + +func endian(e mapache.Endian) string { + if e == mapache.BigEndian { + return "big" + } + return "little" +} diff --git a/gr26/service/can.go b/gr26/service/can.go index d17c512..5306709 100644 --- a/gr26/service/can.go +++ b/gr26/service/can.go @@ -4,10 +4,38 @@ import ( "github.com/gaucho-racing/mapache/gr26/database" "github.com/gaucho-racing/mapache/gr26/model" + mapache "github.com/gaucho-racing/mapache/mapache-go/v3" + ulid "github.com/gaucho-racing/ulid-go" "gorm.io/gorm/clause" ) +// GetCAN looks up a stored CAN frame by its ulid. Returns the zero value +// if no row matches. +func GetCAN(id string) (model.CAN, error) { + var can model.CAN + if err := database.DB.Where("id = ?", id).First(&can).Error; err != nil { + return model.CAN{}, err + } + return can, nil +} + +// GetSignalsForCAN returns every signal currently linked to the given +// CAN message via the gr26_can_signal join table. The query orders by +// signal name so the response is stable and easy to scan. +func GetSignalsForCAN(canMessageID string) ([]mapache.Signal, error) { + var signals []mapache.Signal + err := database.DB. + Joins("JOIN gr26_can_signal ON gr26_can_signal.signal_id = signal.id"). + Where("gr26_can_signal.can_message_id = ?", canMessageID). + Order("signal.name ASC"). + Find(&signals).Error + if err != nil { + return nil, err + } + return signals, nil +} + // CreateCAN inserts (or upserts) a CAN frame record and returns it with // the stored id populated. Conflicts on (vehicle_id, node_id, timestamp) // update the payload columns and leave the id stable. From 3ee5ef54ebdad9cf924c274a4e40b1de97aac70b Mon Sep 17 00:00:00 2001 From: Bharat Kathi Date: Sun, 10 May 2026 01:26:08 -0700 Subject: [PATCH 7/8] feat: click a signal on the debug page to see its CAN frame trace Adds a SignalEvent wire type on gr26 that embeds mapache.Signal and adds can_message_id. The hub's channel and Publish now carry SignalEvent so WS subscribers can go from a streamed signal back to the source frame. WebSocket publishing moves out of CreateSignal/CreateSignals and into HandleMessage, which fans out an event per signal once the join rows have landed. The dashboard's debug page picks up can_message_id from the WS payload, makes rows that have one clickable, and opens a MessageTraceDialog that fetches /gr26/messages/:id and renders: - the frame's metadata + decode status, - the bytes as a per-byte hex grid colored by which field owns each byte, - the field list with offset/size/sign/endian/raw value/signal names, - the persisted signals from this frame (the clicked one highlighted). Rows without can_message_id (signals from a vehicle whose service doesn't yet publish it) just stay non-clickable. --- .../components/debug/MessageTraceDialog.tsx | 310 ++++++++++++++++++ dashboard/src/models/signal.tsx | 4 + dashboard/src/pages/debug/DebugPage.tsx | 53 ++- gr26/api/signal.go | 8 +- gr26/model/signal.go | 12 + gr26/service/message.go | 10 + gr26/service/signal.go | 12 +- gr26/service/signal_hub.go | 12 +- 8 files changed, 399 insertions(+), 22 deletions(-) create mode 100644 dashboard/src/components/debug/MessageTraceDialog.tsx create mode 100644 gr26/model/signal.go diff --git a/dashboard/src/components/debug/MessageTraceDialog.tsx b/dashboard/src/components/debug/MessageTraceDialog.tsx new file mode 100644 index 0000000..6884481 --- /dev/null +++ b/dashboard/src/components/debug/MessageTraceDialog.tsx @@ -0,0 +1,310 @@ +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Card } from "@/components/ui/card"; +import { BACKEND_URL } from "@/consts/config"; +import axios from "axios"; +import { useEffect, useMemo, useState } from "react"; +import { Loader2, AlertCircle } from "lucide-react"; +import { formatTimeWithMillis } from "@/lib/utils"; + +interface CANField { + name: string; + offset: number; + size: number; + sign: "signed" | "unsigned"; + endian: "big" | "little"; + bytes: string; + raw_value: number; + signal_names: string[]; +} + +interface CANSignal { + id: string; + timestamp: number; + vehicle_id: string; + name: string; + value: number; + raw_value: number; + produced_at: string; + created_at: string; +} + +interface CANMessage { + id: string; + vehicle_id: string; + node_id: string; + timestamp: number; + can_id: number; + bytes: string; + upload_key: number; + metadata?: { status: string; note?: string }; + produced_at: string; + created_at: string; + fields: CANField[] | null; + signals: CANSignal[]; +} + +interface Props { + canMessageId: string | null; + highlightSignal?: string; + onOpenChange: (open: boolean) => void; +} + +// Tailwind palette cycled per field so adjacent fields are visually +// distinct. Numerals are arbitrary; just need enough variety. +const FIELD_COLORS = [ + "bg-pink-500/30 text-pink-200", + "bg-purple-500/30 text-purple-200", + "bg-cyan-500/30 text-cyan-200", + "bg-amber-500/30 text-amber-200", + "bg-emerald-500/30 text-emerald-200", + "bg-blue-500/30 text-blue-200", + "bg-rose-500/30 text-rose-200", + "bg-lime-500/30 text-lime-200", +]; + +export default function MessageTraceDialog({ + canMessageId, + highlightSignal, + onOpenChange, +}: Props) { + const [data, setData] = useState(null); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + + useEffect(() => { + if (!canMessageId) { + setData(null); + setError(null); + return; + } + let cancelled = false; + setLoading(true); + setError(null); + axios + .get(`${BACKEND_URL}/gr26/messages/${canMessageId}`, { + headers: { + Authorization: `Bearer ${localStorage.getItem("sentinel_access_token")}`, + }, + }) + .then((res) => { + if (cancelled) return; + setData(res.data as CANMessage); + }) + .catch((err) => { + if (cancelled) return; + setError( + err.response?.data?.error ?? err.message ?? "Failed to load trace", + ); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [canMessageId]); + + // Map every byte offset to the field that owns it (if any) so the hex + // grid can color bytes by field with a single lookup. + const byteToField = useMemo(() => { + const map = new Map(); + if (!data?.fields) return map; + data.fields.forEach((f, idx) => { + for (let i = 0; i < f.size; i++) { + map.set(f.offset + i, idx); + } + }); + return map; + }, [data]); + + const totalBytes = data ? Math.floor(data.bytes.length / 2) : 0; + + return ( + + + + CAN frame trace + + + {loading && ( +
+ + Loading trace... +
+ )} + + {error && ( +
+ +
{error}
+
+ )} + + {data && ( +
+ +
+ + + + + + + + +
+ {data.metadata?.note && ( +
+ {data.metadata.note} +
+ )} +
+ +
+
+ Bytes +
+ +
+ {Array.from({ length: totalBytes }).map((_, i) => { + const hex = data.bytes + .slice(i * 2, i * 2 + 2) + .toUpperCase(); + const fieldIdx = byteToField.get(i); + const cls = + fieldIdx != null + ? FIELD_COLORS[fieldIdx % FIELD_COLORS.length] + : "bg-muted text-muted-foreground"; + return ( +
+ {i} + {hex} +
+ ); + })} +
+
+
+ + {data.fields && data.fields.length > 0 && ( +
+
+ Fields +
+ +
+ {data.fields.map((f, idx) => { + const cls = + FIELD_COLORS[idx % FIELD_COLORS.length].split(" ")[0]; + return ( +
+
+
+ + + {f.name} + +
+ + raw {f.raw_value} · 0x + {f.bytes.toUpperCase() || "00"} + +
+
+ + offset {f.offset} · {f.size} byte + {f.size === 1 ? "" : "s"} + + + {f.sign} · {f.endian} + + {f.signal_names.length > 0 && ( + + → {f.signal_names.join(", ")} + + )} +
+
+ ); + })} +
+
+
+ )} + +
+
+ Signals from this frame +
+ + {data.signals.length === 0 ? ( +
+ None linked. +
+ ) : ( +
+ {data.signals.map((s) => { + const isHighlight = s.name === highlightSignal; + return ( +
+ {s.name} + + {s.value} · raw {s.raw_value} + +
+ ); + })} +
+ )} +
+
+
+ )} +
+
+ ); +} + +function Row({ + label, + value, + mono, +}: { + label: string; + value: string; + mono?: boolean; +}) { + return ( + <> + {label} + {value} + + ); +} diff --git a/dashboard/src/models/signal.tsx b/dashboard/src/models/signal.tsx index 51156b1..fbc1d4d 100644 --- a/dashboard/src/models/signal.tsx +++ b/dashboard/src/models/signal.tsx @@ -1,4 +1,5 @@ export interface Signal { + id?: string; timestamp: number; vehicle_id: string; name: string; @@ -6,6 +7,9 @@ export interface Signal { raw_value: number; produced_at: string; created_at: string; + // Set by gr26's WebSocket; lets the dashboard go from a streamed + // signal back to the CAN frame it was decoded from. + can_message_id?: string; } export const initSignal: Signal = { diff --git a/dashboard/src/pages/debug/DebugPage.tsx b/dashboard/src/pages/debug/DebugPage.tsx index b691d27..18e47b5 100644 --- a/dashboard/src/pages/debug/DebugPage.tsx +++ b/dashboard/src/pages/debug/DebugPage.tsx @@ -9,6 +9,7 @@ import { TableRow, } from "@/components/ui/table"; import { Input } from "@/components/ui/input"; +import MessageTraceDialog from "@/components/debug/MessageTraceDialog"; import { BACKEND_WS_URL } from "@/consts/config"; import { useVehicle, useVehicleList } from "@/lib/store"; import { Signal } from "@/models/signal"; @@ -33,6 +34,9 @@ interface SignalState { producedAtFormatted: string; lastSeen: number; count: number; + // Set when gr26 includes can_message_id in the WS payload. Lets the + // row click open the trace dialog for the source CAN frame. + canMessageId?: string; } type SortKey = "name" | "value" | "rawValue" | "lastSeen" | "count"; @@ -94,6 +98,7 @@ const WsBridge = memo(function WsBridge({ producedAtFormatted: formatTimeWithMillis(new Date(parsed.produced_at)), lastSeen: Date.now(), count: (existing?.count ?? 0) + 1, + canMessageId: parsed.can_message_id, }); }, [lastMessage, signalsRef, totalRef]); @@ -101,7 +106,15 @@ const WsBridge = memo(function WsBridge({ }); const SignalRowView = memo( - function SignalRowView({ s, now }: { s: SignalState; now: number }) { + function SignalRowView({ + s, + now, + onSelect, + }: { + s: SignalState; + now: number; + onSelect: (s: SignalState) => void; + }) { const ageMs = Math.max(0, now - s.lastSeen); const ageColor = ageMs < 500 @@ -109,8 +122,12 @@ const SignalRowView = memo( : ageMs < 5000 ? "text-yellow-500" : "text-red-500"; + const clickable = s.canMessageId != null; return ( - + onSelect(s) : undefined} + > {s.name} {s.value} @@ -136,7 +153,9 @@ const SignalRowView = memo( // Skip the row when neither the signal nor the wall clock changed enough // to nudge the age column. 100ms is below human perception. (prev, next) => - prev.s === next.s && Math.abs(prev.now - next.now) < 100, + prev.s === next.s && + Math.abs(prev.now - next.now) < 100 && + prev.onSelect === next.onSelect, ); interface DebugTableProps { @@ -145,6 +164,7 @@ interface DebugTableProps { sortKey: SortKey; sortDir: SortDir; onSort: (key: SortKey) => void; + onSelect: (s: SignalState) => void; emptyMessage: string; } @@ -154,6 +174,7 @@ const DebugTable = memo(function DebugTable({ sortKey, sortDir, onSort, + onSelect, emptyMessage, }: DebugTableProps) { const SortIcon = ({ k }: { k: SortKey }) => { @@ -201,7 +222,14 @@ const DebugTable = memo(function DebugTable({ ) : ( - rows.map((s) => ) + rows.map((s) => ( + + )) )} @@ -239,6 +267,15 @@ export default function DebugPage() { const [readyState, setReadyState] = useState( ReadyState.UNINSTANTIATED, ); + const [selected, setSelected] = useState<{ + canMessageId: string; + signalName: string; + } | null>(null); + + const onSelect = useCallback((s: SignalState) => { + if (!s.canMessageId) return; + setSelected({ canMessageId: s.canMessageId, signalName: s.name }); + }, []); useEffect(() => { signalsRef.current = new Map(); @@ -381,9 +418,17 @@ export default function DebugPage() { sortKey={sortKey} sortDir={sortDir} onSort={onSort} + onSelect={onSelect} emptyMessage={emptyMessage} /> + { + if (!open) setSelected(null); + }} + /> ); } diff --git a/gr26/api/signal.go b/gr26/api/signal.go index 8885508..32b4bdb 100644 --- a/gr26/api/signal.go +++ b/gr26/api/signal.go @@ -6,10 +6,10 @@ import ( "strings" "time" + "github.com/gaucho-racing/mapache/gr26/model" "github.com/gaucho-racing/mapache/gr26/pkg/logger" "github.com/gaucho-racing/mapache/gr26/service" - mapache "github.com/gaucho-racing/mapache/mapache-go/v3" "github.com/gin-gonic/gin" "github.com/gorilla/websocket" ) @@ -51,7 +51,7 @@ func GetLatestSignalWebSocket(c *gin.Context) { client := &service.Client{ Conn: conn, - Send: make(chan mapache.Signal, 64), + Send: make(chan model.SignalEvent, 64), } service.Hub.Subscribe(vehicleID, signals, client) @@ -82,7 +82,7 @@ func GetLatestSignalWebSocket(c *gin.Context) { // emit the latest value per name on each tick. ticker := time.NewTicker(time.Second / time.Duration(rate)) defer ticker.Stop() - pending := make(map[string]mapache.Signal) + pending := make(map[string]model.SignalEvent) for { select { case sig, ok := <-client.Send: @@ -102,7 +102,7 @@ func GetLatestSignalWebSocket(c *gin.Context) { return } } - pending = make(map[string]mapache.Signal) + pending = make(map[string]model.SignalEvent) } } } diff --git a/gr26/model/signal.go b/gr26/model/signal.go new file mode 100644 index 0000000..fa11f01 --- /dev/null +++ b/gr26/model/signal.go @@ -0,0 +1,12 @@ +package model + +import mapache "github.com/gaucho-racing/mapache/mapache-go/v3" + +// SignalEvent is the gr26-local payload the live WebSocket emits. It +// embeds mapache.Signal for backward-compatible fields and adds the +// can_message_id so consumers (e.g. the dashboard's debug trace) can go +// from a streamed signal back to the CAN frame it was decoded from. +type SignalEvent struct { + mapache.Signal + CANMessageID string `json:"can_message_id,omitempty"` +} diff --git a/gr26/service/message.go b/gr26/service/message.go index 2d4947d..802c437 100644 --- a/gr26/service/message.go +++ b/gr26/service/message.go @@ -8,6 +8,7 @@ import ( "strings" "time" + "github.com/gaucho-racing/mapache/gr26/config" "github.com/gaucho-racing/mapache/gr26/model" "github.com/gaucho-racing/mapache/gr26/mqtt" "github.com/gaucho-racing/mapache/gr26/pkg/logger" @@ -140,6 +141,15 @@ func HandleMessage(vehicleID string, nodeID string, canID int, message []byte) { if err := CreateCANSignals(can.ID, signalIDs); err != nil { logger.SugarLogger.Infof("Error creating CAN-signal links: %s", err) } + + if config.EnableSignalWS { + for _, s := range signals { + Hub.Publish(model.SignalEvent{ + Signal: s, + CANMessageID: can.ID, + }) + } + } } func mustJSON(v any) []byte { diff --git a/gr26/service/signal.go b/gr26/service/signal.go index 477867c..9981679 100644 --- a/gr26/service/signal.go +++ b/gr26/service/signal.go @@ -12,6 +12,10 @@ import ( "gorm.io/gorm/clause" ) +// CreateSignal/CreateSignals are pure DB writes. WebSocket publishing +// lives in HandleMessage now so the published payload can carry the +// can_message_id alongside the signal. + func GetSignal(timestamp int, vehicleID string, name string) mapache.Signal { var signal mapache.Signal database.DB.Where("timestamp = ?", timestamp).Where("vehicle_id = ?", vehicleID).Where("name = ?", name).First(&signal) @@ -29,9 +33,6 @@ func CreateSignal(signal mapache.Signal) error { return fmt.Errorf("signal name cannot be empty") } signal.ID = ulid.Make().Prefixed("sgnl") - if config.EnableSignalWS { - Hub.Publish(signal) - } if config.EnableSignalDB { if database.DB.Where("timestamp = ?", signal.Timestamp).Where("vehicle_id = ?", signal.VehicleID).Where("name = ?", signal.Name).Updates(&signal).RowsAffected == 0 { logger.SugarLogger.Infow("[DB] New signal created", @@ -79,10 +80,5 @@ func CreateSignals(signals []mapache.Signal) error { return result.Error } } - if config.EnableSignalWS { - for _, signal := range signals { - Hub.Publish(signal) - } - } return nil } diff --git a/gr26/service/signal_hub.go b/gr26/service/signal_hub.go index 6deabe1..a6de6b6 100644 --- a/gr26/service/signal_hub.go +++ b/gr26/service/signal_hub.go @@ -3,13 +3,13 @@ package service import ( "sync" - mapache "github.com/gaucho-racing/mapache/mapache-go/v3" + "github.com/gaucho-racing/mapache/gr26/model" "github.com/gorilla/websocket" ) type Client struct { Conn *websocket.Conn - Send chan mapache.Signal + Send chan model.SignalEvent } // WildcardSignal is the sentinel signal name that subscribes a client to every @@ -70,10 +70,10 @@ func (h *SignalHub) Unsubscribe(vehicleID string, signalNames []string, client * } } -func (h *SignalHub) Publish(signal mapache.Signal) { +func (h *SignalHub) Publish(event model.SignalEvent) { h.mu.RLock() defer h.mu.RUnlock() - signals, ok := h.subscribers[signal.VehicleID] + signals, ok := h.subscribers[event.VehicleID] if !ok { return } @@ -85,12 +85,12 @@ func (h *SignalHub) Publish(signal mapache.Signal) { } sent[client] = struct{}{} select { - case client.Send <- signal: + case client.Send <- event: default: } } } - if clients, ok := signals[signal.Name]; ok { + if clients, ok := signals[event.Name]; ok { dispatch(clients) } if clients, ok := signals[WildcardSignal]; ok { From ef5d3f69083c3c3965ebf0e5560bcdf4b7943c76 Mon Sep 17 00:00:00 2001 From: Bharat Kathi Date: Sun, 10 May 2026 09:42:04 -0700 Subject: [PATCH 8/8] refactor: keep WS payload as mapache.Signal, look up CAN by signal id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverts the SignalEvent wrapper and gr26-local Hub channel type. The live WebSocket again emits exactly mapache.Signal — no extra fields, no gr26-local wire shape. Drops gr26/model/signal.go, hub channel and Publish go back to mapache.Signal, WS handler and pending coalesce map follow. Adds GET /gr26/signals/:id which joins gr26_can_signal -> gr26_can and returns the same trace shape as /gr26/messages/:id. Both handlers share respondWithCAN; only the lookup func differs. Dashboard captures the streamed signal.id (already on mapache.Signal) and uses it for the trace fetch. MessageTraceDialog now takes signalId + vehicleType and hits //signals/:id, so the same component works for any service that exposes that route. --- .../components/debug/MessageTraceDialog.tsx | 17 +++++++----- dashboard/src/models/signal.tsx | 3 --- dashboard/src/pages/debug/DebugPage.tsx | 17 ++++++------ gr26/api/api.go | 1 + gr26/api/can.go | 26 +++++++++++++++++-- gr26/api/signal.go | 8 +++--- gr26/model/signal.go | 12 --------- gr26/service/can.go | 16 ++++++++++++ gr26/service/message.go | 5 +--- gr26/service/signal_hub.go | 12 ++++----- 10 files changed, 71 insertions(+), 46 deletions(-) delete mode 100644 gr26/model/signal.go diff --git a/dashboard/src/components/debug/MessageTraceDialog.tsx b/dashboard/src/components/debug/MessageTraceDialog.tsx index 6884481..7707e6c 100644 --- a/dashboard/src/components/debug/MessageTraceDialog.tsx +++ b/dashboard/src/components/debug/MessageTraceDialog.tsx @@ -49,7 +49,11 @@ interface CANMessage { } interface Props { - canMessageId: string | null; + // signalId is the streamed mapache.Signal id; the endpoint joins to + // gr26_can_signal to find the originating frame and returns the same + // shape as /gr26/messages/:id. + signalId: string | null; + vehicleType: string; highlightSignal?: string; onOpenChange: (open: boolean) => void; } @@ -68,7 +72,8 @@ const FIELD_COLORS = [ ]; export default function MessageTraceDialog({ - canMessageId, + signalId, + vehicleType, highlightSignal, onOpenChange, }: Props) { @@ -77,7 +82,7 @@ export default function MessageTraceDialog({ const [loading, setLoading] = useState(false); useEffect(() => { - if (!canMessageId) { + if (!signalId) { setData(null); setError(null); return; @@ -86,7 +91,7 @@ export default function MessageTraceDialog({ setLoading(true); setError(null); axios - .get(`${BACKEND_URL}/gr26/messages/${canMessageId}`, { + .get(`${BACKEND_URL}/${vehicleType}/signals/${signalId}`, { headers: { Authorization: `Bearer ${localStorage.getItem("sentinel_access_token")}`, }, @@ -107,7 +112,7 @@ export default function MessageTraceDialog({ return () => { cancelled = true; }; - }, [canMessageId]); + }, [signalId, vehicleType]); // Map every byte offset to the field that owns it (if any) so the hex // grid can color bytes by field with a single lookup. @@ -125,7 +130,7 @@ export default function MessageTraceDialog({ const totalBytes = data ? Math.floor(data.bytes.length / 2) : 0; return ( - + CAN frame trace diff --git a/dashboard/src/models/signal.tsx b/dashboard/src/models/signal.tsx index fbc1d4d..94b0de7 100644 --- a/dashboard/src/models/signal.tsx +++ b/dashboard/src/models/signal.tsx @@ -7,9 +7,6 @@ export interface Signal { raw_value: number; produced_at: string; created_at: string; - // Set by gr26's WebSocket; lets the dashboard go from a streamed - // signal back to the CAN frame it was decoded from. - can_message_id?: string; } export const initSignal: Signal = { diff --git a/dashboard/src/pages/debug/DebugPage.tsx b/dashboard/src/pages/debug/DebugPage.tsx index 18e47b5..87b4083 100644 --- a/dashboard/src/pages/debug/DebugPage.tsx +++ b/dashboard/src/pages/debug/DebugPage.tsx @@ -28,15 +28,13 @@ import useWebSocket, { ReadyState } from "react-use-websocket"; import { ArrowDown, ArrowUp, ArrowUpDown } from "lucide-react"; interface SignalState { + id?: string; name: string; value: number; rawValue: number; producedAtFormatted: string; lastSeen: number; count: number; - // Set when gr26 includes can_message_id in the WS payload. Lets the - // row click open the trace dialog for the source CAN frame. - canMessageId?: string; } type SortKey = "name" | "value" | "rawValue" | "lastSeen" | "count"; @@ -92,13 +90,13 @@ const WsBridge = memo(function WsBridge({ totalRef.current += 1; const existing = signalsRef.current.get(parsed.name); signalsRef.current.set(parsed.name, { + id: parsed.id, name: parsed.name, value: parsed.value, rawValue: parsed.raw_value, producedAtFormatted: formatTimeWithMillis(new Date(parsed.produced_at)), lastSeen: Date.now(), count: (existing?.count ?? 0) + 1, - canMessageId: parsed.can_message_id, }); }, [lastMessage, signalsRef, totalRef]); @@ -122,7 +120,7 @@ const SignalRowView = memo( : ageMs < 5000 ? "text-yellow-500" : "text-red-500"; - const clickable = s.canMessageId != null; + const clickable = s.id != null; return ( (null); const onSelect = useCallback((s: SignalState) => { - if (!s.canMessageId) return; - setSelected({ canMessageId: s.canMessageId, signalName: s.name }); + if (!s.id) return; + setSelected({ signalId: s.id, signalName: s.name }); }, []); useEffect(() => { @@ -423,7 +421,8 @@ export default function DebugPage() { /> { if (!open) setSelected(null); diff --git a/gr26/api/api.go b/gr26/api/api.go index 9d62d84..410d781 100644 --- a/gr26/api/api.go +++ b/gr26/api/api.go @@ -37,4 +37,5 @@ func InitializeRoutes(router *gin.Engine) { router.GET("/gr26/ping", Ping) router.GET("/gr26/live", GetLatestSignalWebSocket) router.GET("/gr26/messages/:id", GetCANMessage) + router.GET("/gr26/signals/:id", GetCANBySignalID) } diff --git a/gr26/api/can.go b/gr26/api/can.go index fc155ca..eee7d59 100644 --- a/gr26/api/can.go +++ b/gr26/api/can.go @@ -54,11 +54,33 @@ func GetCANMessage(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": "id is required"}) return } + respondWithCAN(c, service.GetCAN, id, "can message not found") +} + +// GetCANBySignalID returns the same trace shape as GetCANMessage but +// looks up the CAN frame by the signal id that came from it. Lets the +// dashboard go straight from a streamed signal.id (which is just +// mapache.Signal — no extra wire fields) to its source frame in one +// call. +func GetCANBySignalID(c *gin.Context) { + id := c.Param("id") + if id == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "id is required"}) + return + } + respondWithCAN(c, service.GetCANForSignal, id, "no can frame linked to this signal") +} - can, err := service.GetCAN(id) +func respondWithCAN( + c *gin.Context, + lookup func(string) (model.CAN, error), + id string, + notFoundMsg string, +) { + can, err := lookup(id) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { - c.JSON(http.StatusNotFound, gin.H{"error": "can message not found"}) + c.JSON(http.StatusNotFound, gin.H{"error": notFoundMsg}) return } c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) diff --git a/gr26/api/signal.go b/gr26/api/signal.go index 32b4bdb..8885508 100644 --- a/gr26/api/signal.go +++ b/gr26/api/signal.go @@ -6,10 +6,10 @@ import ( "strings" "time" - "github.com/gaucho-racing/mapache/gr26/model" "github.com/gaucho-racing/mapache/gr26/pkg/logger" "github.com/gaucho-racing/mapache/gr26/service" + mapache "github.com/gaucho-racing/mapache/mapache-go/v3" "github.com/gin-gonic/gin" "github.com/gorilla/websocket" ) @@ -51,7 +51,7 @@ func GetLatestSignalWebSocket(c *gin.Context) { client := &service.Client{ Conn: conn, - Send: make(chan model.SignalEvent, 64), + Send: make(chan mapache.Signal, 64), } service.Hub.Subscribe(vehicleID, signals, client) @@ -82,7 +82,7 @@ func GetLatestSignalWebSocket(c *gin.Context) { // emit the latest value per name on each tick. ticker := time.NewTicker(time.Second / time.Duration(rate)) defer ticker.Stop() - pending := make(map[string]model.SignalEvent) + pending := make(map[string]mapache.Signal) for { select { case sig, ok := <-client.Send: @@ -102,7 +102,7 @@ func GetLatestSignalWebSocket(c *gin.Context) { return } } - pending = make(map[string]model.SignalEvent) + pending = make(map[string]mapache.Signal) } } } diff --git a/gr26/model/signal.go b/gr26/model/signal.go deleted file mode 100644 index fa11f01..0000000 --- a/gr26/model/signal.go +++ /dev/null @@ -1,12 +0,0 @@ -package model - -import mapache "github.com/gaucho-racing/mapache/mapache-go/v3" - -// SignalEvent is the gr26-local payload the live WebSocket emits. It -// embeds mapache.Signal for backward-compatible fields and adds the -// can_message_id so consumers (e.g. the dashboard's debug trace) can go -// from a streamed signal back to the CAN frame it was decoded from. -type SignalEvent struct { - mapache.Signal - CANMessageID string `json:"can_message_id,omitempty"` -} diff --git a/gr26/service/can.go b/gr26/service/can.go index 5306709..4fc1e38 100644 --- a/gr26/service/can.go +++ b/gr26/service/can.go @@ -20,6 +20,22 @@ func GetCAN(id string) (model.CAN, error) { return can, nil } +// GetCANForSignal looks up the CAN frame that produced a given signal, +// joining via gr26_can_signal. Returns gorm.ErrRecordNotFound if the +// signal isn't linked to any frame (e.g., signal predates the trace +// feature or belongs to a different service). +func GetCANForSignal(signalID string) (model.CAN, error) { + var can model.CAN + err := database.DB. + Joins("JOIN gr26_can_signal ON gr26_can_signal.can_message_id = gr26_can.id"). + Where("gr26_can_signal.signal_id = ?", signalID). + First(&can).Error + if err != nil { + return model.CAN{}, err + } + return can, nil +} + // GetSignalsForCAN returns every signal currently linked to the given // CAN message via the gr26_can_signal join table. The query orders by // signal name so the response is stable and easy to scan. diff --git a/gr26/service/message.go b/gr26/service/message.go index 802c437..14ea75f 100644 --- a/gr26/service/message.go +++ b/gr26/service/message.go @@ -144,10 +144,7 @@ func HandleMessage(vehicleID string, nodeID string, canID int, message []byte) { if config.EnableSignalWS { for _, s := range signals { - Hub.Publish(model.SignalEvent{ - Signal: s, - CANMessageID: can.ID, - }) + Hub.Publish(s) } } } diff --git a/gr26/service/signal_hub.go b/gr26/service/signal_hub.go index a6de6b6..6deabe1 100644 --- a/gr26/service/signal_hub.go +++ b/gr26/service/signal_hub.go @@ -3,13 +3,13 @@ package service import ( "sync" - "github.com/gaucho-racing/mapache/gr26/model" + mapache "github.com/gaucho-racing/mapache/mapache-go/v3" "github.com/gorilla/websocket" ) type Client struct { Conn *websocket.Conn - Send chan model.SignalEvent + Send chan mapache.Signal } // WildcardSignal is the sentinel signal name that subscribes a client to every @@ -70,10 +70,10 @@ func (h *SignalHub) Unsubscribe(vehicleID string, signalNames []string, client * } } -func (h *SignalHub) Publish(event model.SignalEvent) { +func (h *SignalHub) Publish(signal mapache.Signal) { h.mu.RLock() defer h.mu.RUnlock() - signals, ok := h.subscribers[event.VehicleID] + signals, ok := h.subscribers[signal.VehicleID] if !ok { return } @@ -85,12 +85,12 @@ func (h *SignalHub) Publish(event model.SignalEvent) { } sent[client] = struct{}{} select { - case client.Send <- event: + case client.Send <- signal: default: } } } - if clients, ok := signals[event.Name]; ok { + if clients, ok := signals[signal.Name]; ok { dispatch(clients) } if clients, ok := signals[WildcardSignal]; ok {