From 8e5f121c79c6ca622638f496a95406236bf6e4ff Mon Sep 17 00:00:00 2001 From: Bharat Kathi Date: Sat, 29 Aug 2026 09:33:29 -0700 Subject: [PATCH 1/3] feat(p987): add ingest service for the 987 live data path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .github/workflows/p987.yml | 155 +++++++++++ docker-compose.yaml | 34 +++ kerbecs.yaml | 14 + p987/.air.toml | 21 ++ p987/.gitignore | 168 +++++++++++ p987/Dockerfile | 29 ++ p987/Dockerfile.dev | 7 + p987/api/api.go | 44 +++ p987/api/can.go | 175 ++++++++++++ p987/api/dbc.go | 109 ++++++++ p987/api/ping.go | 12 + p987/config/banner.go | 20 ++ p987/config/config.go | 59 ++++ p987/config/verify.go | 42 +++ p987/database/db.go | 129 +++++++++ p987/dbc/cayman_987.dbc | 521 +++++++++++++++++++++++++++++++++++ p987/dbc/dbc.go | 186 +++++++++++++ p987/dbc/dbc_test.go | 125 +++++++++ p987/dbc/decode.go | 129 +++++++++ p987/dbc/decode_test.go | 154 +++++++++++ p987/go.mod | 63 +++++ p987/go.sum | 211 ++++++++++++++ p987/main.go | 39 +++ p987/model/can.go | 30 ++ p987/model/tcm.go | 115 ++++++++ p987/model/tcm_test.go | 130 +++++++++ p987/mqtt/mqtt.go | 127 +++++++++ p987/pkg/kerbecs/kerbecs.go | 114 ++++++++ p987/pkg/logger/logger.go | 16 ++ p987/service/can.go | 161 +++++++++++ p987/service/message.go | 238 ++++++++++++++++ p987/service/message_test.go | 165 +++++++++++ p987/service/ping.go | 68 +++++ p987/service/signal.go | 59 ++++ p987/service/vehicle.go | 104 +++++++ 35 files changed, 3773 insertions(+) create mode 100644 .github/workflows/p987.yml create mode 100644 p987/.air.toml create mode 100644 p987/.gitignore create mode 100644 p987/Dockerfile create mode 100644 p987/Dockerfile.dev create mode 100644 p987/api/api.go create mode 100644 p987/api/can.go create mode 100644 p987/api/dbc.go create mode 100644 p987/api/ping.go create mode 100644 p987/config/banner.go create mode 100644 p987/config/config.go create mode 100644 p987/config/verify.go create mode 100644 p987/database/db.go create mode 100644 p987/dbc/cayman_987.dbc create mode 100644 p987/dbc/dbc.go create mode 100644 p987/dbc/dbc_test.go create mode 100644 p987/dbc/decode.go create mode 100644 p987/dbc/decode_test.go create mode 100644 p987/go.mod create mode 100644 p987/go.sum create mode 100644 p987/main.go create mode 100644 p987/model/can.go create mode 100644 p987/model/tcm.go create mode 100644 p987/model/tcm_test.go create mode 100644 p987/mqtt/mqtt.go create mode 100644 p987/pkg/kerbecs/kerbecs.go create mode 100644 p987/pkg/logger/logger.go create mode 100644 p987/service/can.go create mode 100644 p987/service/message.go create mode 100644 p987/service/message_test.go create mode 100644 p987/service/ping.go create mode 100644 p987/service/signal.go create mode 100644 p987/service/vehicle.go diff --git a/.github/workflows/p987.yml b/.github/workflows/p987.yml new file mode 100644 index 00000000..7a24d287 --- /dev/null +++ b/.github/workflows/p987.yml @@ -0,0 +1,155 @@ +name: p987 +run-name: Triggered by ${{ github.event_name }} to ${{ github.ref }} by @${{ github.actor }} + +on: + push: + branches: + - "**" + tags: + - "v*" + +jobs: + build: + runs-on: ${{ matrix.runner }} + name: Build ${{ matrix.platform }} + strategy: + fail-fast: false + matrix: + include: + - platform: linux/amd64 + runner: ubuntu-24.04 + - platform: linux/arm64 + runner: ubuntu-24.04-arm + + permissions: + contents: read + packages: write + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Generate platform pair + id: platform + run: | + platform=${{ matrix.platform }} + echo "pair=${platform//\//-}" >> $GITHUB_OUTPUT + + - name: Build and push by digest + id: build + uses: docker/build-push-action@v5 + with: + context: p987 + platforms: ${{ matrix.platform }} + outputs: type=image,name=ghcr.io/gaucho-racing/mapache/p987,push-by-digest=true,name-canonical=true,push=true + cache-from: | + type=gha,scope=build-${{ github.workflow }}-${{ steps.platform.outputs.pair }}-${{ github.ref_name }} + type=gha,scope=build-${{ github.workflow }}-${{ steps.platform.outputs.pair }}-main + cache-to: type=gha,scope=build-${{ github.workflow }}-${{ steps.platform.outputs.pair }}-${{ github.ref_name }},mode=max + + - name: Export digest + run: | + mkdir -p /tmp/digests + digest="${{ steps.build.outputs.digest }}" + touch "/tmp/digests/${digest#sha256:}" + + - name: Upload digest + uses: actions/upload-artifact@v4 + with: + name: digests-${{ steps.platform.outputs.pair }} + path: /tmp/digests/* + if-no-files-found: error + retention-days: 1 + + merge: + runs-on: ubuntu-latest + name: Merge manifests + needs: build + + permissions: + contents: read + packages: write + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + fetch-tags: true + + - name: Download digests + uses: actions/download-artifact@v4 + with: + path: /tmp/digests + pattern: digests-* + merge-multiple: true + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Check if this commit has a release tag + id: release + run: | + tag=$(git tag --points-at HEAD | grep '^v' | head -n1) + if [ -n "$tag" ]; then + echo "Found tag: $tag" + if gh release view "$tag" --json tagName > /dev/null 2>&1; then + echo "release_tag=$tag" >> $GITHUB_OUTPUT + echo "is_release=true" >> $GITHUB_OUTPUT + exit 0 + fi + fi + echo "is_release=false" >> $GITHUB_OUTPUT + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Generate tag list + id: tags + shell: bash + run: | + TAGS="type=sha" + + if [ "${GITHUB_REF_TYPE}" = "branch" ] && [ "${GITHUB_REF_NAME}" = "main" ]; then + TAGS="${TAGS}\ntype=raw,value=latest" + fi + + if [ "${{ steps.release.outputs.is_release }}" = "true" ]; then + CLEAN_TAG=$(echo "${{ steps.release.outputs.release_tag }}" | sed 's/^v//') + TAGS="${TAGS}\ntype=raw,value=${CLEAN_TAG}" + fi + + echo -e "tags<> $GITHUB_OUTPUT + + - name: Extract image metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ghcr.io/gaucho-racing/mapache/p987 + tags: ${{ steps.tags.outputs.tags }} + + - name: Create manifest list and push + working-directory: /tmp/digests + run: | + docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ + $(printf 'ghcr.io/gaucho-racing/mapache/p987@sha256:%s ' *) + + - name: Inspect image + run: | + docker buildx imagetools inspect ghcr.io/gaucho-racing/mapache/p987:${{ steps.meta.outputs.version }} diff --git a/docker-compose.yaml b/docker-compose.yaml index 33d30af6..a8aa7da7 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -116,6 +116,39 @@ services: AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID} AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY} + p987: + container_name: mapache-p987 + build: + context: . + dockerfile: p987/Dockerfile.dev + restart: unless-stopped + depends_on: + - nanomq + - clickhouse + ports: + - "7020:7020" + volumes: + - ./p987:/app/p987 + - ./mapache-go:/app/mapache-go + - p987_go_cache:/go + environment: + ENV: "DEV" + PORT: "7020" + MQTT_HOST: "nanomq" + MQTT_PORT: "1883" + MQTT_USER: "p987" + MQTT_PASSWORD: "p987" + CLICKHOUSE_HOST: "clickhouse" + CLICKHOUSE_PORT: "9000" + CLICKHOUSE_USER: "default" + CLICKHOUSE_PASSWORD: "" + CLICKHOUSE_DATABASE: "mapache" + KERBECS_ENDPOINT: "http://kerbecs:10300" + KERBECS_USER: "admin" + KERBECS_PASSWORD: "admin" + SKIP_AUTH_CHECK: "true" + VEHICLE_UPLOAD_KEY_CACHE_TTL: "600" + live: container_name: mapache-live build: @@ -254,5 +287,6 @@ volumes: auth_go_cache: vehicle_go_cache: gr26_go_cache: + p987_go_cache: live_go_cache: dashboard_node_modules: diff --git a/kerbecs.yaml b/kerbecs.yaml index a2d41ebb..cfa86dcb 100644 --- a/kerbecs.yaml +++ b/kerbecs.yaml @@ -50,6 +50,12 @@ upstreams: instances: - http://gr26:7005 + p987: + name: mapache-p987 + version: 3.3.0 + instances: + - http://p987:7020 + live: name: mapache-live version: 3.3.0 @@ -163,6 +169,14 @@ routes: strip_prefix: /api envelope: default + - name: p987 + match: + path: /api/p987/* + upstream: p987 + rewrite: + strip_prefix: /api + envelope: default + - name: live-ws match: path: /api/live/ws diff --git a/p987/.air.toml b/p987/.air.toml new file mode 100644 index 00000000..3d5c83f0 --- /dev/null +++ b/p987/.air.toml @@ -0,0 +1,21 @@ +root = "." +tmp_dir = "tmp" + +[build] + bin = "./tmp/main" + cmd = "go mod tidy && go build -o ./tmp/main ." + delay = 1000 + exclude_dir = ["tmp", "vendor"] + exclude_regex = ["_test.go"] + include_ext = ["go", "toml"] + kill_delay = "0s" + send_interrupt = false + poll = true + poll_interval = 500 + stop_on_error = true + +[log] + time = false + +[misc] + clean_on_exit = true diff --git a/p987/.gitignore b/p987/.gitignore new file mode 100644 index 00000000..f29bf026 --- /dev/null +++ b/p987/.gitignore @@ -0,0 +1,168 @@ +.env + +### VisualStudioCode template +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +!.vscode/*.code-snippets + +# Local History for Visual Studio Code +.history/ + +# Built Visual Studio Code Extensions +*.vsix + +### Go template +# If you prefer the allow list template instead of the deny list, see community template: +# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore +# +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out +coverage.html + +# Dependency directories (remove the comment below to include it) +# vendor/ + +# Go workspace file +go.work + +### JetBrains template +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff +.idea/ + +# AWS User-specific +.idea/**/aws.xml + +# Generated files +.idea/**/contentModel.xml + +# Sensitive or high-churn files +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/dbnavigator.xml + +# Gradle +.idea/**/gradle.xml +.idea/**/libraries + +# Gradle and Maven with auto-import +# When using Gradle or Maven with auto-import, you should exclude module files, +# since they will be recreated, and may cause churn. Uncomment if using +# auto-import. +# .idea/artifacts +# .idea/compiler.xml +# .idea/jarRepositories.xml +# .idea/modules.xml +# .idea/*.iml +# .idea/modules +# *.iml +# *.ipr + +# CMake +cmake-build-*/ + +# Mongo Explorer plugin +.idea/**/mongoSettings.xml + +# File-based project format +*.iws + +# IntelliJ +out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Cursive Clojure plugin +.idea/replstate.xml + +# SonarLint plugin +.idea/sonarlint/ + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +# Editor-based Rest Client +.idea/httpRequests + +# Android studio 3.1+ serialized cache file +.idea/caches/build_file_checksums.ser + +### Windows template +# Windows thumbnail cache files +Thumbs.db +Thumbs.db:encryptable +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk + +### macOS template +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk diff --git a/p987/Dockerfile b/p987/Dockerfile new file mode 100644 index 00000000..278d5e7a --- /dev/null +++ b/p987/Dockerfile @@ -0,0 +1,29 @@ +FROM --platform=$BUILDPLATFORM golang:1.26-alpine AS builder + +RUN apk --no-cache add ca-certificates +RUN apk add --no-cache tzdata + +WORKDIR /app + +COPY go.mod ./ +COPY go.sum ./ +RUN go mod download + +COPY . ./ +ARG TARGETOS +ARG TARGETARCH +RUN GOOS=$TARGETOS GOARCH=$TARGETARCH go build -o /p987 + +## +## Deploy +## +FROM alpine:3.21 + +WORKDIR / + +COPY --from=builder /p987 /p987 + +COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo +ENV TZ=UTC + +ENTRYPOINT ["/p987"] diff --git a/p987/Dockerfile.dev b/p987/Dockerfile.dev new file mode 100644 index 00000000..fdd0b133 --- /dev/null +++ b/p987/Dockerfile.dev @@ -0,0 +1,7 @@ +FROM golang:1.26-alpine + +RUN go install github.com/air-verse/air@latest + +WORKDIR /app/p987 + +CMD ["air", "-c", ".air.toml"] diff --git a/p987/api/api.go b/p987/api/api.go new file mode 100644 index 00000000..27f6586a --- /dev/null +++ b/p987/api/api.go @@ -0,0 +1,44 @@ +package api + +import ( + "time" + + "github.com/gaucho-racing/mapache/p987/config" + "github.com/gaucho-racing/mapache/p987/pkg/logger" + "github.com/gin-contrib/cors" + "github.com/gin-gonic/gin" +) + +func Run() { + api := InitializeRouter() + InitializeRoutes(api) + if err := api.Run(":" + config.Port); err != nil { + logger.SugarLogger.Fatalf("Failed to start server: %v", err) + } +} + +func InitializeRouter() *gin.Engine { + if config.IsProduction() { + gin.SetMode(gin.ReleaseMode) + } + r := gin.Default() + r.Use(cors.New(cors.Config{ + AllowAllOrigins: true, + AllowMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"}, + AllowHeaders: []string{"Origin", "Content-Length", "Content-Type", "Authorization"}, + MaxAge: 12 * time.Hour, + AllowCredentials: true, + })) + return r +} + +func InitializeRoutes(router *gin.Engine) { + router.GET("/p987/ping", Ping) + router.GET("/p987/messages/:id", GetCANMessage) + router.GET("/p987/signals/:id", GetCANBySignalID) + // The decoder registry is the thing most worth inspecting while + // bringing the car up: it answers "is this id in the DBC, and what + // should it produce?" without needing a frame to arrive first. + router.GET("/p987/dbc", GetDBC) + router.GET("/p987/dbc/:id", GetDBCMessage) +} diff --git a/p987/api/can.go b/p987/api/can.go new file mode 100644 index 00000000..a1b7cee9 --- /dev/null +++ b/p987/api/can.go @@ -0,0 +1,175 @@ +package api + +import ( + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "net/http" + + "github.com/gaucho-racing/mapache/p987/config" + "github.com/gaucho-racing/mapache/p987/dbc" + "github.com/gaucho-racing/mapache/p987/model" + "github.com/gaucho-racing/mapache/p987/service" + + mapache "github.com/gaucho-racing/mapache/mapache-go/v3" + + "github.com/gin-gonic/gin" +) + +// Bytes is hex-encoded (not the default base64) so the dashboard's hex +// grid can render 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"` + MessageName string `json:"message_name,omitempty"` + Fields []canSignalTrace `json:"fields"` + Signals []mapache.Signal `json:"signals"` +} + +// canSignalTrace shows where each signal came from inside the frame. +// gr26 traces byte-aligned fields; here the unit is a DBC signal, so the +// trace carries bit position and the scaling that produced the value. +type canSignalTrace struct { + Name string `json:"name"` + SignalName string `json:"signal_name"` + StartBit int `json:"start_bit"` + Length int `json:"length"` + Endian string `json:"endian"` + Sign string `json:"sign"` + Factor float64 `json:"factor"` + Offset float64 `json:"offset"` + Unit string `json:"unit,omitempty"` + RawValue int64 `json:"raw_value"` + Value float64 `json:"value"` +} + +func GetCANMessage(c *gin.Context) { + id := c.Param("id") + if id == "" { + 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 source CAN frame by signal id. +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") +} + +func respondWithCAN( + c *gin.Context, + lookup func(string) (model.CAN, error), + id string, + notFoundMsg string, +) { + if !config.ClickhouseEnabled() { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "clickhouse disabled"}) + return + } + can, err := lookup(id) + if err != nil { + if errors.Is(err, service.ErrNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": notFoundMsg}) + 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 + } + + var meta map[string]any + if len(can.Metadata) > 0 { + _ = json.Unmarshal(can.Metadata, &meta) + } + + name, fields := decodeSignalTrace(can) + + 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"), + MessageName: name, + Fields: fields, + Signals: signals, + }) +} + +// decodeSignalTrace re-runs the decoder to expose per-signal placement. +// Returns no fields for ids the DBC doesn't describe — the reason is +// already recorded in can.Metadata. +func decodeSignalTrace(can model.CAN) (string, []canSignalTrace) { + db, err := dbc.Cayman987() + if err != nil { + return "", nil + } + msg, ok := db.Messages[uint32(can.CANID)] + if !ok { + return "", nil + } + + decoded := msg.Decode(can.Bytes) + byName := make(map[string]dbc.Decoded, len(decoded)) + for _, d := range decoded { + byName[d.Name] = d + } + + out := make([]canSignalTrace, 0, len(msg.Signals)) + for _, s := range msg.Signals { + d, present := byName[s.Name] + if !present { + // Multiplexed, or it doesn't fit this frame. Skipped by the + // decoder, so it has no value to report. + continue + } + endian := "big" + if s.LittleEndian { + endian = "little" + } + sign := "unsigned" + if s.Signed { + sign = "signed" + } + out = append(out, canSignalTrace{ + Name: s.Name, + SignalName: fmt.Sprintf("%s_%s", can.NodeID, s.Name), + StartBit: s.StartBit, + Length: s.Length, + Endian: endian, + Sign: sign, + Factor: s.Factor, + Offset: s.Offset, + Unit: s.Unit, + RawValue: d.Raw, + Value: d.Value, + }) + } + return msg.Name, out +} diff --git a/p987/api/dbc.go b/p987/api/dbc.go new file mode 100644 index 00000000..f04f367a --- /dev/null +++ b/p987/api/dbc.go @@ -0,0 +1,109 @@ +package api + +import ( + "net/http" + "sort" + "strconv" + "strings" + + "github.com/gaucho-racing/mapache/p987/dbc" + + "github.com/gin-gonic/gin" +) + +type dbcMessageResponse struct { + ID uint32 `json:"id"` + HexID string `json:"hex_id"` + Name string `json:"name"` + Length int `json:"length"` + Signals []dbcSignalResponse `json:"signals"` +} + +type dbcSignalResponse struct { + Name string `json:"name"` + StartBit int `json:"start_bit"` + Length int `json:"length"` + Endian string `json:"endian"` + Signed bool `json:"signed"` + Factor float64 `json:"factor"` + Offset float64 `json:"offset"` + Unit string `json:"unit,omitempty"` + Multiplexed bool `json:"multiplexed"` +} + +// GetDBC lists every message in the loaded database, ordered by id. +func GetDBC(c *gin.Context) { + db, err := dbc.Cayman987() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + out := make([]dbcMessageResponse, 0, len(db.Messages)) + for _, m := range db.Messages { + out = append(out, toDBCResponse(m)) + } + sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID }) + + c.JSON(http.StatusOK, gin.H{ + "messages": out, + "message_count": len(db.Messages), + "signal_count": db.SignalCount(), + "multiplexed_count": db.MultiplexedCount(), + }) +} + +// GetDBCMessage looks up one message by decimal or 0x-prefixed hex id. +func GetDBCMessage(c *gin.Context) { + db, err := dbc.Cayman987() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + raw := c.Param("id") + base, digits := 10, raw + if strings.HasPrefix(strings.ToLower(raw), "0x") { + base, digits = 16, raw[2:] + } + id, err := strconv.ParseUint(digits, base, 32) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "id must be a decimal or 0x-prefixed hex can id"}) + return + } + + msg, ok := db.Messages[uint32(id)] + if !ok { + c.JSON(http.StatusNotFound, gin.H{"error": "no dbc entry for that can id"}) + return + } + c.JSON(http.StatusOK, toDBCResponse(msg)) +} + +func toDBCResponse(m *dbc.Message) dbcMessageResponse { + signals := make([]dbcSignalResponse, 0, len(m.Signals)) + for _, s := range m.Signals { + endian := "big" + if s.LittleEndian { + endian = "little" + } + signals = append(signals, dbcSignalResponse{ + Name: s.Name, + StartBit: s.StartBit, + Length: s.Length, + Endian: endian, + Signed: s.Signed, + Factor: s.Factor, + Offset: s.Offset, + Unit: s.Unit, + Multiplexed: s.Multiplexed, + }) + } + return dbcMessageResponse{ + ID: m.ID, + HexID: "0x" + strconv.FormatUint(uint64(m.ID), 16), + Name: m.Name, + Length: m.Length, + Signals: signals, + } +} diff --git a/p987/api/ping.go b/p987/api/ping.go new file mode 100644 index 00000000..8ad07822 --- /dev/null +++ b/p987/api/ping.go @@ -0,0 +1,12 @@ +package api + +import ( + "net/http" + + "github.com/gaucho-racing/mapache/p987/config" + "github.com/gin-gonic/gin" +) + +func Ping(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"message": config.Service.FormattedNameWithVersion() + " is online!"}) +} diff --git a/p987/config/banner.go b/p987/config/banner.go new file mode 100644 index 00000000..ab559089 --- /dev/null +++ b/p987/config/banner.go @@ -0,0 +1,20 @@ +package config + +import "github.com/fatih/color" + +var Banner = ` +███╗ ███╗ █████╗ ██████╗ █████╗ ██████╗██╗ ██╗███████╗ +████╗ ████║██╔══██╗██╔══██╗██╔══██╗██╔════╝██║ ██║██╔════╝ +██╔████╔██║███████║██████╔╝███████║██║ ███████║█████╗ +██║╚██╔╝██║██╔══██║██╔═══╝ ██╔══██║██║ ██╔══██║██╔══╝ +██║ ╚═╝ ██║██║ ██║██║ ██║ ██║╚██████╗██║ ██║███████╗ +╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═╝ ╚═╝ ╚═════╝╚═╝ ╚═╝╚══════╝ +` + +func PrintStartupBanner() { + banner := color.New(color.Bold, color.FgHiMagenta).PrintlnFunc() + banner(Banner) + version := color.New(color.Bold, color.FgMagenta).PrintlnFunc() + version("Running " + Service.FormattedNameWithVersion() + " [ENV: " + Env + "]") + println() +} diff --git a/p987/config/config.go b/p987/config/config.go new file mode 100644 index 00000000..6e66cb7f --- /dev/null +++ b/p987/config/config.go @@ -0,0 +1,59 @@ +package config + +import ( + "fmt" + "os" + "strings" +) + +type ServiceInfo struct { + Name string + Version string +} + +func (s ServiceInfo) FormattedNameWithVersion() string { + return fmt.Sprintf("%s v%s", s.Name, s.Version) +} + +func (s ServiceInfo) PathPrefix() string { + return strings.ToLower(s.Name) +} + +var Service = ServiceInfo{ + Name: "P987", + Version: "0.1.0", +} + +// TopicRoot is the first topic segment the relay publishes under. It is +// also the generation namespace, so it doubles as the table prefix. +const TopicRoot = "p987" + +var SkipAuthCheck = os.Getenv("SKIP_AUTH_CHECK") == "true" +var VehicleUploadKeyCacheTTL = os.Getenv("VEHICLE_UPLOAD_KEY_CACHE_TTL") + +var Env = os.Getenv("ENV") +var Port = os.Getenv("PORT") + +var ClickhouseHost = os.Getenv("CLICKHOUSE_HOST") +var ClickhousePort = os.Getenv("CLICKHOUSE_PORT") +var ClickhouseUser = os.Getenv("CLICKHOUSE_USER") +var ClickhousePassword = os.Getenv("CLICKHOUSE_PASSWORD") +var ClickhouseDatabase = os.Getenv("CLICKHOUSE_DATABASE") + +// ClickhouseEnabled is the master switch for all CH access. Unset +// CLICKHOUSE_HOST means "no ClickHouse", which is how the service is run +// when testing the live path against a relay without standing up storage. +func ClickhouseEnabled() bool { return ClickhouseHost != "" } + +var KerbecsEndpoint = os.Getenv("KERBECS_ENDPOINT") +var KerbecsUser = os.Getenv("KERBECS_USER") +var KerbecsPassword = os.Getenv("KERBECS_PASSWORD") + +var MQTTHost = os.Getenv("MQTT_HOST") +var MQTTPort = os.Getenv("MQTT_PORT") +var MQTTUser = os.Getenv("MQTT_USER") +var MQTTPassword = os.Getenv("MQTT_PASSWORD") + +func IsProduction() bool { + return Env == "PROD" +} diff --git a/p987/config/verify.go b/p987/config/verify.go new file mode 100644 index 00000000..699b37c2 --- /dev/null +++ b/p987/config/verify.go @@ -0,0 +1,42 @@ +package config + +import "github.com/gaucho-racing/mapache/p987/pkg/logger" + +func Verify() { + if Env == "" { + Env = "PROD" + logger.SugarLogger.Infof("ENV is not set, defaulting to %s", Env) + } + if Port == "" { + Port = "7020" + logger.SugarLogger.Infof("PORT is not set, defaulting to %s", Port) + } + if !ClickhouseEnabled() { + logger.SugarLogger.Infoln("CLICKHOUSE_HOST is not set, ClickHouse disabled") + } else { + if ClickhousePort == "" { + ClickhousePort = "9000" + logger.SugarLogger.Infof("CLICKHOUSE_PORT is not set, defaulting to %s", ClickhousePort) + } + if ClickhouseUser == "" { + ClickhouseUser = "default" + logger.SugarLogger.Infof("CLICKHOUSE_USER is not set, defaulting to %s", ClickhouseUser) + } + if ClickhouseDatabase == "" { + ClickhouseDatabase = "mapache" + logger.SugarLogger.Infof("CLICKHOUSE_DATABASE is not set, defaulting to %s", ClickhouseDatabase) + } + } + if MQTTHost == "" { + MQTTHost = "localhost" + logger.SugarLogger.Infof("MQTT_HOST is not set, defaulting to %s", MQTTHost) + } + if MQTTPort == "" { + MQTTPort = "1883" + logger.SugarLogger.Infof("MQTT_PORT is not set, defaulting to %s", MQTTPort) + } + if VehicleUploadKeyCacheTTL == "" { + VehicleUploadKeyCacheTTL = "600" + logger.SugarLogger.Infof("VEHICLE_UPLOAD_KEY_CACHE_TTL is not set, defaulting to %s", VehicleUploadKeyCacheTTL) + } +} diff --git a/p987/database/db.go b/p987/database/db.go new file mode 100644 index 00000000..bed2cfca --- /dev/null +++ b/p987/database/db.go @@ -0,0 +1,129 @@ +package database + +import ( + "context" + "fmt" + "time" + + "github.com/gaucho-racing/mapache/p987/config" + "github.com/gaucho-racing/mapache/p987/pkg/logger" + + "github.com/ClickHouse/clickhouse-go/v2" + "github.com/ClickHouse/clickhouse-go/v2/lib/driver" + mapache "github.com/gaucho-racing/mapache/mapache-go/v3" +) + +var Conn driver.Conn + +var dbRetries = 0 + +func options(database string) *clickhouse.Options { + return &clickhouse.Options{ + Addr: []string{fmt.Sprintf("%s:%s", config.ClickhouseHost, config.ClickhousePort)}, + Auth: clickhouse.Auth{ + Database: database, + Username: config.ClickhouseUser, + Password: config.ClickhousePassword, + }, + DialTimeout: 10 * time.Second, + } +} + +// InsertCtx flags inserts as server-side async — CH coalesces small INSERTs +// into larger parts and we don't block on the flush. +func InsertCtx(parent context.Context) context.Context { + return clickhouse.Context(parent, clickhouse.WithSettings(clickhouse.Settings{ + "async_insert": 1, + "wait_for_async_insert": 0, + })) +} + +func Init() { + if err := connect(); err != nil { + if dbRetries < 5 { + dbRetries++ + logger.SugarLogger.Errorln("failed to connect clickhouse, retrying in 5s... ", err) + time.Sleep(time.Second * 5) + Init() + return + } + logger.SugarLogger.Fatalf("failed to connect clickhouse after 5 attempts: %v", err) + } +} + +func connect() error { + ctx := context.Background() + + // Bootstrap through "default" so we can CREATE DATABASE before opening the app conn. + bootstrap, err := clickhouse.Open(options("default")) + if err != nil { + return err + } + if err := bootstrap.Exec(ctx, fmt.Sprintf("CREATE DATABASE IF NOT EXISTS %s", config.ClickhouseDatabase)); err != nil { + bootstrap.Close() + return err + } + bootstrap.Close() + + conn, err := clickhouse.Open(options(config.ClickhouseDatabase)) + if err != nil { + return err + } + if err := conn.Ping(ctx); err != nil { + conn.Close() + return err + } + if err := migrate(ctx, conn); err != nil { + conn.Close() + return err + } + logger.SugarLogger.Infoln("Connected to ClickHouse") + Conn = conn + return nil +} + +func migrate(ctx context.Context, conn driver.Conn) error { + for _, stmt := range []string{mapache.SignalClickHouseDDL, canDDL, mapache.PingClickHouseDDL} { + if err := conn.Exec(ctx, stmt); err != nil { + return err + } + } + logger.SugarLogger.Infoln("ClickHouse migration complete") + return nil +} + +// p987_can DDL lives here (service-specific); signal/ping DDL live on +// mapache-go. node_id holds the bus label, so it belongs in the sort key: +// two physical buses have independent 11-bit id spaces, and the same +// arbitration id on each is a different signal. +const canDDL = ` +CREATE TABLE IF NOT EXISTS p987_can ( + id String CODEC(ZSTD(1)), + + vehicle_id LowCardinality(String), + + node_id LowCardinality(String), + + timestamp Int64 CODEC(Delta, ZSTD(1)), + + can_id Int32 CODEC(T64, ZSTD(1)), + + bytes String CODEC(ZSTD(1)), + + upload_key Int32 CODEC(T64, ZSTD(1)), + + metadata String CODEC(ZSTD(1)), + + produced_at DateTime64(6, 'UTC') + MATERIALIZED fromUnixTimestamp64Micro(timestamp) + CODEC(Delta, ZSTD(1)), + + created_at DateTime64(6, 'UTC') + DEFAULT now64(6) + CODEC(Delta, ZSTD(1)), + + INDEX idx_id id TYPE bloom_filter GRANULARITY 4 +) +ENGINE = ReplacingMergeTree(created_at) +PARTITION BY toYYYYMM(produced_at) +ORDER BY (vehicle_id, timestamp, node_id, can_id)` diff --git a/p987/dbc/cayman_987.dbc b/p987/dbc/cayman_987.dbc new file mode 100644 index 00000000..c856edf9 --- /dev/null +++ b/p987/dbc/cayman_987.dbc @@ -0,0 +1,521 @@ +VERSION "" + +NS_ : + NS_DESC_ + CM_ + BA_DEF_ + BA_ + VAL_ + CAT_DEF_ + CAT_ + FILTER + BA_DEF_DEF_ + EV_DATA_ + ENVVAR_DATA_ + SGTYPE_ + SGTYPE_VAL_ + BA_DEF_SGTYPE_ + BA_SGTYPE_ + SIG_TYPE_REF_ + VAL_TABLE_ + SIG_GROUP_ + SIG_VALTYPE_ + SIGTYPE_VALTYPE_ + BO_TX_BU_ + BA_DEF_REL_ + BA_REL_ + BA_DEF_DEF_REL_ + BU_SG_REL_ + BU_EV_REL_ + BU_BO_REL_ + SG_MUL_VAL_ + +BS_: + +BU_: DME PSM PDK SCCM PAS Gateway KLIMO DIAG + + +; ═══════════════════════════════════════════════════════════════════ +; Porsche 987 Cayman — Powertrain CAN Bus DBC +; ═══════════════════════════════════════════════════════════════════ +; Decoded from cayman_startup_idle.csv (61,890 msgs, 35 CAN IDs) +; Cross-referenced with Porsche 997.1 DBC. +; +; Confidence markers: +; [✓] = validated against CSV — formula produces correct physical values +; [~] = ported from 997 DBC — structurally correct, scaling may need check +; [?] = placeholder — ID exists but signal layout is unverified +; ═══════════════════════════════════════════════════════════════════ + + +; ─── 0x0C2 (194) — SCCM1: Steering Angle ────────────────────────── +BO_ 194 SCCM1: 8 SCCM + SG_ SCCM_SteeringAngleSign : 15|1@1+ (1,0) [0|1] "" SCCM + SG_ SCCM_SteeringAngleRateSign : 31|1@1+ (1,0) [0|1] "" SCCM + SG_ SCCM_SteeringAngleRate : 18|13@1+ (0.175,0) [0|0] "deg/sec" SCCM + SG_ SCCM_SteeringAngle : 2|13@1+ (0.175,0) [0|0] "deg" SCCM + SG_ SCCM_SensorID : 32|8@1+ (1,0) [0|255] "" SCCM + SG_ SCCM_Counter : 44|4@1+ (1,0) [0|15] "" SCCM + SG_ SCCM_Checksum : 56|8@1+ (1,0) [0|255] "" SCCM + + +; ─── 0x140 (320) — DME Heartbeat [✓] ───────────────────────────── +; D4 = D1 XOR D2 XOR D3 (verified 21/21 unique payloads) +BO_ 320 Heartbeat: 4 DME + SG_ HB_Page : 0|8@1+ (1,0) [0|255] "" DME + SG_ HB_SourceID : 8|8@1+ (1,0) [0|255] "" DME + SG_ HB_Counter : 16|4@1+ (1,0) [0|15] "" DME + SG_ HB_Checksum : 24|8@1+ (1,0) [0|255] "" DME + + +; ─── 0x14A (330) — PSM1: Vehicle Speed, Brake/ESP Status [~] ──── +BO_ 330 PSM1: 8 PSM + SG_ ASR_Requirement : 0|1@1+ (1,0) [0|1] "" PSM + SG_ MSR_Requirement : 1|1@1+ (1,0) [0|1] "" PSM + SG_ ABS_Status : 2|1@1+ (1,0) [0|1] "" PSM + SG_ Brake_Intervention : 3|1@1+ (1,0) [0|1] "" PSM + SG_ ESP_Intervention : 4|1@1+ (1,0) [0|1] "" PSM + SG_ ASR_Switching : 5|2@1+ (1,0) [0|3] "" PSM + SG_ ESP_Control : 7|1@1+ (1,0) [0|1] "" PSM + SG_ ABS_Error : 8|1@1+ (1,0) [0|1] "" PSM + SG_ ESP_Error : 9|1@1+ (1,0) [0|1] "" PSM + SG_ EBV_Error : 10|1@1+ (1,0) [0|1] "" PSM + SG_ PSM_FootBrake : 11|1@1+ (1,0) [0|1] "" PSM + SG_ PSM_FootBrake2 : 12|1@1+ (1,0) [0|1] "" PSM + SG_ PSM_Disabled : 13|1@1+ (1,0) [0|1] "" PSM + SG_ Brake_Fluid_Switch : 14|1@1+ (1,0) [0|1] "" PSM + SG_ PSM_HandBrake : 15|1@1+ (1,0) [0|1] "" PSM + SG_ ESP_Diag_Mode : 16|1@1+ (1,0) [0|1] "" PSM + SG_ Vref : 16|16@1+ (0.01,0) [0|655.35] "km/h" PSM + SG_ PSM_TorqueReqSlow : 32|8@1+ (1,0) [0|255] "" PSM + SG_ PSM_TorqueReqFast : 40|8@1+ (1,0) [0|255] "" PSM + SG_ Engagement_Torque : 48|8@1+ (0.39,0) [0|99.45] "%" PSM + SG_ PSM_LateralAccel : 56|8@1+ (1,0) [0|255] "" PSM + +CM_ SG_ 330 Vref "Vehicle reference speed. (D4*256 + D3) / 100 = km/h. Verified against OBD2 GPS speed (0-48 mph town drive)."; + + + +; ─── 0x165 (357) — PAS: Key Position [✓] ──────────────────────── +; Observed: Off (424×), Ignition On (212×), Engine Start (5×) +BO_ 357 PAS: 1 PAS + SG_ PAS_KeyPresent : 0|1@1+ (1,0) [0|1] "" PAS + SG_ PAS_KeyPosition : 2|2@1+ (1,0) [0|3] "" PAS + +VAL_ 357 PAS_KeyPosition 2 "Engine Start" 1 "Ignition On" 0 "Ignition Off" ; + + +; ─── 0x210 (528) — SCCM2: Cruise Control Buttons [~] ───────────── +BO_ 528 SCCM2: 4 SCCM + SG_ SCCM_CruiseEnable : 8|1@1+ (1,0) [0|1] "" SCCM + SG_ SCCM_CruiseDown : 9|1@1+ (1,0) [0|1] "" SCCM + SG_ SCCM_CruiseTowards : 10|1@1+ (1,0) [0|1] "" SCCM + SG_ SCCM_CruiseAway : 11|1@1+ (1,0) [0|1] "" SCCM + SG_ SCCM_CruiseTowardsHold : 12|1@1+ (1,0) [0|1] "" SCCM + SG_ SCCM_CruiseAwayHold : 13|1@1+ (1,0) [0|1] "" SCCM + SG_ SCCM_CruiseAvailable : 15|1@1+ (1,0) [0|1] "" SCCM + SG_ SCCM_CruiseUp : 17|1@1+ (1,0) [0|1] "" SCCM + SG_ SCCM_CruiseCount1 : 4|4@1+ (1,0) [0|15] "" SCCM + SG_ SCCM_CruiseCount2 : 20|4@1+ (1,0) [0|15] "" SCCM + + +; ─── 0x242 (578) — DME1: Engine Speed, Torque, Pedal ───────────── +; +; RPM is at D3-D4 (bit 16), Intel byte order: (D4 << 8 | D3) * 0.25. +; This exactly matches the 997 DBC definition (0.25 RPM/bit). +; +; Verified against cayman_startup_idle.csv: +; Warm idle: D4=0x0A D3=0xA4 → (0x0AA4) * 0.25 = 681 RPM +; DME2 idle target: 680 RPM ✓ (perfect match) +; Cranking: ~124-138 RPM ✓ (starter motor speed) +; Max range: 0–16384 RPM ✓ (ample headroom above 7400 redline) +; +; D6 (APP) = 0x00 at warm idle = 0% pedal ✓ +; D2 (EngineTorque) varies with engine load — torque-related. +; +BO_ 578 DME1: 8 DME + SG_ DME1_Counter : 0|4@1+ (1,0) [0|15] "" DME + SG_ DME_EngineTorque : 8|8@1+ (0.39,0) [0|99.45] "%" DME + SG_ DME_RPM : 16|16@1+ (0.25,0) [0|16383.75] "rpm" DME + SG_ DME_Interventions : 32|8@1+ (0.39,0) [0|99.45] "%" DME + SG_ DME_APP : 40|8@1+ (0.39215,0) [0|99.998] "%" DME + SG_ DME_TorqueLoss : 48|8@1+ (0.39,0) [0|99.45] "%" DME + SG_ DME_DriverTrq : 56|8@1+ (0.781,0) [0|199.155] "%" DME + +CM_ SG_ 578 DME_RPM "Engine speed at D3-D4 (bit 16), Intel byte order. 0.25 RPM/bit — verified: warm idle = 681 RPM vs 680 target. Cranking = ~130 RPM. Matches 997 DBC exactly. Max range: 16383 RPM."; +CM_ SG_ 578 DME_EngineTorque "Actual engine torque. 0.39 %/bit. Validated: 14% at warm idle (plausible)."; +CM_ SG_ 578 DME_APP "Accelerator pedal position. Verified: 0% at warm idle (foot off). 0.39215 %/bit."; + + +; ─── 0x245 (581) — DME2: Coolant Temp, Idle Target [✓] ────────── +BO_ 581 DME2: 8 DME + SG_ DME2_MUL_Code m0 : 0|6@1+ (1,0) [0|63] "" DME + SG_ DME2_MUL_Code m1 : 0|6@1+ (1,0) [0|63] "" DME + SG_ DME2_MUL_Code m2 : 0|6@1+ (1,0) [0|63] "" DME + SG_ DME2_MUL_Code m3 : 0|6@1+ (1,0) [0|63] "" DME + SG_ DME2_MUX : 6|2@1+ (1,0) [0|3] "" DME + SG_ DME_CoolantTemp : 8|8@1+ (0.75,-48) [-48|143.25] "°C" DME + SG_ DME2_B2_B3 m0 : 16|16@1+ (0.25,0) [0|16383.75] "" DME + SG_ DME2_B2_B3 m1 : 16|16@1+ (1,0) [0|65535] "" DME + SG_ DME2_B2_B3 m2 : 16|16@1+ (1,0) [0|65535] "" DME + SG_ DME2_B2_B3 m3 : 16|16@1+ (1,0) [0|65535] "" DME + SG_ DME_IdleSpeedTarget : 32|8@1+ (10,0) [0|2550] "rpm" DME + SG_ DME_MomentBase : 40|8@1+ (0.39,0) [0|99.45] "%" DME + SG_ DME2_Counter : 48|4@1+ (1,0) [0|15] "" DME + SG_ DME2_TorqueIndexed : 56|8@1+ (0.39215,0) [0|99.998] "%" DME + +VAL_ 581 DME2_MUX 3 "Page3" 2 "Page2_Gearbox" 1 "Page1_Engine" 0 "Page0_Base" ; + +CM_ SG_ 581 DME_CoolantTemp "Coolant temperature. Verified: 66.8–69.0°C in capture. Formula: D2 × 0.75 − 48."; +CM_ SG_ 581 DME_IdleSpeedTarget "Idle speed target. Verified: 680–750 RPM in capture. Formula: D5 × 10."; + + +; ─── 0x246 (582) — DME3: Gear, Ambient Pressure [~] ────────────── +BO_ 582 DME3: 8 DME + SG_ DME_EngagedGear : 0|3@1+ (1,0) [0|7] "" DME + SG_ DME_KickdownActive : 3|1@1+ (1,0) [0|1] "" DME + SG_ DME_CompressorRunning : 4|1@1+ (1,0) [0|1] "" DME + SG_ DME_CompressorFault : 5|1@1+ (1,0) [0|1] "" DME + SG_ Sport_Mode_Error : 6|1@1+ (1,0) [0|1] "" DME + SG_ Ambient_Pressure_Error : 7|1@1+ (1,0) [0|1] "" DME + SG_ DME_GearRequirement : 8|3@1+ (1,0) [0|7] "" DME + SG_ DME_GearRequest : 11|3@1+ (1,0) [0|7] "" DME + SG_ DME_TRQ_Target_Error : 14|1@1+ (1,0) [0|1] "" DME + SG_ DME_Overboost : 15|1@1+ (1,0) [0|1] "" DME + SG_ DME_GB_TargetTrq : 16|8@1+ (0.39,0) [0|99.45] "%" DME + SG_ DME_AccelPedalAngle : 24|8@1+ (0.4,0) [0|99.998] "%" DME + SG_ DME_GB_TrqActual : 32|8@1+ (0.39,0) [0|99.45] "%" DME + SG_ DME_AmbientPressure : 40|8@1+ (5,0) [0|1275] "mbar" DME + SG_ DME3_Counter : 48|4@1+ (1,0) [0|15] "" DME + SG_ DME3_Muxed m0 : 56|8@1+ (0.3937,0) [0|100.39] "%" DME + SG_ DME3_Muxed m1 : 56|8@1+ (0.39215,0) [0|99.998] "%" DME + SG_ DME3_Muxed m2 : 56|8@1+ (1,-40) [-40|215] "°C" DME + SG_ DME3_Muxed m3 : 56|8@1+ (0.75,-96) [-96|95.25] "°" DME + +VAL_ 582 DME_EngagedGear 7 "Reverse" 6 "6th" 5 "5th" 4 "4th" 3 "3rd" 2 "2nd" 1 "1st" 0 "N/P" ; + +CM_ SG_ 582 DME_AmbientPressure "Ambient pressure. Decodes to 990–995 mbar (sea level). D6 × 5."; + + +; ─── 0x24A (586) — PSM2: Wheel Speeds [~] ─────────────────────── +BO_ 586 PSM2: 8 PSM + SG_ PSM_WheelSpeedFL : 0|16@1+ (0.01,0) [0|655.35] "km/h" PSM + SG_ PSM_WheelSpeedFR : 16|16@1+ (0.01,0) [0|655.35] "km/h" PSM + SG_ PSM_WheelSpeedRL : 32|16@1+ (0.01,0) [0|655.35] "km/h" PSM + SG_ PSM_WheelSpeedRR : 48|16@1+ (0.01,0) [0|655.35] "km/h" PSM + +CM_ SG_ 586 PSM_WheelSpeedFL "Wheel speed. Full 16-bit per wheel, 0.01 km/h per bit. Verified against OBD2 GPS and vehicle speed. Formula: (D2*256 + D1) / 100 for FL, etc."; + + + +; ─── 0x303 (771) — DME: Lambda / AFR [?] ───────────────────────── +; 7-byte message, variable data, not in 997 DBC. +; D1 = counter (0x00–0x05), D3-D4 vary as 16-bit, D6 is checksum-like. +BO_ 771 DME_Lambda: 7 DME + SG_ DME_Lambda_Counter : 0|4@1+ (1,0) [0|15] "" DME + SG_ DME_Lambda_Value : 16|16@1+ (0.0001,0) [0|6.5535] "" DME + SG_ DME_Lambda_Status : 32|8@1+ (1,0) [0|255] "" DME + SG_ DME_Lambda_Checksum : 40|8@1+ (1,0) [0|255] "" DME + SG_ DME_Lambda_Flags : 48|8@1+ (1,0) [0|255] "" DME + +CM_ BO_ 771 "Lambda / AFR / intake data. 7-byte DLC unique to 987. Signal layout is approximate — verify with wideband O2."; + + +; ─── 0x308 (776) — Body Control: Lights, Fans, Outside Temp [~] ── +BO_ 776 DRIVEMODE: 8 DME + SG_ LT_Rad_Fan_PWM : 0|2@1+ (1,0) [0|3] "" DME + SG_ RT_Rad_Fan_PWM : 2|2@1+ (1,0) [0|3] "" DME + SG_ Trunk_Lid_Open : 4|1@1+ (1,0) [0|1] "" DME + SG_ Sport_Mode : 5|1@1+ (1,0) [0|1] "" DME + SG_ Wiper_Status : 6|1@1+ (1,0) [0|1] "" DME + SG_ Radio_Key : 7|4@1+ (1,0) [0|15] "" DME + SG_ Low_Beam : 11|1@1+ (1,0) [0|1] "" DME + SG_ Reverse_Light : 12|1@1+ (1,0) [0|1] "" DME + SG_ High_Beam : 14|1@1+ (1,0) [0|1] "" DME + SG_ Outside_Temp : 32|8@1+ (0.5,-50) [-50|77.5] "°C" DME + + +; ─── 0x31F (799) — DME: Engine Running Status [?] ──────────────── +; Constant payload throughout capture: 06 60 34 FF 00 00 0F 00 +BO_ 799 DME_Status: 8 DME + SG_ DME_Status_ID : 0|16@1+ (1,0) [0|65535] "" DME + SG_ DME_Status_Flags1 : 16|8@1+ (1,0) [0|255] "" DME + SG_ DME_Status_Flags2 : 24|8@1+ (1,0) [0|255] "" DME + SG_ DME_Status_Reserved : 32|16@1+ (1,0) [0|65535] "" DME + SG_ DME_Status_Counter : 48|16@1+ (1,0) [0|65535] "" DME + +CM_ BO_ 799 "Engine running status broadcast. Constant data during idle — likely ECU health + counters."; + + +; ─── 0x441 (1089) — DME4: Oil, Boost, Alerts [✓] ──────────────── +BO_ 1089 DME4: 8 DME + SG_ DME_CEL_Flashing : 0|1@1+ (1,0) [0|1] "" DME + SG_ DME_CEL_Steady : 1|1@1+ (1,0) [0|1] "" DME + SG_ DME_FuelReserve : 2|1@1+ (1,0) [0|1] "" DME + SG_ DME_ReducedPower : 3|1@1+ (1,0) [0|1] "" DME + SG_ DME_EngCompFanAlert : 4|1@1+ (1,0) [0|1] "" DME + SG_ DME_OilTempSensFault : 5|1@1+ (1,0) [0|1] "" DME + SG_ DME_OilPressureAlert : 6|1@1+ (1,0) [0|1] "" DME + SG_ DME_ChargingAlert : 7|1@1+ (1,0) [0|1] "" DME + SG_ DME_RadFanSpeedReq : 8|7@1+ (1,0) [0|127] "%" DME + SG_ DME_EngineRunning : 15|1@1+ (1,0) [0|1] "" DME + SG_ DME_FuelConsumption1 : 16|8@1+ (1,0) [0|255] "µl" DME + SG_ DME_FuelConsumption2 : 24|8@1+ (1,0) [0|255] "µl" DME + SG_ DME_BoostPressure : 32|8@1+ (0.01,0) [0|2.55] "bar" DME + SG_ DME_OilTemp : 40|8@1+ (0.75,-48) [-48|143.25] "°C" DME + SG_ DME_OilPressure : 48|8@1+ (0.04,0) [0|10.2] "bar" DME + SG_ DME_CoolantLevelSW : 56|1@1+ (1,0) [0|1] "" DME + SG_ DME_EngineCompTemp : 57|6@1- (1,-48) [-48|15] "°C" DME + SG_ DME_EngCompTempFail : 63|1@1+ (1,0) [0|1] "" DME + +CM_ SG_ 1089 DME_OilTemp "Oil temperature. Verified: 42.0–43.5°C in capture. D6 × 0.75 − 48."; +CM_ SG_ 1089 DME_BoostPressure "Boost pressure at D5 (byte 4, bit 32). Reads 0.00 bar on NA 987 Cayman. 0.01 bar/bit. On 997 Turbo this carries actual boost; shared CAN layout with NA cars."; +CM_ SG_ 1089 DME_CEL_Steady "Check-engine light steady. Flagged in this capture — DTC may be present."; +CM_ SG_ 1089 DME_EngineRunning "1 = engine speed > 40 RPM. Verified: 2150/2163 msgs show running."; + + +; ─── 0x44A (1098) — PSM3 [?] ──────────────────────────────────── +BO_ 1098 PSM3: 8 PSM + SG_ PSM3_B0 : 0|8@1+ (1,0) [0|255] "" PSM + SG_ PSM3_B1 : 8|8@1+ (1,0) [0|255] "" PSM + SG_ PSM3_B2 : 16|8@1+ (1,0) [0|255] "" PSM + SG_ PSM3_B3 : 24|8@1+ (1,0) [0|255] "" PSM + SG_ PSM3_B4 : 32|8@1+ (1,0) [0|255] "" PSM + SG_ PSM3_B5 : 40|8@1+ (1,0) [0|255] "" PSM + SG_ PSM3_B6 : 48|8@1+ (1,0) [0|255] "" PSM + SG_ PSM3_B7 : 56|8@1+ (1,0) [0|255] "" PSM + + +; ─── 0x44B (1099) — PSM4: Yaw, Accel, Brake Pressure [~] ──────── +BO_ 1099 PSM4: 8 PSM + SG_ PSM_BrakePressure : 0|8@1+ (1,0) [0|255] "Bar" PSM + SG_ Yaw_Rate : 16|9@1+ (0.0021326,0) [0|1.091] "rad/s" PSM + SG_ Yaw_Rate_Sign : 25|1@1+ (1,0) [0|1] "" PSM + SG_ Longitudinal_Accel : 56|8@1+ (0.015,-1.8) [-1.8|2.025] "g" PSM + + +; ─── 0x44C (1100) — PDK: Gearbox Status [?] ───────────────────── +BO_ 1100 PDK1: 8 PDK + SG_ PDK_SelectedGear : 0|3@1+ (1,0) [0|7] "" PDK + SG_ PDK_ShiftFork1 : 8|8@1+ (1,0) [0|255] "" PDK + SG_ PDK_ShiftFork2 : 16|8@1+ (1,0) [0|255] "" PDK + SG_ PDK_ClutchStatus : 24|8@1+ (1,0) [0|255] "" PDK + SG_ PDK_OilTemp : 32|8@1+ (0.75,-48) [-48|143.25] "°C" PDK + SG_ PDK_Counter : 48|4@1+ (1,0) [0|15] "" PDK + SG_ PDK_Checksum : 56|8@1+ (1,0) [0|255] "" PDK + + +; ─── 0x44F (1103) — PDK: Fault Flags [?] ──────────────────────── +; Always 0x0000 in this capture = no PDK faults. +BO_ 1103 PDK_Flags: 2 PDK + SG_ PDK_ErrorFlags : 0|16@1+ (1,0) [0|65535] "" PDK + +CM_ BO_ 1103 "PDK fault flags. All zero in this capture — gearbox healthy."; + + +; ─── 0x470 (1136) — DME: Torque Coordination [?] ───────────────── +BO_ 1136 DME_Torque: 8 DME + SG_ DME_Torque_Counter : 0|4@1+ (1,0) [0|15] "" DME + SG_ DME_Torque_Req1 : 8|8@1+ (0.39,0) [0|99.45] "%" DME + SG_ DME_Torque_Req2 : 16|8@1+ (0.39,0) [0|99.45] "%" DME + SG_ DME_Torque_Req3 : 24|8@1+ (0.39,0) [0|99.45] "%" DME + SG_ DME_Torque_Max : 32|8@1+ (0.39,0) [0|99.45] "%" DME + SG_ DME_Torque_Min : 40|8@1+ (0.39,0) [0|99.45] "%" DME + SG_ DME_Torque_Checksum : 56|8@1+ (1,0) [0|255] "" DME + + +; ─── 0x502 (1282) — CLUSTER1: Instrument Cluster [~] ───────────── +BO_ 1282 CLUSTER1: 8 DME + SG_ CLUSTER1_B0 : 0|8@1+ (1,0) [0|255] "" DME + SG_ CLUSTER1_B1 : 8|8@1+ (1,0) [0|255] "" DME + SG_ CLUSTER1_Flags : 16|5@1+ (1,0) [0|31] "" DME + SG_ CLUSTER_ClutchSW : 21|1@1+ (1,0) [0|1] "" DME + SG_ CLUSTER1_B2u : 22|2@1+ (1,0) [0|3] "" DME + SG_ CLUSTER_AmbBrightness : 24|8@1+ (0.3922,0) [0|100] "%" DME + SG_ CLUSTER1_B4 : 32|8@1+ (1,0) [0|255] "" DME + SG_ CLUSTER1_B5 : 40|8@1+ (1,0) [0|255] "" DME + SG_ CLUSTER1_B6 : 48|8@1+ (1,0) [0|255] "" DME + SG_ CLUSTER1_B7 : 56|8@1+ (1,0) [0|255] "" DME + + +; ─── 0x513 (1299) — Immobilizer Challenge [?] ──────────────────── +; One-shot, 5 bytes. Appears only at startup. +BO_ 1299 Immobilizer: 5 DME + SG_ Immo_Challenge : 0|40@1+ (1,0) [0|1099511627775] "" DME + + +; ─── 0x600 (1536) — KLIMA: Climate Control [~] ─────────────────── +BO_ 1536 KLIMA: 8 KLIMO + SG_ HVAC_Fan_Increase : 0|1@1+ (1,0) [0|1] "" KLIMO + SG_ HVAC_Display_On : 2|1@1+ (1,0) [0|1] "" KLIMO + SG_ KLIMA_CompressorReq : 3|1@1+ (1,0) [0|1] "" KLIMO + SG_ KLIMA_B1_Bits : 8|4@1+ (1,0) [0|15] "" KLIMO + SG_ KLIMA_RearDefrost : 12|1@1+ (1,0) [0|1] "" KLIMO + SG_ KLIMA_BlowerStage : 13|3@1+ (1,0) [0|7] "" KLIMO + SG_ KLIMA_RefrigPressure : 16|8@1+ (0.2,0) [0|51] "Bar" KLIMO + SG_ KLIMA_B3_Temp : 24|8@1+ (0.5,-50) [-50|77.5] "°C" KLIMO + SG_ KLIMA_BlowerSpeed : 32|8@1+ (1,0) [0|255] "" KLIMO + SG_ KLIMA_InsideTemp : 40|8@1+ (0.5,-50) [-50|77.5] "°C" KLIMO + SG_ KLIMA_B6_Temp : 48|8@1+ (0.5,-50) [-50|77.5] "°C" KLIMO + SG_ KLIMA_B7 : 56|8@1+ (1,0) [0|255] "" KLIMO + + +; ─── 0x62A / 0x66B (1578 / 1643) — ECU Identification [?] ─────── +; Both carry same constant payload: 41 41 08 42 55 67 10 93 +BO_ 1578 ECU_ID1: 8 DME + SG_ ECU_ID1_Bytes : 0|64@1+ (1,0) [0|1.84e19] "" DME + +BO_ 1583 ECU_Coding: 8 DME + SG_ ECU_Coding_Bytes : 0|64@1+ (1,0) [0|1.84e19] "" DME + +CM_ BO_ 1578 "ECU identification. Constant: 41 41 08 42 55 67 10 93. Appears at startup only."; +CM_ BO_ 1583 "ECU calibration/coding data. Constant: 59 8F 9E 02 23 00 80 C7."; + + +; ─── 0x669 (1641) — DME6: Odometer, Country Code [~] ───────────── +BO_ 1641 DME6: 8 DME + SG_ DME_Odometer : 0|20@1+ (1,0) [0|1048575] "km" DME + SG_ DME_CountryCode : 24|7@1+ (1,0) [0|127] "" DME + SG_ DME6_StatusBit : 32|1@1+ (1,0) [0|1] "" DME + SG_ DME6_Counter : 40|8@1+ (1,0) [0|255] "" DME + SG_ DME6_Data : 48|16@1+ (1,0) [0|65535] "" DME + + +; ─── 0x66B — same as 0x62A, see above ──────────────────────────── + + +; ─── 0x70B (1803) — Gateway: Module Config [?] ─────────────────── +BO_ 1803 Gateway_Cfg: 8 Gateway + SG_ GW_Config_Data : 0|64@1+ (1,0) [0|1.84e19] "" Gateway + +CM_ BO_ 1803 "Gateway module configuration. Constant during capture."; + + +; ─── 0x70D (1805) — Gateway: Network Config [?] ────────────────── +BO_ 1805 Gateway_Net: 8 Gateway + SG_ GW_Network_Data : 0|64@1+ (1,0) [0|1.84e19] "" Gateway + +CM_ BO_ 1805 "Gateway network configuration. Transitions 0x00→active during startup."; + + +; ─── 0x716 (1814) — DME8: Software Version [~] ─────────────────── +; Payload: 35 65 00 00 00 00 00 00 (ASCII "5e") +BO_ 1814 DME8_Version: 8 DME + SG_ DME_SW_Byte0 : 0|8@1+ (1,0) [0|255] "" DME + SG_ DME_SW_Byte1 : 8|8@1+ (1,0) [0|255] "" DME + SG_ DME_SW_Byte2 : 16|8@1+ (1,0) [0|255] "" DME + SG_ DME_SW_Byte3 : 24|8@1+ (1,0) [0|255] "" DME + SG_ DME_SW_Byte4 : 32|8@1+ (1,0) [0|255] "" DME + SG_ DME_SW_Byte5 : 40|8@1+ (1,0) [0|255] "" DME + SG_ DME_SW_Byte6 : 48|8@1+ (1,0) [0|255] "" DME + SG_ DME_SW_Byte7 : 56|8@1+ (1,0) [0|255] "" DME + +CM_ BO_ 1814 "Motronic software version. ASCII '5e' in bytes 0-1."; + + +; ─── 0x718 (1816) — PSM5 [?] ──────────────────────────────────── +BO_ 1816 PSM5: 8 PSM + SG_ PSM5_B0 : 0|8@1+ (1,0) [0|255] "" PSM + SG_ PSM5_B1 : 8|8@1+ (1,0) [0|255] "" PSM + SG_ PSM5_B2 : 16|8@1+ (1,0) [0|255] "" PSM + SG_ PSM5_B3 : 24|8@1+ (1,0) [0|255] "" PSM + SG_ PSM5_B4 : 32|8@1+ (1,0) [0|255] "" PSM + SG_ PSM5_B5 : 40|8@1+ (1,0) [0|255] "" PSM + SG_ PSM5_B6 : 48|8@1+ (1,0) [0|255] "" PSM + SG_ PSM5_B7 : 56|8@1+ (1,0) [0|255] "" PSM + + +; ─── 0x719 (1817) — Gateway: Wake/Sleep [?] ────────────────────── +; D4 transitions 0x0B → 0x5B during startup. +BO_ 1817 Gateway_State: 8 Gateway + SG_ GW_State_B0 : 0|8@1+ (1,0) [0|255] "" Gateway + SG_ GW_State_B1 : 8|8@1+ (1,0) [0|255] "" Gateway + SG_ GW_State_B2 : 16|8@1+ (1,0) [0|255] "" Gateway + SG_ GW_State_Change : 24|8@1+ (1,0) [0|255] "" Gateway + SG_ GW_State_B4_B7 : 32|32@1+ (1,0) [0|4294967295] "" Gateway + +CM_ BO_ 1817 "Gateway wake/sleep state. D4 changes 0x0B→0x5B at startup transition."; + + +; ─── 0x71A (1818) — SCCM3 [?] ──────────────────────────────────── +BO_ 1818 SCCM3: 8 SCCM + SG_ SCCM3_B0 : 0|8@1+ (1,0) [0|255] "" SCCM + SG_ SCCM3_B1 : 8|8@1+ (1,0) [0|255] "" SCCM + SG_ SCCM3_B2 : 16|8@1+ (1,0) [0|255] "" SCCM + SG_ SCCM3_B3 : 24|8@1+ (1,0) [0|255] "" SCCM + SG_ SCCM3_B4 : 32|8@1+ (1,0) [0|255] "" SCCM + SG_ SCCM3_B5 : 40|8@1+ (1,0) [0|255] "" SCCM + SG_ SCCM3_B6 : 48|8@1+ (1,0) [0|255] "" SCCM + SG_ SCCM3_B7 : 56|8@1+ (1,0) [0|255] "" SCCM + + +; ═══════════════════════════════════════════════════════════════════ +; Transmit cycle times (approximate, inferred from message counts) +; ═══════════════════════════════════════════════════════════════════ + +BA_DEF_ SG_ "GenSigSendType" ENUM "Cyclic","OnWrite","OnWriteWithRepetition","OnChange","OnChangeWithRepetition","IfActive","IfActiveWithRepetition","NoSigSendType"; +BA_DEF_ BO_ "GenMsgCycleTime" INT 0 65535; +BA_DEF_ BO_ "GenMsgSendType" ENUM "Cyclic","NotUsed","NotUsed","NotUsed","NotUsed","Cyclic","NotUsed","IfActive","NoMsgSendType"; +BA_DEF_ "DBName" STRING ; +BA_DEF_DEF_ "GenSigSendType" "Cyclic"; +BA_DEF_DEF_ "GenMsgCycleTime" 0; +BA_DEF_DEF_ "GenMsgSendType" "NoMsgSendType"; +BA_DEF_DEF_ "DBName" ""; + +BA_ "DBName" "Porsche_987_Cayman_Powertrain"; + +; ~10ms streaming +BA_ "GenMsgCycleTime" BO_ 320 10; +BA_ "GenMsgSendType" BO_ 320 0; +BA_ "GenMsgCycleTime" BO_ 578 10; +BA_ "GenMsgSendType" BO_ 578 0; +BA_ "GenMsgCycleTime" BO_ 581 10; +BA_ "GenMsgSendType" BO_ 581 0; +BA_ "GenMsgCycleTime" BO_ 582 10; +BA_ "GenMsgSendType" BO_ 582 0; +BA_ "GenMsgCycleTime" BO_ 771 10; +BA_ "GenMsgSendType" BO_ 771 0; +BA_ "GenMsgCycleTime" BO_ 1089 20; +BA_ "GenMsgSendType" BO_ 1089 0; +BA_ "GenMsgCycleTime" BO_ 1100 10; +BA_ "GenMsgSendType" BO_ 1100 0; +BA_ "GenMsgCycleTime" BO_ 1136 10; +BA_ "GenMsgSendType" BO_ 1136 0; + +; ~20ms +BA_ "GenMsgCycleTime" BO_ 194 10; +BA_ "GenMsgSendType" BO_ 194 0; +BA_ "GenMsgCycleTime" BO_ 330 20; +BA_ "GenMsgSendType" BO_ 330 0; +BA_ "GenMsgCycleTime" BO_ 586 20; +BA_ "GenMsgSendType" BO_ 586 0; +BA_ "GenMsgCycleTime" BO_ 1099 10; +BA_ "GenMsgSendType" BO_ 1099 0; +BA_ "GenMsgCycleTime" BO_ 528 20; +BA_ "GenMsgSendType" BO_ 528 0; + +; ~100-200ms +BA_ "GenMsgCycleTime" BO_ 357 200; +BA_ "GenMsgSendType" BO_ 357 0; +BA_ "GenMsgCycleTime" BO_ 776 100; +BA_ "GenMsgSendType" BO_ 776 0; +BA_ "GenMsgCycleTime" BO_ 799 100; +BA_ "GenMsgSendType" BO_ 799 0; +BA_ "GenMsgCycleTime" BO_ 1103 100; +BA_ "GenMsgSendType" BO_ 1103 0; +BA_ "GenMsgCycleTime" BO_ 1536 100; +BA_ "GenMsgSendType" BO_ 1536 0; + +; ~1s +BA_ "GenMsgCycleTime" BO_ 1282 1000; +BA_ "GenMsgSendType" BO_ 1282 0; + +; Startup-only (event-driven) +BA_ "GenMsgSendType" BO_ 1578 5; +BA_ "GenMsgSendType" BO_ 1583 5; +BA_ "GenMsgSendType" BO_ 1641 5; +BA_ "GenMsgSendType" BO_ 1814 5; +BA_ "GenMsgSendType" BO_ 1803 5; +BA_ "GenMsgSendType" BO_ 1805 5; +BA_ "GenMsgSendType" BO_ 1817 5; +BA_ "GenMsgSendType" BO_ 1299 5; diff --git a/p987/dbc/dbc.go b/p987/dbc/dbc.go new file mode 100644 index 00000000..8b478d78 --- /dev/null +++ b/p987/dbc/dbc.go @@ -0,0 +1,186 @@ +// Package dbc parses a subset of the DBC format and decodes CAN frames +// against it. +// +// gr26 describes its messages with mapache-go's Message/Field types, which +// model a frame as a sequence of whole-byte fields. That works for GR's own +// CAN, where the layout was designed alongside the decoder. It cannot +// describe stock Porsche CAN: signals there start at arbitrary bit offsets +// and run arbitrary bit lengths (SCCM_SteeringAngle is 13 bits starting at +// bit 2), and they carry a scale and offset. So p987 decodes from the DBC +// itself rather than from hand-written field lists. +package dbc + +import ( + "bufio" + "fmt" + "io" + "regexp" + "strconv" + "strings" +) + +// Signal is one decoded value within a message. +type Signal struct { + Name string + StartBit int + Length int + // LittleEndian is DBC byte order @1 (Intel). @0 is Motorola. + LittleEndian bool + Signed bool + Factor float64 + Offset float64 + Unit string + // Multiplexed marks a signal that is only present for a particular + // multiplexer value (the "m0" indicator). See Message.Multiplexed. + Multiplexed bool +} + +// Message is one CAN arbitration id and the signals it carries. +type Message struct { + ID uint32 + Name string + Length int + Signals []Signal +} + +// Database is a parsed DBC file, indexed by arbitration id. +type Database struct { + Messages map[uint32]*Message +} + +var ( + messageRe = regexp.MustCompile(`^BO_\s+(\d+)\s+([A-Za-z0-9_]+)\s*:\s*(\d+)\s+([A-Za-z0-9_]+)`) + // Signal name may be followed by a multiplexer indicator (M or m). + signalRe = regexp.MustCompile(`^\s*SG_\s+([A-Za-z0-9_]+)\s*(M|m\d+)?\s*:\s*(\d+)\|(\d+)@([01])([+-])\s*\(([^,]+),([^)]+)\)\s*\[([^|]*)\|([^\]]*)\]\s*"([^"]*)"`) +) + +// Parse reads a DBC file. Lines it does not recognize (BU_, CM_, VAL_, +// attribute definitions) are skipped: this decodes frames, it is not a +// general-purpose DBC editor. +func Parse(r io.Reader) (*Database, error) { + db := &Database{Messages: make(map[uint32]*Message)} + + scanner := bufio.NewScanner(r) + scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) + + var current *Message + line := 0 + for scanner.Scan() { + line++ + text := scanner.Text() + + if m := messageRe.FindStringSubmatch(text); m != nil { + id, err := strconv.ParseUint(m[1], 10, 32) + if err != nil { + return nil, fmt.Errorf("line %d: bad message id %q: %w", line, m[1], err) + } + length, err := strconv.Atoi(m[3]) + if err != nil { + return nil, fmt.Errorf("line %d: bad message length %q: %w", line, m[3], err) + } + msg := &Message{ID: uint32(id), Name: m[2], Length: length} + db.Messages[msg.ID] = msg + current = msg + continue + } + + // Must be the SG_ token itself, not a keyword that merely starts + // with it — the NS_ header block lists SG_MUL_VAL_. + if trimmed := strings.TrimSpace(text); isSignalLine(trimmed) { + if current == nil { + return nil, fmt.Errorf("line %d: signal outside any message", line) + } + sig, err := parseSignal(text) + if err != nil { + return nil, fmt.Errorf("line %d: %w", line, err) + } + current.Signals = append(current.Signals, sig) + continue + } + + // A blank line ends the current message block; anything else at + // column 0 starts a new section. + if strings.TrimSpace(text) == "" || !strings.HasPrefix(text, " ") { + current = nil + } + } + if err := scanner.Err(); err != nil { + return nil, err + } + if len(db.Messages) == 0 { + return nil, fmt.Errorf("no messages found") + } + return db, nil +} + +// isSignalLine reports whether a trimmed line opens with the SG_ keyword +// followed by a separator. +func isSignalLine(trimmed string) bool { + const kw = "SG_" + if !strings.HasPrefix(trimmed, kw) || len(trimmed) <= len(kw) { + return false + } + return trimmed[len(kw)] == ' ' || trimmed[len(kw)] == '\t' +} + +func parseSignal(text string) (Signal, error) { + m := signalRe.FindStringSubmatch(text) + if m == nil { + return Signal{}, fmt.Errorf("malformed signal: %q", strings.TrimSpace(text)) + } + + startBit, err := strconv.Atoi(m[3]) + if err != nil { + return Signal{}, fmt.Errorf("bad start bit %q: %w", m[3], err) + } + length, err := strconv.Atoi(m[4]) + if err != nil { + return Signal{}, fmt.Errorf("bad length %q: %w", m[4], err) + } + if length < 1 || length > 64 { + return Signal{}, fmt.Errorf("signal %s: length %d out of range", m[1], length) + } + factor, err := strconv.ParseFloat(strings.TrimSpace(m[7]), 64) + if err != nil { + return Signal{}, fmt.Errorf("bad factor %q: %w", m[7], err) + } + offset, err := strconv.ParseFloat(strings.TrimSpace(m[8]), 64) + if err != nil { + return Signal{}, fmt.Errorf("bad offset %q: %w", m[8], err) + } + + return Signal{ + Name: m[1], + StartBit: startBit, + Length: length, + LittleEndian: m[5] == "1", + Signed: m[6] == "-", + Factor: factor, + Offset: offset, + Unit: m[11], + Multiplexed: m[2] != "", + }, nil +} + +// SignalCount is the total number of signals across every message. +func (d *Database) SignalCount() int { + n := 0 + for _, m := range d.Messages { + n += len(m.Signals) + } + return n +} + +// MultiplexedCount is the number of signals skipped at decode time +// because they carry a multiplexer indicator. +func (d *Database) MultiplexedCount() int { + n := 0 + for _, m := range d.Messages { + for _, s := range m.Signals { + if s.Multiplexed { + n++ + } + } + } + return n +} diff --git a/p987/dbc/dbc_test.go b/p987/dbc/dbc_test.go new file mode 100644 index 00000000..6b14addd --- /dev/null +++ b/p987/dbc/dbc_test.go @@ -0,0 +1,125 @@ +package dbc + +import ( + "strings" + "testing" +) + +const sampleDBC = ` +BO_ 194 SCCM1: 8 SCCM + SG_ SCCM_SteeringAngleSign : 15|1@1+ (1,0) [0|1] "" SCCM + SG_ SCCM_SteeringAngle : 2|13@1+ (0.175,0) [0|0] "deg" SCCM + +BO_ 581 DME_Signed: 8 DME + SG_ DME_EngineCompTemp : 57|6@1- (1,-48) [-48|15] "degC" DME + +BO_ 582 DME_Muxed: 8 DME + SG_ DME2_MUL_Code m0 : 0|6@1+ (1,0) [0|63] "" DME + SG_ DME_Plain : 8|8@1+ (1,0) [0|255] "" DME +` + +func TestParse(t *testing.T) { + db, err := Parse(strings.NewReader(sampleDBC)) + if err != nil { + t.Fatalf("Parse: %v", err) + } + + if len(db.Messages) != 3 { + t.Fatalf("message count = %d, want 3", len(db.Messages)) + } + + msg, ok := db.Messages[194] + if !ok { + t.Fatal("message 194 missing") + } + if msg.Name != "SCCM1" || msg.Length != 8 { + t.Errorf("message 194 = %q len %d, want SCCM1 len 8", msg.Name, msg.Length) + } + if len(msg.Signals) != 2 { + t.Fatalf("message 194 signal count = %d, want 2", len(msg.Signals)) + } + + angle := msg.Signals[1] + if angle.Name != "SCCM_SteeringAngle" { + t.Errorf("signal name = %q", angle.Name) + } + if angle.StartBit != 2 || angle.Length != 13 { + t.Errorf("signal placement = %d|%d, want 2|13", angle.StartBit, angle.Length) + } + if !angle.LittleEndian || angle.Signed { + t.Errorf("signal byte order/sign = little:%v signed:%v, want little:true signed:false", angle.LittleEndian, angle.Signed) + } + if angle.Factor != 0.175 || angle.Offset != 0 { + t.Errorf("scaling = (%v,%v), want (0.175,0)", angle.Factor, angle.Offset) + } + if angle.Unit != "deg" { + t.Errorf("unit = %q, want deg", angle.Unit) + } +} + +func TestParseSignedAndNegativeOffset(t *testing.T) { + db, err := Parse(strings.NewReader(sampleDBC)) + if err != nil { + t.Fatalf("Parse: %v", err) + } + s := db.Messages[581].Signals[0] + if !s.Signed { + t.Error("DME_EngineCompTemp should be signed") + } + if s.Offset != -48 { + t.Errorf("offset = %v, want -48", s.Offset) + } +} + +func TestParseMarksMultiplexed(t *testing.T) { + db, err := Parse(strings.NewReader(sampleDBC)) + if err != nil { + t.Fatalf("Parse: %v", err) + } + msg := db.Messages[582] + if !msg.Signals[0].Multiplexed { + t.Error("m0 signal should be marked multiplexed") + } + if msg.Signals[1].Multiplexed { + t.Error("plain signal should not be marked multiplexed") + } + if db.MultiplexedCount() != 1 { + t.Errorf("MultiplexedCount = %d, want 1", db.MultiplexedCount()) + } +} + +func TestParseRejectsEmpty(t *testing.T) { + if _, err := Parse(strings.NewReader("VERSION \"\"\n")); err == nil { + t.Error("expected an error for a DBC with no messages") + } +} + +// The real file is the one that matters — a parser that only handles the +// synthetic sample above would be useless. +func TestParseEmbeddedCayman(t *testing.T) { + db, err := Cayman987() + if err != nil { + t.Fatalf("Cayman987: %v", err) + } + if len(db.Messages) != 30 { + t.Errorf("message count = %d, want 30", len(db.Messages)) + } + if db.SignalCount() != 214 { + t.Errorf("signal count = %d, want 214", db.SignalCount()) + } + // 0x24A PSM2 — a message we expect to decode on the car. + if _, ok := db.Messages[0x24A]; !ok { + t.Error("0x24A missing from the parsed database") + } + for id, m := range db.Messages { + if m.Length < 1 || m.Length > 64 { + t.Errorf("message 0x%X has implausible length %d", id, m.Length) + } + for _, s := range m.Signals { + if s.StartBit+s.Length > m.Length*8 { + t.Errorf("0x%X signal %s runs past the frame: %d|%d in %d bytes", + id, s.Name, s.StartBit, s.Length, m.Length) + } + } + } +} diff --git a/p987/dbc/decode.go b/p987/dbc/decode.go new file mode 100644 index 00000000..5545f29e --- /dev/null +++ b/p987/dbc/decode.go @@ -0,0 +1,129 @@ +package dbc + +import ( + _ "embed" + "strings" + "sync" +) + +//go:embed cayman_987.dbc +var caymanDBC string + +var ( + loadOnce sync.Once + loaded *Database + loadErr error +) + +// Cayman987 returns the embedded 987 database, parsed once. Embedding +// rather than reading a path keeps the image self-contained and makes a +// malformed DBC a startup failure instead of a runtime surprise. +func Cayman987() (*Database, error) { + loadOnce.Do(func() { + loaded, loadErr = Parse(strings.NewReader(caymanDBC)) + }) + return loaded, loadErr +} + +// Decoded is one signal decoded out of a frame. +type Decoded struct { + Name string + Value float64 + Raw int64 + Unit string +} + +// Decode extracts every non-multiplexed signal in msg from data. +// +// Multiplexed signals are skipped. Resolving them requires the message's +// multiplexer switch signal (the "M" indicator), and the 987 DBC declares +// multiplexed signals without ever declaring the switch — so there is no +// correct way to know which variant a given frame carries. Decoding them +// anyway would emit three wrong values for every right one. +// +// Signals that extend past the end of the frame are skipped rather than +// zero-filled: a short frame means the data is not what the DBC describes, +// and a plausible-looking zero is worse than a missing signal. +func (m *Message) Decode(data []byte) []Decoded { + out := make([]Decoded, 0, len(m.Signals)) + for _, s := range m.Signals { + if s.Multiplexed { + continue + } + raw, ok := s.extract(data) + if !ok { + continue + } + out = append(out, Decoded{ + Name: s.Name, + Value: float64(raw)*s.Factor + s.Offset, + Raw: raw, + Unit: s.Unit, + }) + } + return out +} + +// extract pulls the signal's raw integer out of the frame, applying sign +// extension. ok is false when the signal does not fit in the data. +func (s Signal) extract(data []byte) (int64, bool) { + var bits uint64 + if s.LittleEndian { + var ok bool + bits, ok = extractLittleEndian(data, s.StartBit, s.Length) + if !ok { + return 0, false + } + } else { + var ok bool + bits, ok = extractBigEndian(data, s.StartBit, s.Length) + if !ok { + return 0, false + } + } + + if s.Signed && s.Length < 64 && bits&(1<<(s.Length-1)) != 0 { + bits |= ^uint64(0) << s.Length + } + return int64(bits), true +} + +// extractLittleEndian reads Intel byte order: start bit is the signal's +// least significant bit, and bit numbering runs LSB-first within each byte +// and then upward through the bytes. +func extractLittleEndian(data []byte, startBit, length int) (uint64, bool) { + if startBit < 0 || length < 1 || startBit+length > len(data)*8 { + return 0, false + } + var v uint64 + for i := 0; i < length; i++ { + bit := startBit + i + if data[bit/8]>>(bit%8)&1 == 1 { + v |= 1 << i + } + } + return v, true +} + +// extractBigEndian reads Motorola byte order: start bit is the signal's +// most significant bit, and consecutive bits walk downward within a byte +// then continue at the top of the next byte. +func extractBigEndian(data []byte, startBit, length int) (uint64, bool) { + if startBit < 0 || length < 1 || startBit >= len(data)*8 { + return 0, false + } + var v uint64 + bit := startBit + for i := 0; i < length; i++ { + if bit/8 >= len(data) || bit < 0 { + return 0, false + } + v = v<<1 | uint64(data[bit/8]>>(bit%8)&1) + if bit%8 == 0 { + bit += 15 // down to the next byte, back up to its MSB + } else { + bit-- + } + } + return v, true +} diff --git a/p987/dbc/decode_test.go b/p987/dbc/decode_test.go new file mode 100644 index 00000000..ab7cb4cc --- /dev/null +++ b/p987/dbc/decode_test.go @@ -0,0 +1,154 @@ +package dbc + +import ( + "math" + "testing" +) + +func TestExtractLittleEndian(t *testing.T) { + // 13 bits starting at bit 2. Raw 1000 shifted left 2 is 4000 = + // 0x0FA0, which little-endian is byte0=0xA0 byte1=0x0F. + data := []byte{0xA0, 0x0F, 0, 0, 0, 0, 0, 0} + got, ok := extractLittleEndian(data, 2, 13) + if !ok { + t.Fatal("extract reported the signal does not fit") + } + if got != 1000 { + t.Errorf("raw = %d, want 1000", got) + } +} + +func TestExtractLittleEndianSingleBit(t *testing.T) { + data := []byte{0b0000_1000} + if got, _ := extractLittleEndian(data, 3, 1); got != 1 { + t.Errorf("bit 3 = %d, want 1", got) + } + if got, _ := extractLittleEndian(data, 2, 1); got != 0 { + t.Errorf("bit 2 = %d, want 0", got) + } +} + +// Motorola order walks down within a byte then resumes at the top of the +// next, so a 16-bit signal starting at bit 7 reads the bytes big-endian. +func TestExtractBigEndian(t *testing.T) { + got, ok := extractBigEndian([]byte{0x12, 0x34}, 7, 16) + if !ok { + t.Fatal("extract reported the signal does not fit") + } + if got != 0x1234 { + t.Errorf("raw = %#x, want 0x1234", got) + } +} + +func TestExtractRejectsOverrun(t *testing.T) { + if _, ok := extractLittleEndian([]byte{0xFF}, 4, 8); ok { + t.Error("a signal running past the frame should not extract") + } + if _, ok := extractLittleEndian([]byte{0xFF}, 0, 8); !ok { + t.Error("a signal exactly filling the frame should extract") + } +} + +func TestSignExtension(t *testing.T) { + // 6 bits at bit 57 (byte 7, bit 1). All ones is -1 two's complement. + data := []byte{0, 0, 0, 0, 0, 0, 0, 0x7E} + s := Signal{StartBit: 57, Length: 6, LittleEndian: true, Signed: true} + raw, ok := s.extract(data) + if !ok { + t.Fatal("extract failed") + } + if raw != -1 { + t.Errorf("raw = %d, want -1", raw) + } + + unsigned := Signal{StartBit: 57, Length: 6, LittleEndian: true} + if raw, _ := unsigned.extract(data); raw != 63 { + t.Errorf("unsigned raw = %d, want 63", raw) + } +} + +func TestDecodeAppliesScaling(t *testing.T) { + msg := &Message{ + ID: 1, Name: "T", Length: 8, + Signals: []Signal{ + {Name: "angle", StartBit: 2, Length: 13, LittleEndian: true, Factor: 0.175, Unit: "deg"}, + {Name: "temp", StartBit: 57, Length: 6, LittleEndian: true, Signed: true, Factor: 1, Offset: -48, Unit: "C"}, + }, + } + data := []byte{0xA0, 0x0F, 0, 0, 0, 0, 0, 0x7E} + + got := msg.Decode(data) + if len(got) != 2 { + t.Fatalf("decoded %d signals, want 2", len(got)) + } + if math.Abs(got[0].Value-175.0) > 1e-9 { + t.Errorf("angle = %v, want 175", got[0].Value) + } + if got[0].Raw != 1000 { + t.Errorf("angle raw = %d, want 1000", got[0].Raw) + } + // -1 raw with offset -48. + if math.Abs(got[1].Value-(-49)) > 1e-9 { + t.Errorf("temp = %v, want -49", got[1].Value) + } +} + +func TestDecodeSkipsMultiplexed(t *testing.T) { + msg := &Message{ + ID: 1, Length: 8, + Signals: []Signal{ + {Name: "muxed", StartBit: 0, Length: 8, LittleEndian: true, Factor: 1, Multiplexed: true}, + {Name: "plain", StartBit: 8, Length: 8, LittleEndian: true, Factor: 1}, + }, + } + got := msg.Decode([]byte{0xAA, 0xBB, 0, 0, 0, 0, 0, 0}) + if len(got) != 1 { + t.Fatalf("decoded %d signals, want 1 (multiplexed skipped)", len(got)) + } + if got[0].Name != "plain" || got[0].Raw != 0xBB { + t.Errorf("decoded %+v, want plain=0xBB", got[0]) + } +} + +func TestDecodeSkipsSignalsPastEndOfFrame(t *testing.T) { + msg := &Message{ + ID: 1, Length: 8, + Signals: []Signal{ + {Name: "fits", StartBit: 0, Length: 8, LittleEndian: true, Factor: 1}, + {Name: "overruns", StartBit: 8, Length: 16, LittleEndian: true, Factor: 1}, + }, + } + // Two bytes only: the second signal cannot be read. A zero would be + // indistinguishable from a real reading, so it must be absent. + got := msg.Decode([]byte{0x11, 0x22}) + if len(got) != 1 || got[0].Name != "fits" { + t.Errorf("decoded %+v, want only the signal that fits", got) + } +} + +// Decode every message in the real DBC against a frame of its declared +// length. Nothing should panic, and every non-multiplexed signal that fits +// should produce a value. +func TestDecodeEmbeddedCaymanMessages(t *testing.T) { + db, err := Cayman987() + if err != nil { + t.Fatalf("Cayman987: %v", err) + } + for id, msg := range db.Messages { + data := make([]byte, msg.Length) + for i := range data { + data[i] = 0xA5 + } + got := msg.Decode(data) + + want := 0 + for _, s := range msg.Signals { + if !s.Multiplexed { + want++ + } + } + if len(got) != want { + t.Errorf("0x%X (%s) decoded %d signals, want %d", id, msg.Name, len(got), want) + } + } +} diff --git a/p987/go.mod b/p987/go.mod new file mode 100644 index 00000000..faf98fbe --- /dev/null +++ b/p987/go.mod @@ -0,0 +1,63 @@ +module github.com/gaucho-racing/mapache/p987 + +go 1.26 + +require ( + github.com/ClickHouse/clickhouse-go/v2 v2.46.0 + github.com/eclipse/paho.golang v0.23.0 + github.com/fatih/color v1.18.0 + github.com/gaucho-racing/mapache/mapache-go/v3 v3.5.0 + github.com/gaucho-racing/ulid-go v1.1.0 + github.com/gin-contrib/cors v1.7.6 + github.com/gin-gonic/gin v1.12.0 + go.uber.org/zap v1.27.1 +) + +require ( + github.com/ClickHouse/ch-go v0.71.0 // indirect + github.com/andybalholm/brotli v1.2.0 // indirect + github.com/bytedance/gopkg v0.1.3 // indirect + github.com/bytedance/sonic v1.15.0 // indirect + github.com/bytedance/sonic/loader v0.5.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/cloudwego/base64x v0.1.6 // indirect + github.com/gabriel-vasile/mimetype v1.4.12 // indirect + github.com/gin-contrib/sse v1.1.0 // indirect + github.com/go-faster/city v1.0.1 // indirect + github.com/go-faster/errors v0.7.1 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.30.1 // indirect + github.com/goccy/go-json v0.10.5 // indirect + github.com/goccy/go-yaml v1.19.2 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/gorilla/websocket v1.5.3 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/compress v1.18.3 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/paulmach/orb v0.12.0 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/pierrec/lz4/v4 v4.1.25 // indirect + github.com/quic-go/qpack v0.6.0 // indirect + github.com/quic-go/quic-go v0.59.0 // indirect + github.com/segmentio/asm v1.2.1 // indirect + github.com/shopspring/decimal v1.4.0 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.3.1 // indirect + go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect + go.opentelemetry.io/otel v1.41.0 // indirect + go.opentelemetry.io/otel/trace v1.41.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/arch v0.22.0 // indirect + golang.org/x/crypto v0.48.0 // indirect + golang.org/x/net v0.51.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/text v0.34.0 // indirect + google.golang.org/protobuf v1.36.10 // indirect +) diff --git a/p987/go.sum b/p987/go.sum new file mode 100644 index 00000000..1fd307c3 --- /dev/null +++ b/p987/go.sum @@ -0,0 +1,211 @@ +github.com/ClickHouse/ch-go v0.71.0 h1:bUdZ/EZj/LcVHsMqaRUP2holqygrPWQKeMjc6nZoyRM= +github.com/ClickHouse/ch-go v0.71.0/go.mod h1:NwbNc+7jaqfY58dmdDUbG4Jl22vThgx1cYjBw0vtgXw= +github.com/ClickHouse/clickhouse-go/v2 v2.46.0 h1:s3eRy+hYmu5uzotB6ZhDofgHu8kDgGN/fpmjxRkqSpk= +github.com/ClickHouse/clickhouse-go/v2 v2.46.0/go.mod h1:giJfUVlMkcfUEPVfRpt51zZaGEx9i17gCos8gBl392c= +github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= +github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= +github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= +github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= +github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= +github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= +github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/eclipse/paho.golang v0.23.0 h1:KHgl2wz6EJo7cMBmkuhpt7C576vP+kpPv7jjvSyR6Mk= +github.com/eclipse/paho.golang v0.23.0/go.mod h1:nQRhTkoZv8EAiNs5UU0/WdQIx2NrnWUpL9nsGJTQN04= +github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= +github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= +github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= +github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/gaucho-racing/mapache/mapache-go/v3 v3.5.0 h1:sihPvHGJ9CgkoqBySJnjZ1IKoHi9kqh0l1w8nAyNDRE= +github.com/gaucho-racing/mapache/mapache-go/v3 v3.5.0/go.mod h1:2Zb3ztikLtk3UMS0/bg2nhwroSoyFWvQa/tqVGvYFlc= +github.com/gaucho-racing/ulid-go v1.1.0 h1:x00XM8EjlegfhlLYIob+U8ba5iX0gDRUr8mgBsjCunk= +github.com/gaucho-racing/ulid-go v1.1.0/go.mod h1:HwqoC27UtvXHrmhTO7K2GnXZ1VAeR6tg6EjrSEP5JUU= +github.com/gin-contrib/cors v1.7.6 h1:3gQ8GMzs1Ylpf70y8bMw4fVpycXIeX1ZemuSQIsnQQY= +github.com/gin-contrib/cors v1.7.6/go.mod h1:Ulcl+xN4jel9t1Ry8vqph23a60FwH9xVLd+3ykmTjOk= +github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= +github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= +github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= +github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc= +github.com/go-faster/city v1.0.1 h1:4WAxSZ3V2Ws4QRDrscLEDcibJY8uf41H6AhXDrNDcGw= +github.com/go-faster/city v1.0.1/go.mod h1:jKcUJId49qdW3L1qKHH/3wPeUstCVpVSXTM6vO3VcTw= +github.com/go-faster/errors v0.7.1 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AYg= +github.com/go-faster/errors v0.7.1/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w= +github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= +github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk= +github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw= +github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc= +github.com/paulmach/orb v0.12.0 h1:z+zOwjmG3MyEEqzv92UN49Lg1JFYx0L9GpGKNVDKk1s= +github.com/paulmach/orb v0.12.0/go.mod h1:5mULz1xQfs3bmQm63QEJA6lNGujuRafwA5S/EnuLaLU= +github.com/paulmach/protoscan v0.2.1/go.mod h1:SpcSwydNLrxUGSDvXvO0P7g7AuhJ7lcKfDlhJCDw2gY= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pierrec/lz4/v4 v4.1.25 h1:kocOqRffaIbU5djlIBr7Wh+cx82C0vtFb0fOurZHqD0= +github.com/pierrec/lz4/v4 v4.1.25/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= +github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= +github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw= +github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0= +github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= +github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= +github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= +github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= +github.com/xdg-go/scram v1.1.1/go.mod h1:RaEWvsqvNKKvBPvcKeFjrG2cJqOkHTiyTpzz23ni57g= +github.com/xdg-go/stringprep v1.0.3/go.mod h1:W3f5j4i+9rC0kuIEJL0ky1VpHXQU3ocBgklLGvcBnW8= +github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= +github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +go.mongodb.org/mongo-driver v1.11.4/go.mod h1:PTSz5yu21bkT/wXpkS7WR5f0ddqw5quethTUn9WM+2g= +go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE= +go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= +go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c= +go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE= +go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0= +go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= +go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI= +golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= +golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/p987/main.go b/p987/main.go new file mode 100644 index 00000000..f6040f6d --- /dev/null +++ b/p987/main.go @@ -0,0 +1,39 @@ +package main + +import ( + "context" + + "github.com/gaucho-racing/mapache/p987/api" + "github.com/gaucho-racing/mapache/p987/config" + "github.com/gaucho-racing/mapache/p987/database" + "github.com/gaucho-racing/mapache/p987/mqtt" + "github.com/gaucho-racing/mapache/p987/pkg/kerbecs" + "github.com/gaucho-racing/mapache/p987/pkg/logger" + "github.com/gaucho-racing/mapache/p987/service" +) + +func main() { + logger.Init(config.IsProduction()) + defer logger.Logger.Sync() + + config.Verify() + config.PrintStartupBanner() + kerbecs.Init(config.KerbecsEndpoint, config.KerbecsUser, config.KerbecsPassword) + if config.ClickhouseEnabled() { + database.Init() + } + + // Parse the DBC before subscribing so the first frame doesn't race the + // load, and so a malformed file fails at startup rather than silently + // decoding nothing. + if err := service.InitDecoder(); err != nil { + logger.SugarLogger.Fatalf("Failed to load DBC: %v", err) + } + + mqtt.SetMessageHandler(service.HandleInboundMessage) + if err := mqtt.Init(context.Background()); err != nil { + logger.SugarLogger.Fatalf("Failed to initialize MQTT: %v", err) + } + + api.Run() +} diff --git a/p987/model/can.go b/p987/model/can.go new file mode 100644 index 00000000..4371aa0d --- /dev/null +++ b/p987/model/can.go @@ -0,0 +1,30 @@ +package model + +import "time" + +// CAN is a stored record of a decoded CAN frame. +// +// NodeID carries the bus label (pcan, kcan, tcm) rather than a sender node +// id. On stock Porsche CAN the sending ECU is a function of the +// arbitration id, so the only routing fact the id cannot carry is which +// physical bus the frame arrived on — and two buses have independent +// 11-bit id spaces, so the bus is part of the natural key. +// +// Natural key: (vehicle_id, node_id, timestamp). +// Metadata carries {"status": ok|unknown_can_id|decode_error|..., "note": ...}. +type CAN 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 []byte `json:"bytes"` + UploadKey int `json:"upload_key"` + Metadata []byte `json:"metadata,omitempty"` + ProducedAt time.Time `json:"produced_at"` + CreatedAt time.Time `json:"created_at"` +} + +func (CAN) TableName() string { + return "p987_can" +} diff --git a/p987/model/tcm.go b/p987/model/tcm.go new file mode 100644 index 00000000..bbc2cf91 --- /dev/null +++ b/p987/model/tcm.go @@ -0,0 +1,115 @@ +package model + +import "encoding/binary" + +// The TCM publishes two synthetic frames under the "tcm" bus label. They +// never touched a physical CAN bus, so they are not in the DBC and are +// decoded here instead. +const ( + MsgIDTCMStatus = 0x200 + MsgIDTCMResources = 0x201 +) + +// Decoded is the decoder output, matching dbc.Decoded so the dispatch path +// treats DBC frames and TCM frames identically. +type Decoded struct { + Name string + Value float64 + Raw int64 + Unit string +} + +// TCM Status is 8 bytes published every 5s: +// +// [0] status_bits +// [1:3] mapache_ping u16 LE, ms +// [3:8] reserved +// +// Each status bit becomes its own boolean signal so consumers can ask "is +// X reachable?" without bit-twiddling. +func DecodeTCMStatus(data []byte) ([]Decoded, bool) { + if len(data) < 3 { + return nil, false + } + bits := data[0] + out := []Decoded{ + bit(bits, 0, "connection_ok"), + bit(bits, 1, "mqtt_ok"), + bit(bits, 2, "mapache_ok"), + bit(bits, 3, "clock_ok"), + } + ping := binary.LittleEndian.Uint16(data[1:3]) + return append(out, Decoded{Name: "mapache_ping", Value: float64(ping), Raw: int64(ping), Unit: "ms"}), true +} + +func bit(v byte, n uint, name string) Decoded { + raw := int64(v >> n & 1) + return Decoded{Name: name, Value: float64(raw), Raw: raw} +} + +// resourcesPayloadSize is the TCM-987 0x201 layout. It is deliberately not +// TCM-26's 44-byte Jetson layout: a Pi Zero 2 W is quad-core with no +// discrete GPU counters and no power-rail sensors, so those fields would +// be permanently zero. The freed space carries throttle flags instead, +// which is the failure mode this board actually has — under-voltage +// corrupts SD cards and shows up in no other metric. +// +// [0:12] 4 × (freq u16 LE MHz, util u8 %) +// [12] cpu_total_util u8 % +// [13:15] ram_total u16 LE MB +// [15:17] ram_used u16 LE MB +// [17] ram_util u8 % +// [18:22] disk_total u32 LE MB +// [22:26] disk_used u32 LE MB +// [26] disk_util u8 % +// [27] cpu_temp u8 °C +// [28] throttle_flags u8 +const resourcesPayloadSize = 29 + +// ReportedCPUs must match the relay's model.ReportedCPUs. +const ReportedCPUs = 4 + +var cpuNames = [ReportedCPUs]struct{ freq, util string }{ + {"cpu_0_freq", "cpu_0_util"}, + {"cpu_1_freq", "cpu_1_util"}, + {"cpu_2_freq", "cpu_2_util"}, + {"cpu_3_freq", "cpu_3_util"}, +} + +func DecodeTCMResources(data []byte) ([]Decoded, bool) { + if len(data) < resourcesPayloadSize { + return nil, false + } + + out := make([]Decoded, 0, 20) + for i := 0; i < ReportedCPUs; i++ { + off := i * 3 + freq := binary.LittleEndian.Uint16(data[off : off+2]) + out = append(out, + Decoded{Name: cpuNames[i].freq, Value: float64(freq), Raw: int64(freq), Unit: "MHz"}, + Decoded{Name: cpuNames[i].util, Value: float64(data[off+2]), Raw: int64(data[off+2]), Unit: "%"}, + ) + } + + ramTotal := binary.LittleEndian.Uint16(data[13:15]) + ramUsed := binary.LittleEndian.Uint16(data[15:17]) + diskTotal := binary.LittleEndian.Uint32(data[18:22]) + diskUsed := binary.LittleEndian.Uint32(data[22:26]) + throttle := data[28] + + out = append(out, + Decoded{Name: "cpu_total_util", Value: float64(data[12]), Raw: int64(data[12]), Unit: "%"}, + Decoded{Name: "ram_total", Value: float64(ramTotal), Raw: int64(ramTotal), Unit: "MB"}, + Decoded{Name: "ram_used", Value: float64(ramUsed), Raw: int64(ramUsed), Unit: "MB"}, + Decoded{Name: "ram_util", Value: float64(data[17]), Raw: int64(data[17]), Unit: "%"}, + Decoded{Name: "disk_total", Value: float64(diskTotal), Raw: int64(diskTotal), Unit: "MB"}, + Decoded{Name: "disk_used", Value: float64(diskUsed), Raw: int64(diskUsed), Unit: "MB"}, + Decoded{Name: "disk_util", Value: float64(data[26]), Raw: int64(data[26]), Unit: "%"}, + Decoded{Name: "cpu_temp", Value: float64(data[27]), Raw: int64(data[27]), Unit: "C"}, + bit(throttle, 0, "undervoltage"), + bit(throttle, 1, "undervoltage_since_boot"), + bit(throttle, 2, "thermal_throttled"), + bit(throttle, 3, "thermal_throttled_since_boot"), + ) + return out, true +} diff --git a/p987/model/tcm_test.go b/p987/model/tcm_test.go new file mode 100644 index 00000000..bed9d8f6 --- /dev/null +++ b/p987/model/tcm_test.go @@ -0,0 +1,130 @@ +package model + +import ( + "encoding/binary" + "testing" +) + +func find(t *testing.T, decoded []Decoded, name string) Decoded { + t.Helper() + for _, d := range decoded { + if d.Name == name { + return d + } + } + t.Fatalf("signal %q not decoded", name) + return Decoded{} +} + +func TestDecodeTCMStatus(t *testing.T) { + data := make([]byte, 8) + data[0] = 0b1011 // connection, mqtt, clock ok; mapache down + binary.LittleEndian.PutUint16(data[1:3], 1234) + + got, ok := DecodeTCMStatus(data) + if !ok { + t.Fatal("decode failed") + } + for name, want := range map[string]float64{ + "connection_ok": 1, + "mqtt_ok": 1, + "mapache_ok": 0, + "clock_ok": 1, + "mapache_ping": 1234, + } { + if v := find(t, got, name).Value; v != want { + t.Errorf("%s = %v, want %v", name, v, want) + } + } +} + +func TestDecodeTCMStatusRejectsShortFrame(t *testing.T) { + if _, ok := DecodeTCMStatus([]byte{0x01, 0x02}); ok { + t.Error("a 2-byte status frame should not decode") + } +} + +// buildResources mirrors the relay's encodeResourcePayload. If the two +// layouts ever drift this test is what catches it. +func buildResources() []byte { + data := make([]byte, resourcesPayloadSize) + off := 0 + for i, v := range []struct{ freq, util int }{{1000, 10}, {1001, 20}, {1002, 30}, {1003, 40}} { + binary.LittleEndian.PutUint16(data[off:off+2], uint16(v.freq)) + data[off+2] = byte(v.util) + off += 3 + _ = i + } + data[12] = 25 + binary.LittleEndian.PutUint16(data[13:15], 512) + binary.LittleEndian.PutUint16(data[15:17], 128) + data[17] = 25 + binary.LittleEndian.PutUint32(data[18:22], 30000) + binary.LittleEndian.PutUint32(data[22:26], 12000) + data[26] = 40 + data[27] = 55 + data[28] = ThrottleFlagsForTest + return data +} + +// bit 1 (under-voltage since boot) and bit 2 (thermal throttled now). +const ThrottleFlagsForTest = 0b0110 + +func TestDecodeTCMResources(t *testing.T) { + got, ok := DecodeTCMResources(buildResources()) + if !ok { + t.Fatal("decode failed") + } + + for name, want := range map[string]float64{ + "cpu_0_freq": 1000, + "cpu_0_util": 10, + "cpu_3_freq": 1003, + "cpu_3_util": 40, + "cpu_total_util": 25, + "ram_total": 512, + "ram_used": 128, + "ram_util": 25, + "disk_total": 30000, + "disk_used": 12000, + "disk_util": 40, + "cpu_temp": 55, + "undervoltage": 0, + "undervoltage_since_boot": 1, + "thermal_throttled": 1, + "thermal_throttled_since_boot": 0, + } { + if v := find(t, got, name).Value; v != want { + t.Errorf("%s = %v, want %v", name, v, want) + } + } +} + +// The Jetson layout was 44 bytes with GPU and power fields. A relay still +// sending that would decode into garbage, so the length check must reject +// anything shorter than the current layout and the decoder must not read +// past it. +func TestDecodeTCMResourcesRejectsOldLayout(t *testing.T) { + if _, ok := DecodeTCMResources(make([]byte, 28)); ok { + t.Error("a 28-byte frame should not decode") + } + if _, ok := DecodeTCMResources(make([]byte, resourcesPayloadSize)); !ok { + t.Error("a 29-byte frame should decode") + } +} + +func TestDecodeTCMResourcesReportsEveryCPU(t *testing.T) { + got, _ := DecodeTCMResources(buildResources()) + for i := 0; i < ReportedCPUs; i++ { + find(t, got, cpuNames[i].freq) + find(t, got, cpuNames[i].util) + } + // The Pi has no GPU counters, so these must not appear at all. + for _, gone := range []string{"gpu_util", "gpu_freq", "gpu_temp", "voltage_draw", "power_draw"} { + for _, d := range got { + if d.Name == gone { + t.Errorf("%s should not be decoded on the Pi layout", gone) + } + } + } +} diff --git a/p987/mqtt/mqtt.go b/p987/mqtt/mqtt.go new file mode 100644 index 00000000..7358a0a8 --- /dev/null +++ b/p987/mqtt/mqtt.go @@ -0,0 +1,127 @@ +package mqtt + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "os" + "sync" + "time" + + "github.com/gaucho-racing/mapache/p987/config" + "github.com/gaucho-racing/mapache/p987/pkg/logger" + + "github.com/eclipse/paho.golang/autopaho" + "github.com/eclipse/paho.golang/paho" +) + +const ( + sharedSubGroup = "p987-cluster" + canTopicFilter = "$share/" + sharedSubGroup + "/" + config.TopicRoot + "/#" +) + +const ( + keepAliveSeconds = 30 + connectTimeout = 15 * time.Second +) + +var Manager *autopaho.ConnectionManager + +type MessageHandler func(topic string, payload []byte) + +var ( + handlerMu sync.RWMutex + handler MessageHandler +) + +func SetMessageHandler(h MessageHandler) { + handlerMu.Lock() + handler = h + handlerMu.Unlock() +} + +func Init(ctx context.Context) error { + serverURL, err := url.Parse(fmt.Sprintf("mqtt://%s:%s", config.MQTTHost, config.MQTTPort)) + if err != nil { + return fmt.Errorf("invalid MQTT URL: %w", err) + } + + host, err := os.Hostname() + if err != nil || host == "" { + host = fmt.Sprintf("pid%d", os.Getpid()) + } + clientID := fmt.Sprintf("%s-%s", config.Service.Name, host) + + cfg := autopaho.ClientConfig{ + ServerUrls: []*url.URL{serverURL}, + KeepAlive: keepAliveSeconds, + ConnectTimeout: connectTimeout, + ConnectUsername: config.MQTTUser, + ConnectPassword: []byte(config.MQTTPassword), + CleanStartOnInitialConnection: true, + SessionExpiryInterval: 0, + OnConnectionUp: func(cm *autopaho.ConnectionManager, _ *paho.Connack) { + logger.SugarLogger.Infoln("[MQ] Connected to MQTT broker") + if _, err := cm.Subscribe(context.Background(), &paho.Subscribe{ + Subscriptions: []paho.SubscribeOptions{ + {Topic: canTopicFilter, QoS: 0}, + }, + }); err != nil { + logger.SugarLogger.Warnf("[MQ] Subscribe to %s failed: %v", canTopicFilter, err) + } + }, + OnConnectError: func(err error) { + logger.SugarLogger.Warnf("[MQ] Connection error: %v", err) + }, + ClientConfig: paho.ClientConfig{ + ClientID: clientID, + OnPublishReceived: []func(paho.PublishReceived) (bool, error){ + func(pr paho.PublishReceived) (bool, error) { + handlerMu.RLock() + h := handler + handlerMu.RUnlock() + if h != nil { + h(pr.Packet.Topic, pr.Packet.Payload) + } + return true, nil + }, + }, + OnClientError: func(err error) { + logger.SugarLogger.Warnf("[MQ] Client error: %v", err) + }, + OnServerDisconnect: func(d *paho.Disconnect) { + logger.SugarLogger.Warnf("[MQ] Server disconnect: reason=%d", d.ReasonCode) + }, + }, + } + + cm, err := autopaho.NewConnection(ctx, cfg) + if err != nil { + return fmt.Errorf("autopaho.NewConnection: %w", err) + } + Manager = cm + return nil +} + +func Publish(ctx context.Context, topic string, payload []byte) { + if Manager == nil { + return + } + if _, err := Manager.Publish(ctx, &paho.Publish{ + QoS: 0, + Topic: topic, + Payload: payload, + }); err != nil { + logger.SugarLogger.Warnf("[MQ] Publish to %s failed: %v", topic, err) + } +} + +func PublishJSON(ctx context.Context, topic string, v any) { + payload, err := json.Marshal(v) + if err != nil { + logger.SugarLogger.Warnf("[MQ] JSON marshal for %s failed: %v", topic, err) + return + } + Publish(ctx, topic, payload) +} diff --git a/p987/pkg/kerbecs/kerbecs.go b/p987/pkg/kerbecs/kerbecs.go new file mode 100644 index 00000000..b7c6bd22 --- /dev/null +++ b/p987/pkg/kerbecs/kerbecs.go @@ -0,0 +1,114 @@ +// Package kerbecs resolves gateway-form paths (e.g. /vehicles/{id}) to the +// concrete upstream URL by asking the kerbecs gateway's admin /admin-gw/resolve +// endpoint. Mirrors the pattern from Sentinel — kerbecs is the routing source +// of truth, so we ask it where a request should go and cache the answer. +package kerbecs + +import ( + "encoding/json" + "fmt" + "net/http" + "net/url" + "strings" + "sync" + "time" +) + +const cacheTTL = 5 * time.Minute + +var ( + endpoint string + user string + password string + client = &http.Client{Timeout: 5 * time.Second} +) + +type entry struct { + url string + exp time.Time +} + +var ( + mu sync.RWMutex + cache = map[string]entry{} +) + +// Init configures the resolver against the kerbecs admin API. No connection is +// made here — lookups happen lazily on first Resolve. +func Init(adminEndpoint, adminUser, adminPassword string) { + endpoint = strings.TrimRight(adminEndpoint, "/") + user = adminUser + password = adminPassword + go sweep() +} + +type resolveResponse struct { + Matched bool `json:"matched"` + URL string `json:"url"` + RewrittenPath string `json:"rewritten_path"` +} + +// Resolve maps a gateway-form path and HTTP method to the full upstream URL. +// Answers are cached for cacheTTL. +func Resolve(method, path string) (string, error) { + if endpoint == "" { + return "", fmt.Errorf("kerbecs resolver not initialized") + } + key := method + " " + path + + mu.RLock() + if e, ok := cache[key]; ok && time.Now().Before(e.exp) { + mu.RUnlock() + return e.url, nil + } + mu.RUnlock() + + q := url.Values{} + q.Set("path", path) + q.Set("method", method) + req, err := http.NewRequest(http.MethodGet, endpoint+"/admin-gw/resolve?"+q.Encode(), nil) + if err != nil { + return "", fmt.Errorf("resolve %s: %w", path, err) + } + req.SetBasicAuth(user, password) + + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("resolve %s: %w", path, err) + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + return "", fmt.Errorf("no upstream registered for %s", path) + } + if resp.StatusCode >= 400 { + return "", fmt.Errorf("resolve %s: kerbecs returned %d", path, resp.StatusCode) + } + + var rr resolveResponse + if err := json.NewDecoder(resp.Body).Decode(&rr); err != nil { + return "", fmt.Errorf("resolve %s: decode: %w", path, err) + } + if !rr.Matched { + return "", fmt.Errorf("no upstream registered for %s", path) + } + + full := strings.TrimRight(rr.URL, "/") + rr.RewrittenPath + mu.Lock() + cache[key] = entry{url: full, exp: time.Now().Add(cacheTTL)} + mu.Unlock() + return full, nil +} + +func sweep() { + for range time.Tick(cacheTTL) { + now := time.Now() + mu.Lock() + for k, e := range cache { + if now.After(e.exp) { + delete(cache, k) + } + } + mu.Unlock() + } +} diff --git a/p987/pkg/logger/logger.go b/p987/pkg/logger/logger.go new file mode 100644 index 00000000..2722d06c --- /dev/null +++ b/p987/pkg/logger/logger.go @@ -0,0 +1,16 @@ +package logger + +import ( + "go.uber.org/zap" +) + +var Logger *zap.Logger +var SugarLogger *zap.SugaredLogger + +func Init(production bool) { + Logger = zap.Must(zap.NewProduction()) + if !production { + Logger = zap.Must(zap.NewDevelopment(zap.AddCaller(), zap.AddStacktrace(zap.ErrorLevel))) + } + SugarLogger = Logger.Sugar() +} diff --git a/p987/service/can.go b/p987/service/can.go new file mode 100644 index 00000000..a67c55b9 --- /dev/null +++ b/p987/service/can.go @@ -0,0 +1,161 @@ +package service + +import ( + "context" + "database/sql" + "errors" + "time" + + "github.com/gaucho-racing/mapache/p987/config" + "github.com/gaucho-racing/mapache/p987/database" + "github.com/gaucho-racing/mapache/p987/model" + + mapache "github.com/gaucho-racing/mapache/mapache-go/v3" + + "github.com/ClickHouse/clickhouse-go/v2/lib/driver" + ulid "github.com/gaucho-racing/ulid-go" +) + +// ErrNotFound is mapped to 404 by the API layer. +var ErrNotFound = errors.New("record not found") + +// MATERIALIZED columns are excluded from SELECT *, so we list explicitly. +const canColumns = `id, vehicle_id, node_id, timestamp, can_id, bytes, upload_key, metadata, produced_at, created_at` +const canColumnsC = `c.id, c.vehicle_id, c.node_id, c.timestamp, c.can_id, c.bytes, c.upload_key, c.metadata, c.produced_at, c.created_at` + +// GetCAN looks up a stored CAN frame by ulid. +func GetCAN(id string) (model.CAN, error) { + row := database.Conn.QueryRow(context.Background(), + "SELECT "+canColumns+" FROM p987_can FINAL WHERE id = ? LIMIT 1", id) + return scanCANRow(row) +} + +// GetCANForSignal joins back to the source CAN frame using the signal +// name's `_` prefix to recover node_id. +func GetCANForSignal(signalID string) (model.CAN, error) { + row := database.Conn.QueryRow(context.Background(), ` + SELECT `+canColumnsC+` + FROM p987_can AS c FINAL + INNER JOIN ( + SELECT vehicle_id, timestamp, splitByChar('_', name)[1] AS node_id + FROM signal FINAL WHERE id = ? + ) AS s + ON c.vehicle_id = s.vehicle_id AND c.timestamp = s.timestamp AND c.node_id = s.node_id + LIMIT 1`, signalID) + return scanCANRow(row) +} + +// GetSignalsForCAN returns every signal decoded from the given frame, +// ordered by name for a stable response. +func GetSignalsForCAN(canMessageID string) ([]mapache.Signal, error) { + rows, err := database.Conn.Query(context.Background(), ` + SELECT s.id, s.timestamp, s.vehicle_id, s.name, s.value, s.raw_value, s.produced_at, s.created_at + FROM signal AS s FINAL + INNER JOIN ( + SELECT vehicle_id, timestamp, node_id FROM p987_can FINAL WHERE id = ? + ) AS c + ON s.vehicle_id = c.vehicle_id AND s.timestamp = c.timestamp AND splitByChar('_', s.name)[1] = c.node_id + ORDER BY s.name ASC`, canMessageID) + if err != nil { + return nil, err + } + defer rows.Close() + + var signals []mapache.Signal + for rows.Next() { + var ( + id, vehicleID, name string + ts, rawValue int64 + value float64 + producedAt, createdAt time.Time + ) + if err := rows.Scan(&id, &ts, &vehicleID, &name, &value, &rawValue, &producedAt, &createdAt); err != nil { + return nil, err + } + signals = append(signals, mapache.Signal{ + ID: id, + Timestamp: int(ts), + VehicleID: vehicleID, + Name: name, + Value: value, + RawValue: int(rawValue), + ProducedAt: producedAt, + CreatedAt: createdAt, + }) + } + return signals, rows.Err() +} + +// Dedup on (vehicle_id, node_id, timestamp) is handled by the +// ReplacingMergeTree engine — latest created_at wins on merge. +const insertCANSQL = `INSERT INTO p987_can (id, vehicle_id, node_id, timestamp, can_id, bytes, upload_key, metadata) VALUES (?, ?, ?, ?, ?, ?, ?, ?)` +const insertCANBatchSQL = `INSERT INTO p987_can (id, vehicle_id, node_id, timestamp, can_id, bytes, upload_key, metadata)` + +func CreateCAN(can model.CAN) (model.CAN, error) { + can.ID = ulid.Make().Prefixed("can") + if !config.ClickhouseEnabled() { + return can, nil + } + ctx := database.InsertCtx(context.Background()) + if err := database.Conn.Exec(ctx, insertCANSQL, + can.ID, can.VehicleID, can.NodeID, int64(can.Timestamp), int32(can.CANID), + string(can.Bytes), int32(can.UploadKey), string(can.Metadata), + ); err != nil { + return model.CAN{}, err + } + return can, nil +} + +// CreateCANs is the bulk-ingest counterpart of CreateCAN: one buffered block +// insert per call — a single round trip regardless of frame count. async_insert +// (InsertCtx) doesn't apply to native block inserts, so plain ctx here. +func CreateCANs(cans []model.CAN) error { + for i := range cans { + cans[i].ID = ulid.Make().Prefixed("can") + } + if !config.ClickhouseEnabled() || len(cans) == 0 { + return nil + } + batch, err := database.Conn.PrepareBatch(context.Background(), insertCANBatchSQL) + if err != nil { + return err + } + defer batch.Close() + for _, c := range cans { + if err := batch.Append( + c.ID, c.VehicleID, c.NodeID, int64(c.Timestamp), int32(c.CANID), + string(c.Bytes), int32(c.UploadKey), string(c.Metadata), + ); err != nil { + return err + } + } + return batch.Send() +} + +func scanCANRow(row driver.Row) (model.CAN, error) { + var ( + id, vehicleID, nodeID string + bytesStr, metaStr string + ts int64 + canID, uploadKey int32 + producedAt, createdAt time.Time + ) + if err := row.Scan(&id, &vehicleID, &nodeID, &ts, &canID, &bytesStr, &uploadKey, &metaStr, &producedAt, &createdAt); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return model.CAN{}, ErrNotFound + } + return model.CAN{}, err + } + return model.CAN{ + ID: id, + VehicleID: vehicleID, + NodeID: nodeID, + Timestamp: int(ts), + CANID: int(canID), + Bytes: []byte(bytesStr), + UploadKey: int(uploadKey), + Metadata: []byte(metaStr), + ProducedAt: producedAt, + CreatedAt: createdAt, + }, nil +} diff --git a/p987/service/message.go b/p987/service/message.go new file mode 100644 index 00000000..1f90d5c5 --- /dev/null +++ b/p987/service/message.go @@ -0,0 +1,238 @@ +package service + +import ( + "context" + "encoding/binary" + "encoding/json" + "fmt" + "strconv" + "strings" + "time" + + "github.com/gaucho-racing/mapache/p987/dbc" + "github.com/gaucho-racing/mapache/p987/model" + "github.com/gaucho-racing/mapache/p987/mqtt" + "github.com/gaucho-racing/mapache/p987/pkg/logger" + + mapache "github.com/gaucho-racing/mapache/mapache-go/v3" +) + +// headerSize is the relay's wire format: u64 BE microsecond timestamp +// followed by a u16 BE upload key, then the raw CAN payload. +const headerSize = 10 + +// minValidProducedAt is the cutoff for sane CAN-frame timestamps. A Pi +// with no RTC and no network boots to 1970, and the relay publishes +// whatever its clock says — anything stamped before this is pre-clock +// garbage. Kept in lockstep with the relay's own check. +var minValidProducedAt = time.Date(2003, 10, 31, 0, 0, 0, 0, time.UTC) + +// IsValidProducedAt reports whether the given microseconds-since-epoch +// resolves to a time at or after minValidProducedAt. +func IsValidProducedAt(tsMicros int) bool { + return !time.UnixMicro(int64(tsMicros)).Before(minValidProducedAt) +} + +var decoder *dbc.Database + +// InitDecoder parses the embedded DBC. Called before MQTT so the first +// frame doesn't race the load. +func InitDecoder() error { + db, err := dbc.Cayman987() + if err != nil { + return err + } + decoder = db + logger.SugarLogger.Infof("[DBC] Loaded %d messages, %d signals (%d multiplexed, skipped)", + len(db.Messages), db.SignalCount(), db.MultiplexedCount()) + return nil +} + +// HandleInboundMessage routes one MQTT message. +// +// Topic shape is p987/{vehicle_id}/{bus}/{can_id}, exactly four segments. +// The bus segment takes the place of gr26's node segment: on stock Porsche +// CAN the sender is implied by the arbitration id, so the bus is the only +// routing fact the id cannot carry. +func HandleInboundMessage(topic string, payload []byte) { + parts := strings.Split(topic, "/") + if len(parts) != 4 { + logger.SugarLogger.Infof("[MQ] Received invalid topic: %s, ignoring", topic) + return + } + vehicleID, bus, canID := parts[1], parts[2], parts[3] + + if vehicleID == "" { + logger.SugarLogger.Infof("[MQ] Received invalid vehicle id: %s, ignoring", topic) + return + } + if bus == "" { + logger.SugarLogger.Infof("[MQ] Received invalid bus: %s, ignoring", topic) + return + } + + switch canID { + case "ping": + go HandlePing(vehicleID, bus, payload) + return + case "pong": + // Our own reply echoed back by the shared subscription. + return + } + + canIDInt, err := strconv.ParseInt(strings.TrimPrefix(canID, "0x"), 16, 64) + if err != nil { + logger.SugarLogger.Infof("[MQ] Received invalid can id: %s, ignoring", canID) + return + } + go HandleMessage(vehicleID, bus, int(canIDInt), payload) +} + +// ProcessFrame is pure data transformation: bytes in, one CAN row and its +// signals out. UploadKey is left at 0 for the caller. Unknown ids, decode +// failures, and invalid timestamps yield no signals but still return a row +// with a status blob in Metadata, so the raw frame is never lost. +func ProcessFrame(vehicleID, bus string, canID, timestamp int, data []byte) (model.CAN, []mapache.Signal) { + producedAt := time.UnixMicro(int64(timestamp)) + + var ( + decoded []model.Decoded + meta []byte + ) + + switch { + case !IsValidProducedAt(timestamp): + logger.SugarLogger.Warnf("Frame with invalid timestamp: vehicle=%s bus=%s can_id=0x%X ts=%d decoded=%s", + vehicleID, bus, canID, timestamp, producedAt.UTC().Format(time.RFC3339Nano)) + meta = MustJSON(map[string]any{ + "status": "invalid_timestamp", + "note": fmt.Sprintf("ts=%d (%s) is before %s", timestamp, + producedAt.UTC().Format(time.RFC3339Nano), minValidProducedAt.UTC().Format(time.RFC3339Nano)), + }) + default: + decoded, meta = decodeFrame(bus, canID, data) + } + + can := model.CAN{ + VehicleID: vehicleID, + NodeID: bus, + Timestamp: timestamp, + CANID: canID, + Bytes: data, + Metadata: meta, + ProducedAt: producedAt, + } + + now := time.Now().Truncate(time.Microsecond) + signals := make([]mapache.Signal, 0, len(decoded)) + for _, d := range decoded { + signals = append(signals, mapache.Signal{ + // Prefixed with the bus for the same reason gr26 prefixes with + // the node: it keeps names unique across buses and lets the + // signal-to-frame join recover the segment from the name. + Name: fmt.Sprintf("%s_%s", bus, d.Name), + Value: d.Value, + RawValue: int(d.Raw), + Timestamp: timestamp, + VehicleID: vehicleID, + ProducedAt: producedAt, + CreatedAt: now, + }) + } + return can, signals +} + +// decodeFrame picks a decoder: TCM housekeeping frames are synthetic and +// described here, everything else comes from the DBC. +func decodeFrame(bus string, canID int, data []byte) ([]model.Decoded, []byte) { + if bus == busTCM { + switch canID { + case model.MsgIDTCMStatus: + if d, ok := model.DecodeTCMStatus(data); ok { + return d, MustJSON(map[string]any{"status": "ok"}) + } + return nil, MustJSON(map[string]any{ + "status": "decode_error", + "note": fmt.Sprintf("tcm status frame is %d bytes, want at least 3", len(data)), + }) + case model.MsgIDTCMResources: + if d, ok := model.DecodeTCMResources(data); ok { + return d, MustJSON(map[string]any{"status": "ok"}) + } + return nil, MustJSON(map[string]any{ + "status": "decode_error", + "note": fmt.Sprintf("tcm resources frame is %d bytes, want 29", len(data)), + }) + } + } + + if decoder == nil { + return nil, MustJSON(map[string]any{"status": "decoder_unavailable"}) + } + + msg, ok := decoder.Messages[uint32(canID)] + if !ok { + return nil, MustJSON(map[string]any{ + "status": "unknown_can_id", + "note": fmt.Sprintf("no dbc entry for can id 0x%X", canID), + }) + } + if len(data) < msg.Length { + return nil, MustJSON(map[string]any{ + "status": "short_frame", + "note": fmt.Sprintf("%s expects %d bytes, got %d", msg.Name, msg.Length, len(data)), + }) + } + + out := msg.Decode(data) + decoded := make([]model.Decoded, 0, len(out)) + for _, d := range out { + decoded = append(decoded, model.Decoded{Name: d.Name, Value: d.Value, Raw: d.Raw, Unit: d.Unit}) + } + return decoded, MustJSON(map[string]any{"status": "ok", "message": msg.Name}) +} + +// busTCM is the bus label the relay uses for frames that never touched a +// physical CAN bus. +const busTCM = "tcm" + +func HandleMessage(vehicleID string, bus string, canID int, message []byte) { + if len(message) < headerSize { + logger.SugarLogger.Infof("[MQ] Message too short, ignoring %d bytes", len(message)) + return + } + uploadKey := int(binary.BigEndian.Uint16(message[8:10])) + if !ValidateUploadKey(vehicleID, uploadKey) { + logger.SugarLogger.Infof("Upload key validation failed for vehicle %s, ignoring", vehicleID) + return + } + + ts := int(binary.BigEndian.Uint64(message[:8])) + can, signals := ProcessFrame(vehicleID, bus, canID, ts, message[headerSize:]) + can.UploadKey = uploadKey + + // Persist steps log-and-continue so one failure doesn't drop the rest. + if _, err := CreateCAN(can); err != nil { + logger.SugarLogger.Infof("Error creating CAN record: %s", err) + } + + if len(signals) == 0 { + return + } + if err := CreateSignals(signals); err != nil { + logger.SugarLogger.Infof("Error creating signals: %s", err) + } + for _, s := range signals { + mqtt.PublishJSON(context.Background(), fmt.Sprintf("query/live/%s/%s", s.VehicleID, s.Name), s) + } +} + +// MustJSON marshals v, falling back to a sentinel blob on error so callers +// always get valid json for the metadata column. +func MustJSON(v any) []byte { + b, err := json.Marshal(v) + if err != nil { + return []byte(`{"status":"marshal_error"}`) + } + return b +} diff --git a/p987/service/message_test.go b/p987/service/message_test.go new file mode 100644 index 00000000..f4ee1bc7 --- /dev/null +++ b/p987/service/message_test.go @@ -0,0 +1,165 @@ +package service + +import ( + "encoding/binary" + "encoding/json" + "testing" + "time" + + "github.com/gaucho-racing/mapache/p987/model" + "github.com/gaucho-racing/mapache/p987/pkg/logger" +) + +func init() { + // The service logs through the package logger, which main wires up + // before anything else runs. + logger.Init(false) + if err := InitDecoder(); err != nil { + panic(err) + } +} + +// validTS is a timestamp comfortably past the pre-clock cutoff. +var validTS = int(time.Date(2026, 8, 1, 0, 0, 0, 0, time.UTC).UnixMicro()) + +func metaStatus(t *testing.T, raw []byte) string { + t.Helper() + var m map[string]any + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatalf("metadata is not valid json: %v", err) + } + status, _ := m["status"].(string) + return status +} + +func TestIsValidProducedAt(t *testing.T) { + if IsValidProducedAt(0) { + t.Error("epoch 0 should be rejected — a Pi with no RTC boots to 1970") + } + if !IsValidProducedAt(validTS) { + t.Error("a 2026 timestamp should be accepted") + } +} + +func TestProcessFrameDecodesDBCMessage(t *testing.T) { + // 0xC2 SCCM1: SCCM_SteeringAngle is 13 bits at bit 2, scale 0.175. + data := []byte{0xA0, 0x0F, 0, 0, 0, 0, 0, 0} + can, signals := ProcessFrame("cayman", "pcan", 0xC2, validTS, data) + + if got := metaStatus(t, can.Metadata); got != "ok" { + t.Fatalf("metadata status = %q, want ok", got) + } + if can.NodeID != "pcan" { + t.Errorf("node id = %q, want the bus label", can.NodeID) + } + if len(signals) == 0 { + t.Fatal("expected decoded signals") + } + + var found bool + for _, s := range signals { + if s.Name == "pcan_SCCM_SteeringAngle" { + found = true + if s.Value != 175.0 { + t.Errorf("steering angle = %v, want 175", s.Value) + } + if s.RawValue != 1000 { + t.Errorf("steering raw = %d, want 1000", s.RawValue) + } + } + if s.VehicleID != "cayman" || s.Timestamp != validTS { + t.Errorf("signal %s not stamped: vehicle=%q ts=%d", s.Name, s.VehicleID, s.Timestamp) + } + } + if !found { + t.Error("pcan_SCCM_SteeringAngle missing — signals should be bus-prefixed") + } +} + +func TestProcessFrameKeepsRawFrameOnUnknownID(t *testing.T) { + can, signals := ProcessFrame("cayman", "pcan", 0x7FF, validTS, []byte{1, 2, 3}) + if len(signals) != 0 { + t.Errorf("unknown id should decode no signals, got %d", len(signals)) + } + if got := metaStatus(t, can.Metadata); got != "unknown_can_id" { + t.Errorf("status = %q, want unknown_can_id", got) + } + // The frame itself must still be stored — that's how an unknown id + // gets reverse-engineered later. + if len(can.Bytes) != 3 { + t.Errorf("raw bytes = %v, want them preserved", can.Bytes) + } +} + +func TestProcessFrameRejectsPreClockTimestamp(t *testing.T) { + can, signals := ProcessFrame("cayman", "pcan", 0xC2, 0, []byte{0xA0, 0x0F, 0, 0, 0, 0, 0, 0}) + if len(signals) != 0 { + t.Error("a pre-clock frame should decode no signals") + } + if got := metaStatus(t, can.Metadata); got != "invalid_timestamp" { + t.Errorf("status = %q, want invalid_timestamp", got) + } +} + +func TestProcessFrameFlagsShortFrame(t *testing.T) { + // 0xC2 is declared as 8 bytes. + can, signals := ProcessFrame("cayman", "pcan", 0xC2, validTS, []byte{0x01, 0x02}) + if len(signals) != 0 { + t.Error("a short frame should decode no signals") + } + if got := metaStatus(t, can.Metadata); got != "short_frame" { + t.Errorf("status = %q, want short_frame", got) + } +} + +func TestProcessFrameDecodesTCMStatus(t *testing.T) { + data := make([]byte, 8) + data[0] = 0b1111 + binary.LittleEndian.PutUint16(data[1:3], 42) + + _, signals := ProcessFrame("cayman", "tcm", model.MsgIDTCMStatus, validTS, data) + byName := map[string]float64{} + for _, s := range signals { + byName[s.Name] = s.Value + } + if byName["tcm_mapache_ok"] != 1 { + t.Errorf("tcm_mapache_ok = %v, want 1", byName["tcm_mapache_ok"]) + } + if byName["tcm_mapache_ping"] != 42 { + t.Errorf("tcm_mapache_ping = %v, want 42", byName["tcm_mapache_ping"]) + } +} + +// 0x200 and 0x201 are only TCM frames when they arrive on the tcm bus. +// The same ids on a physical bus belong to the DBC (0x210 is SCCM2). +func TestProcessFrameOnlyTreatsTCMBusAsHousekeeping(t *testing.T) { + data := make([]byte, 29) + _, signals := ProcessFrame("cayman", "pcan", model.MsgIDTCMResources, validTS, data) + for _, s := range signals { + if s.Name == "pcan_cpu_temp" { + t.Error("0x201 on a physical bus must not decode as TCM resources") + } + } +} + +func TestProcessFrameDecodesTCMResources(t *testing.T) { + data := make([]byte, 29) + binary.LittleEndian.PutUint16(data[13:15], 512) + data[27] = 55 + data[28] = 0b0010 + + _, signals := ProcessFrame("cayman", "tcm", model.MsgIDTCMResources, validTS, data) + byName := map[string]float64{} + for _, s := range signals { + byName[s.Name] = s.Value + } + if byName["tcm_ram_total"] != 512 { + t.Errorf("tcm_ram_total = %v, want 512", byName["tcm_ram_total"]) + } + if byName["tcm_cpu_temp"] != 55 { + t.Errorf("tcm_cpu_temp = %v, want 55", byName["tcm_cpu_temp"]) + } + if byName["tcm_undervoltage_since_boot"] != 1 { + t.Errorf("tcm_undervoltage_since_boot = %v, want 1", byName["tcm_undervoltage_since_boot"]) + } +} diff --git a/p987/service/ping.go b/p987/service/ping.go new file mode 100644 index 00000000..38c05dc0 --- /dev/null +++ b/p987/service/ping.go @@ -0,0 +1,68 @@ +package service + +import ( + "context" + "encoding/binary" + "fmt" + "time" + + "github.com/gaucho-racing/mapache/p987/config" + "github.com/gaucho-racing/mapache/p987/database" + "github.com/gaucho-racing/mapache/p987/mqtt" + "github.com/gaucho-racing/mapache/p987/pkg/logger" + + "github.com/gaucho-racing/mapache/mapache-go/v3" +) + +// pingPayloadSize is the relay's ping: u64 BE microsecond timestamp +// followed by a u16 BE upload key. +const pingPayloadSize = 10 + +func HandlePing(vehicleID string, bus string, payload []byte) { + if len(payload) < pingPayloadSize { + logger.SugarLogger.Infof("[MQ] Ping too short, ignoring %d bytes", len(payload)) + return + } + ping := binary.BigEndian.Uint64(payload[:8]) + uploadKey := binary.BigEndian.Uint16(payload[8:10]) + if !ValidateUploadKey(vehicleID, int(uploadKey)) { + logger.SugarLogger.Infof("Upload key validation failed for vehicle %s, ignoring", vehicleID) + return + } + SendPong(vehicleID, bus, ping) +} + +// SendPong echoes the original ping alongside our own clock so the relay +// can compute round-trip time without trusting our clock offset. +func SendPong(vehicleID string, bus string, ping uint64) { + topic := fmt.Sprintf("%s/%s/%s/pong", config.TopicRoot, vehicleID, bus) + pong := uint64(time.Now().UnixMicro()) + latency := pong - ping + + payload := make([]byte, 16) + binary.BigEndian.PutUint64(payload, ping) + binary.BigEndian.PutUint64(payload[8:], pong) + + mqtt.Publish(context.Background(), topic, payload) + logger.SugarLogger.Infof("[PING] Received ping from %s/%s/%s in %dms", config.TopicRoot, vehicleID, bus, latency/1000) + + if err := CreatePing(mapache.Ping{ + VehicleID: vehicleID, + Ping: int(ping), + Pong: int(pong), + Latency: int(latency), + }); err != nil { + logger.SugarLogger.Infof("Error creating ping: %s", err) + } +} + +const insertPingSQL = `INSERT INTO ping (vehicle_id, ping, pong, latency) VALUES (?, ?, ?, ?)` + +func CreatePing(ping mapache.Ping) error { + if !config.ClickhouseEnabled() { + return nil + } + ctx := database.InsertCtx(context.Background()) + return database.Conn.Exec(ctx, insertPingSQL, + ping.VehicleID, int64(ping.Ping), int64(ping.Pong), int32(ping.Latency)) +} diff --git a/p987/service/signal.go b/p987/service/signal.go new file mode 100644 index 00000000..a4d06c01 --- /dev/null +++ b/p987/service/signal.go @@ -0,0 +1,59 @@ +package service + +import ( + "context" + "fmt" + "time" + + "github.com/gaucho-racing/mapache/p987/config" + "github.com/gaucho-racing/mapache/p987/database" + + mapache "github.com/gaucho-racing/mapache/mapache-go/v3" + ulid "github.com/gaucho-racing/ulid-go" +) + +// produced_at is MATERIALIZED from timestamp; created_at defaults to insert +// wall clock and acts as the ReplacingMergeTree version. Both omitted here. +const insertSignalSQL = `INSERT INTO signal (id, timestamp, vehicle_id, name, value, raw_value)` + +func CreateSignals(signals []mapache.Signal) error { + if len(signals) == 0 { + return nil + } + // Stamp CreatedAt locally before any MQTT publish or CH insert so + // downstream consumers (live cache eviction, SSE Last-Event-ID) have + // a reliable wall-clock anchor independent of producer/CAN-frame clock + // skew. The CH insert omits the column so the server-side now64(6) + // default still wins for the persisted row. + now := time.Now().UTC() + for i := range signals { + if signals[i].Timestamp == 0 { + return fmt.Errorf("signal timestamp cannot be 0") + } + if signals[i].VehicleID == "" { + return fmt.Errorf("signal vehicle id cannot be empty") + } + if signals[i].Name == "" { + return fmt.Errorf("signal name cannot be empty") + } + signals[i].ID = ulid.Make().Prefixed("sgnl") + signals[i].CreatedAt = now + } + if !config.ClickhouseEnabled() { + return nil + } + // One buffered block insert per call — a single round trip regardless of + // signal count, which matters with CH ~25ms away from foundry. async_insert + // (InsertCtx) doesn't apply to native block inserts, so plain ctx here. + batch, err := database.Conn.PrepareBatch(context.Background(), insertSignalSQL) + if err != nil { + return err + } + defer batch.Close() + for _, s := range signals { + if err := batch.Append(s.ID, int64(s.Timestamp), s.VehicleID, s.Name, s.Value, int64(s.RawValue)); err != nil { + return err + } + } + return batch.Send() +} diff --git a/p987/service/vehicle.go b/p987/service/vehicle.go new file mode 100644 index 00000000..58b401b2 --- /dev/null +++ b/p987/service/vehicle.go @@ -0,0 +1,104 @@ +package service + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "strconv" + "sync" + "time" + + "github.com/gaucho-racing/mapache/p987/config" + "github.com/gaucho-racing/mapache/p987/pkg/kerbecs" + "github.com/gaucho-racing/mapache/p987/pkg/logger" + + "github.com/gaucho-racing/mapache/mapache-go/v3" +) + +type uploadKeyCacheEntry struct { + UploadKey int + Found bool + ExpiresAt time.Time +} + +var uploadKeyCache sync.Map + +// missTTL keeps a failed lookup cached briefly so a vehicle service +// outage doesn't turn every inbound frame into an HTTP request. +const missTTL = time.Minute + +func ValidateUploadKey(vehicleID string, key int) bool { + if config.SkipAuthCheck { + return true + } + + if entry, ok := uploadKeyCache.Load(vehicleID); ok { + cached := entry.(uploadKeyCacheEntry) + if time.Now().Before(cached.ExpiresAt) { + if !cached.Found { + return false + } + return cached.UploadKey == key + } + } + + vehicle, ok := fetchVehicle(vehicleID) + if !ok { + uploadKeyCache.Store(vehicleID, uploadKeyCacheEntry{ + Found: false, + ExpiresAt: time.Now().Add(missTTL), + }) + return false + } + + hitTTL, err := strconv.Atoi(config.VehicleUploadKeyCacheTTL) + if err != nil { + hitTTL = 600 + } + uploadKeyCache.Store(vehicleID, uploadKeyCacheEntry{ + UploadKey: vehicle.UploadKey, + Found: true, + ExpiresAt: time.Now().Add(time.Duration(hitTTL) * time.Second), + }) + + if vehicle.UploadKey != key { + logger.SugarLogger.Infof("Upload key mismatch for vehicle %s: expected %d, got %d", vehicleID, vehicle.UploadKey, key) + return false + } + return true +} + +func fetchVehicle(vehicleID string) (mapache.Vehicle, bool) { + path := fmt.Sprintf("/api/vehicles/%s", vehicleID) + upstreamURL, err := kerbecs.Resolve("GET", path) + if err != nil { + logger.SugarLogger.Warnf("Failed to resolve vehicle route via kerbecs: %v", err) + return mapache.Vehicle{}, false + } + + resp, err := http.Get(upstreamURL) + if err != nil { + logger.SugarLogger.Warnf("Failed to fetch vehicle %s: %v", vehicleID, err) + return mapache.Vehicle{}, false + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + logger.SugarLogger.Warnf("Vehicle service returned %d for vehicle %s", resp.StatusCode, vehicleID) + return mapache.Vehicle{}, false + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + logger.SugarLogger.Warnf("Failed to read vehicle response for %s: %v", vehicleID, err) + return mapache.Vehicle{}, false + } + + var vehicle mapache.Vehicle + if err := json.Unmarshal(body, &vehicle); err != nil { + logger.SugarLogger.Warnf("Failed to unmarshal vehicle %s: %v", vehicleID, err) + return mapache.Vehicle{}, false + } + return vehicle, true +} From 942fd4a2fc542962687bdf6a71a09a47cb0b8b3d Mon Sep 17 00:00:00 2001 From: Bharat Kathi Date: Sat, 29 Aug 2026 09:35:43 -0700 Subject: [PATCH 2/3] chore(p987): drop the test suite Matches how the rest of the services are set up. --- p987/dbc/dbc_test.go | 125 -------------------------- p987/dbc/decode_test.go | 154 -------------------------------- p987/model/tcm_test.go | 130 --------------------------- p987/service/message_test.go | 165 ----------------------------------- 4 files changed, 574 deletions(-) delete mode 100644 p987/dbc/dbc_test.go delete mode 100644 p987/dbc/decode_test.go delete mode 100644 p987/model/tcm_test.go delete mode 100644 p987/service/message_test.go diff --git a/p987/dbc/dbc_test.go b/p987/dbc/dbc_test.go deleted file mode 100644 index 6b14addd..00000000 --- a/p987/dbc/dbc_test.go +++ /dev/null @@ -1,125 +0,0 @@ -package dbc - -import ( - "strings" - "testing" -) - -const sampleDBC = ` -BO_ 194 SCCM1: 8 SCCM - SG_ SCCM_SteeringAngleSign : 15|1@1+ (1,0) [0|1] "" SCCM - SG_ SCCM_SteeringAngle : 2|13@1+ (0.175,0) [0|0] "deg" SCCM - -BO_ 581 DME_Signed: 8 DME - SG_ DME_EngineCompTemp : 57|6@1- (1,-48) [-48|15] "degC" DME - -BO_ 582 DME_Muxed: 8 DME - SG_ DME2_MUL_Code m0 : 0|6@1+ (1,0) [0|63] "" DME - SG_ DME_Plain : 8|8@1+ (1,0) [0|255] "" DME -` - -func TestParse(t *testing.T) { - db, err := Parse(strings.NewReader(sampleDBC)) - if err != nil { - t.Fatalf("Parse: %v", err) - } - - if len(db.Messages) != 3 { - t.Fatalf("message count = %d, want 3", len(db.Messages)) - } - - msg, ok := db.Messages[194] - if !ok { - t.Fatal("message 194 missing") - } - if msg.Name != "SCCM1" || msg.Length != 8 { - t.Errorf("message 194 = %q len %d, want SCCM1 len 8", msg.Name, msg.Length) - } - if len(msg.Signals) != 2 { - t.Fatalf("message 194 signal count = %d, want 2", len(msg.Signals)) - } - - angle := msg.Signals[1] - if angle.Name != "SCCM_SteeringAngle" { - t.Errorf("signal name = %q", angle.Name) - } - if angle.StartBit != 2 || angle.Length != 13 { - t.Errorf("signal placement = %d|%d, want 2|13", angle.StartBit, angle.Length) - } - if !angle.LittleEndian || angle.Signed { - t.Errorf("signal byte order/sign = little:%v signed:%v, want little:true signed:false", angle.LittleEndian, angle.Signed) - } - if angle.Factor != 0.175 || angle.Offset != 0 { - t.Errorf("scaling = (%v,%v), want (0.175,0)", angle.Factor, angle.Offset) - } - if angle.Unit != "deg" { - t.Errorf("unit = %q, want deg", angle.Unit) - } -} - -func TestParseSignedAndNegativeOffset(t *testing.T) { - db, err := Parse(strings.NewReader(sampleDBC)) - if err != nil { - t.Fatalf("Parse: %v", err) - } - s := db.Messages[581].Signals[0] - if !s.Signed { - t.Error("DME_EngineCompTemp should be signed") - } - if s.Offset != -48 { - t.Errorf("offset = %v, want -48", s.Offset) - } -} - -func TestParseMarksMultiplexed(t *testing.T) { - db, err := Parse(strings.NewReader(sampleDBC)) - if err != nil { - t.Fatalf("Parse: %v", err) - } - msg := db.Messages[582] - if !msg.Signals[0].Multiplexed { - t.Error("m0 signal should be marked multiplexed") - } - if msg.Signals[1].Multiplexed { - t.Error("plain signal should not be marked multiplexed") - } - if db.MultiplexedCount() != 1 { - t.Errorf("MultiplexedCount = %d, want 1", db.MultiplexedCount()) - } -} - -func TestParseRejectsEmpty(t *testing.T) { - if _, err := Parse(strings.NewReader("VERSION \"\"\n")); err == nil { - t.Error("expected an error for a DBC with no messages") - } -} - -// The real file is the one that matters — a parser that only handles the -// synthetic sample above would be useless. -func TestParseEmbeddedCayman(t *testing.T) { - db, err := Cayman987() - if err != nil { - t.Fatalf("Cayman987: %v", err) - } - if len(db.Messages) != 30 { - t.Errorf("message count = %d, want 30", len(db.Messages)) - } - if db.SignalCount() != 214 { - t.Errorf("signal count = %d, want 214", db.SignalCount()) - } - // 0x24A PSM2 — a message we expect to decode on the car. - if _, ok := db.Messages[0x24A]; !ok { - t.Error("0x24A missing from the parsed database") - } - for id, m := range db.Messages { - if m.Length < 1 || m.Length > 64 { - t.Errorf("message 0x%X has implausible length %d", id, m.Length) - } - for _, s := range m.Signals { - if s.StartBit+s.Length > m.Length*8 { - t.Errorf("0x%X signal %s runs past the frame: %d|%d in %d bytes", - id, s.Name, s.StartBit, s.Length, m.Length) - } - } - } -} diff --git a/p987/dbc/decode_test.go b/p987/dbc/decode_test.go deleted file mode 100644 index ab7cb4cc..00000000 --- a/p987/dbc/decode_test.go +++ /dev/null @@ -1,154 +0,0 @@ -package dbc - -import ( - "math" - "testing" -) - -func TestExtractLittleEndian(t *testing.T) { - // 13 bits starting at bit 2. Raw 1000 shifted left 2 is 4000 = - // 0x0FA0, which little-endian is byte0=0xA0 byte1=0x0F. - data := []byte{0xA0, 0x0F, 0, 0, 0, 0, 0, 0} - got, ok := extractLittleEndian(data, 2, 13) - if !ok { - t.Fatal("extract reported the signal does not fit") - } - if got != 1000 { - t.Errorf("raw = %d, want 1000", got) - } -} - -func TestExtractLittleEndianSingleBit(t *testing.T) { - data := []byte{0b0000_1000} - if got, _ := extractLittleEndian(data, 3, 1); got != 1 { - t.Errorf("bit 3 = %d, want 1", got) - } - if got, _ := extractLittleEndian(data, 2, 1); got != 0 { - t.Errorf("bit 2 = %d, want 0", got) - } -} - -// Motorola order walks down within a byte then resumes at the top of the -// next, so a 16-bit signal starting at bit 7 reads the bytes big-endian. -func TestExtractBigEndian(t *testing.T) { - got, ok := extractBigEndian([]byte{0x12, 0x34}, 7, 16) - if !ok { - t.Fatal("extract reported the signal does not fit") - } - if got != 0x1234 { - t.Errorf("raw = %#x, want 0x1234", got) - } -} - -func TestExtractRejectsOverrun(t *testing.T) { - if _, ok := extractLittleEndian([]byte{0xFF}, 4, 8); ok { - t.Error("a signal running past the frame should not extract") - } - if _, ok := extractLittleEndian([]byte{0xFF}, 0, 8); !ok { - t.Error("a signal exactly filling the frame should extract") - } -} - -func TestSignExtension(t *testing.T) { - // 6 bits at bit 57 (byte 7, bit 1). All ones is -1 two's complement. - data := []byte{0, 0, 0, 0, 0, 0, 0, 0x7E} - s := Signal{StartBit: 57, Length: 6, LittleEndian: true, Signed: true} - raw, ok := s.extract(data) - if !ok { - t.Fatal("extract failed") - } - if raw != -1 { - t.Errorf("raw = %d, want -1", raw) - } - - unsigned := Signal{StartBit: 57, Length: 6, LittleEndian: true} - if raw, _ := unsigned.extract(data); raw != 63 { - t.Errorf("unsigned raw = %d, want 63", raw) - } -} - -func TestDecodeAppliesScaling(t *testing.T) { - msg := &Message{ - ID: 1, Name: "T", Length: 8, - Signals: []Signal{ - {Name: "angle", StartBit: 2, Length: 13, LittleEndian: true, Factor: 0.175, Unit: "deg"}, - {Name: "temp", StartBit: 57, Length: 6, LittleEndian: true, Signed: true, Factor: 1, Offset: -48, Unit: "C"}, - }, - } - data := []byte{0xA0, 0x0F, 0, 0, 0, 0, 0, 0x7E} - - got := msg.Decode(data) - if len(got) != 2 { - t.Fatalf("decoded %d signals, want 2", len(got)) - } - if math.Abs(got[0].Value-175.0) > 1e-9 { - t.Errorf("angle = %v, want 175", got[0].Value) - } - if got[0].Raw != 1000 { - t.Errorf("angle raw = %d, want 1000", got[0].Raw) - } - // -1 raw with offset -48. - if math.Abs(got[1].Value-(-49)) > 1e-9 { - t.Errorf("temp = %v, want -49", got[1].Value) - } -} - -func TestDecodeSkipsMultiplexed(t *testing.T) { - msg := &Message{ - ID: 1, Length: 8, - Signals: []Signal{ - {Name: "muxed", StartBit: 0, Length: 8, LittleEndian: true, Factor: 1, Multiplexed: true}, - {Name: "plain", StartBit: 8, Length: 8, LittleEndian: true, Factor: 1}, - }, - } - got := msg.Decode([]byte{0xAA, 0xBB, 0, 0, 0, 0, 0, 0}) - if len(got) != 1 { - t.Fatalf("decoded %d signals, want 1 (multiplexed skipped)", len(got)) - } - if got[0].Name != "plain" || got[0].Raw != 0xBB { - t.Errorf("decoded %+v, want plain=0xBB", got[0]) - } -} - -func TestDecodeSkipsSignalsPastEndOfFrame(t *testing.T) { - msg := &Message{ - ID: 1, Length: 8, - Signals: []Signal{ - {Name: "fits", StartBit: 0, Length: 8, LittleEndian: true, Factor: 1}, - {Name: "overruns", StartBit: 8, Length: 16, LittleEndian: true, Factor: 1}, - }, - } - // Two bytes only: the second signal cannot be read. A zero would be - // indistinguishable from a real reading, so it must be absent. - got := msg.Decode([]byte{0x11, 0x22}) - if len(got) != 1 || got[0].Name != "fits" { - t.Errorf("decoded %+v, want only the signal that fits", got) - } -} - -// Decode every message in the real DBC against a frame of its declared -// length. Nothing should panic, and every non-multiplexed signal that fits -// should produce a value. -func TestDecodeEmbeddedCaymanMessages(t *testing.T) { - db, err := Cayman987() - if err != nil { - t.Fatalf("Cayman987: %v", err) - } - for id, msg := range db.Messages { - data := make([]byte, msg.Length) - for i := range data { - data[i] = 0xA5 - } - got := msg.Decode(data) - - want := 0 - for _, s := range msg.Signals { - if !s.Multiplexed { - want++ - } - } - if len(got) != want { - t.Errorf("0x%X (%s) decoded %d signals, want %d", id, msg.Name, len(got), want) - } - } -} diff --git a/p987/model/tcm_test.go b/p987/model/tcm_test.go deleted file mode 100644 index bed9d8f6..00000000 --- a/p987/model/tcm_test.go +++ /dev/null @@ -1,130 +0,0 @@ -package model - -import ( - "encoding/binary" - "testing" -) - -func find(t *testing.T, decoded []Decoded, name string) Decoded { - t.Helper() - for _, d := range decoded { - if d.Name == name { - return d - } - } - t.Fatalf("signal %q not decoded", name) - return Decoded{} -} - -func TestDecodeTCMStatus(t *testing.T) { - data := make([]byte, 8) - data[0] = 0b1011 // connection, mqtt, clock ok; mapache down - binary.LittleEndian.PutUint16(data[1:3], 1234) - - got, ok := DecodeTCMStatus(data) - if !ok { - t.Fatal("decode failed") - } - for name, want := range map[string]float64{ - "connection_ok": 1, - "mqtt_ok": 1, - "mapache_ok": 0, - "clock_ok": 1, - "mapache_ping": 1234, - } { - if v := find(t, got, name).Value; v != want { - t.Errorf("%s = %v, want %v", name, v, want) - } - } -} - -func TestDecodeTCMStatusRejectsShortFrame(t *testing.T) { - if _, ok := DecodeTCMStatus([]byte{0x01, 0x02}); ok { - t.Error("a 2-byte status frame should not decode") - } -} - -// buildResources mirrors the relay's encodeResourcePayload. If the two -// layouts ever drift this test is what catches it. -func buildResources() []byte { - data := make([]byte, resourcesPayloadSize) - off := 0 - for i, v := range []struct{ freq, util int }{{1000, 10}, {1001, 20}, {1002, 30}, {1003, 40}} { - binary.LittleEndian.PutUint16(data[off:off+2], uint16(v.freq)) - data[off+2] = byte(v.util) - off += 3 - _ = i - } - data[12] = 25 - binary.LittleEndian.PutUint16(data[13:15], 512) - binary.LittleEndian.PutUint16(data[15:17], 128) - data[17] = 25 - binary.LittleEndian.PutUint32(data[18:22], 30000) - binary.LittleEndian.PutUint32(data[22:26], 12000) - data[26] = 40 - data[27] = 55 - data[28] = ThrottleFlagsForTest - return data -} - -// bit 1 (under-voltage since boot) and bit 2 (thermal throttled now). -const ThrottleFlagsForTest = 0b0110 - -func TestDecodeTCMResources(t *testing.T) { - got, ok := DecodeTCMResources(buildResources()) - if !ok { - t.Fatal("decode failed") - } - - for name, want := range map[string]float64{ - "cpu_0_freq": 1000, - "cpu_0_util": 10, - "cpu_3_freq": 1003, - "cpu_3_util": 40, - "cpu_total_util": 25, - "ram_total": 512, - "ram_used": 128, - "ram_util": 25, - "disk_total": 30000, - "disk_used": 12000, - "disk_util": 40, - "cpu_temp": 55, - "undervoltage": 0, - "undervoltage_since_boot": 1, - "thermal_throttled": 1, - "thermal_throttled_since_boot": 0, - } { - if v := find(t, got, name).Value; v != want { - t.Errorf("%s = %v, want %v", name, v, want) - } - } -} - -// The Jetson layout was 44 bytes with GPU and power fields. A relay still -// sending that would decode into garbage, so the length check must reject -// anything shorter than the current layout and the decoder must not read -// past it. -func TestDecodeTCMResourcesRejectsOldLayout(t *testing.T) { - if _, ok := DecodeTCMResources(make([]byte, 28)); ok { - t.Error("a 28-byte frame should not decode") - } - if _, ok := DecodeTCMResources(make([]byte, resourcesPayloadSize)); !ok { - t.Error("a 29-byte frame should decode") - } -} - -func TestDecodeTCMResourcesReportsEveryCPU(t *testing.T) { - got, _ := DecodeTCMResources(buildResources()) - for i := 0; i < ReportedCPUs; i++ { - find(t, got, cpuNames[i].freq) - find(t, got, cpuNames[i].util) - } - // The Pi has no GPU counters, so these must not appear at all. - for _, gone := range []string{"gpu_util", "gpu_freq", "gpu_temp", "voltage_draw", "power_draw"} { - for _, d := range got { - if d.Name == gone { - t.Errorf("%s should not be decoded on the Pi layout", gone) - } - } - } -} diff --git a/p987/service/message_test.go b/p987/service/message_test.go deleted file mode 100644 index f4ee1bc7..00000000 --- a/p987/service/message_test.go +++ /dev/null @@ -1,165 +0,0 @@ -package service - -import ( - "encoding/binary" - "encoding/json" - "testing" - "time" - - "github.com/gaucho-racing/mapache/p987/model" - "github.com/gaucho-racing/mapache/p987/pkg/logger" -) - -func init() { - // The service logs through the package logger, which main wires up - // before anything else runs. - logger.Init(false) - if err := InitDecoder(); err != nil { - panic(err) - } -} - -// validTS is a timestamp comfortably past the pre-clock cutoff. -var validTS = int(time.Date(2026, 8, 1, 0, 0, 0, 0, time.UTC).UnixMicro()) - -func metaStatus(t *testing.T, raw []byte) string { - t.Helper() - var m map[string]any - if err := json.Unmarshal(raw, &m); err != nil { - t.Fatalf("metadata is not valid json: %v", err) - } - status, _ := m["status"].(string) - return status -} - -func TestIsValidProducedAt(t *testing.T) { - if IsValidProducedAt(0) { - t.Error("epoch 0 should be rejected — a Pi with no RTC boots to 1970") - } - if !IsValidProducedAt(validTS) { - t.Error("a 2026 timestamp should be accepted") - } -} - -func TestProcessFrameDecodesDBCMessage(t *testing.T) { - // 0xC2 SCCM1: SCCM_SteeringAngle is 13 bits at bit 2, scale 0.175. - data := []byte{0xA0, 0x0F, 0, 0, 0, 0, 0, 0} - can, signals := ProcessFrame("cayman", "pcan", 0xC2, validTS, data) - - if got := metaStatus(t, can.Metadata); got != "ok" { - t.Fatalf("metadata status = %q, want ok", got) - } - if can.NodeID != "pcan" { - t.Errorf("node id = %q, want the bus label", can.NodeID) - } - if len(signals) == 0 { - t.Fatal("expected decoded signals") - } - - var found bool - for _, s := range signals { - if s.Name == "pcan_SCCM_SteeringAngle" { - found = true - if s.Value != 175.0 { - t.Errorf("steering angle = %v, want 175", s.Value) - } - if s.RawValue != 1000 { - t.Errorf("steering raw = %d, want 1000", s.RawValue) - } - } - if s.VehicleID != "cayman" || s.Timestamp != validTS { - t.Errorf("signal %s not stamped: vehicle=%q ts=%d", s.Name, s.VehicleID, s.Timestamp) - } - } - if !found { - t.Error("pcan_SCCM_SteeringAngle missing — signals should be bus-prefixed") - } -} - -func TestProcessFrameKeepsRawFrameOnUnknownID(t *testing.T) { - can, signals := ProcessFrame("cayman", "pcan", 0x7FF, validTS, []byte{1, 2, 3}) - if len(signals) != 0 { - t.Errorf("unknown id should decode no signals, got %d", len(signals)) - } - if got := metaStatus(t, can.Metadata); got != "unknown_can_id" { - t.Errorf("status = %q, want unknown_can_id", got) - } - // The frame itself must still be stored — that's how an unknown id - // gets reverse-engineered later. - if len(can.Bytes) != 3 { - t.Errorf("raw bytes = %v, want them preserved", can.Bytes) - } -} - -func TestProcessFrameRejectsPreClockTimestamp(t *testing.T) { - can, signals := ProcessFrame("cayman", "pcan", 0xC2, 0, []byte{0xA0, 0x0F, 0, 0, 0, 0, 0, 0}) - if len(signals) != 0 { - t.Error("a pre-clock frame should decode no signals") - } - if got := metaStatus(t, can.Metadata); got != "invalid_timestamp" { - t.Errorf("status = %q, want invalid_timestamp", got) - } -} - -func TestProcessFrameFlagsShortFrame(t *testing.T) { - // 0xC2 is declared as 8 bytes. - can, signals := ProcessFrame("cayman", "pcan", 0xC2, validTS, []byte{0x01, 0x02}) - if len(signals) != 0 { - t.Error("a short frame should decode no signals") - } - if got := metaStatus(t, can.Metadata); got != "short_frame" { - t.Errorf("status = %q, want short_frame", got) - } -} - -func TestProcessFrameDecodesTCMStatus(t *testing.T) { - data := make([]byte, 8) - data[0] = 0b1111 - binary.LittleEndian.PutUint16(data[1:3], 42) - - _, signals := ProcessFrame("cayman", "tcm", model.MsgIDTCMStatus, validTS, data) - byName := map[string]float64{} - for _, s := range signals { - byName[s.Name] = s.Value - } - if byName["tcm_mapache_ok"] != 1 { - t.Errorf("tcm_mapache_ok = %v, want 1", byName["tcm_mapache_ok"]) - } - if byName["tcm_mapache_ping"] != 42 { - t.Errorf("tcm_mapache_ping = %v, want 42", byName["tcm_mapache_ping"]) - } -} - -// 0x200 and 0x201 are only TCM frames when they arrive on the tcm bus. -// The same ids on a physical bus belong to the DBC (0x210 is SCCM2). -func TestProcessFrameOnlyTreatsTCMBusAsHousekeeping(t *testing.T) { - data := make([]byte, 29) - _, signals := ProcessFrame("cayman", "pcan", model.MsgIDTCMResources, validTS, data) - for _, s := range signals { - if s.Name == "pcan_cpu_temp" { - t.Error("0x201 on a physical bus must not decode as TCM resources") - } - } -} - -func TestProcessFrameDecodesTCMResources(t *testing.T) { - data := make([]byte, 29) - binary.LittleEndian.PutUint16(data[13:15], 512) - data[27] = 55 - data[28] = 0b0010 - - _, signals := ProcessFrame("cayman", "tcm", model.MsgIDTCMResources, validTS, data) - byName := map[string]float64{} - for _, s := range signals { - byName[s.Name] = s.Value - } - if byName["tcm_ram_total"] != 512 { - t.Errorf("tcm_ram_total = %v, want 512", byName["tcm_ram_total"]) - } - if byName["tcm_cpu_temp"] != 55 { - t.Errorf("tcm_cpu_temp = %v, want 55", byName["tcm_cpu_temp"]) - } - if byName["tcm_undervoltage_since_boot"] != 1 { - t.Errorf("tcm_undervoltage_since_boot = %v, want 1", byName["tcm_undervoltage_since_boot"]) - } -} From abaf6d5d63b4f3880b7a5de1fa7a694fc5320493 Mon Sep 17 00:00:00 2001 From: Bharat Kathi Date: Sat, 29 Aug 2026 09:44:19 -0700 Subject: [PATCH 3/3] refactor(p987): declare signals as mapache-go messages instead of parsing a DBC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- p987/api/api.go | 5 - p987/api/can.go | 123 ++-------- p987/api/can_trace.go | 69 ++++++ p987/api/dbc.go | 109 --------- p987/dbc/cayman_987.dbc | 521 ---------------------------------------- p987/dbc/dbc.go | 186 -------------- p987/dbc/decode.go | 129 ---------- p987/main.go | 7 - p987/model/dme.go | 493 +++++++++++++++++++++++++++++++++++++ p987/model/gateway.go | 49 ++++ p987/model/klima.go | 51 ++++ p987/model/message.go | 75 ++++++ p987/model/pas.go | 13 + p987/model/pdk.go | 55 +++++ p987/model/psm.go | 194 +++++++++++++++ p987/model/sccm.go | 110 +++++++++ p987/model/signal.go | 34 +++ p987/model/tcm.go | 185 +++++++------- p987/service/message.go | 98 ++------ 19 files changed, 1274 insertions(+), 1232 deletions(-) create mode 100644 p987/api/can_trace.go delete mode 100644 p987/api/dbc.go delete mode 100644 p987/dbc/cayman_987.dbc delete mode 100644 p987/dbc/dbc.go delete mode 100644 p987/dbc/decode.go create mode 100644 p987/model/dme.go create mode 100644 p987/model/gateway.go create mode 100644 p987/model/klima.go create mode 100644 p987/model/message.go create mode 100644 p987/model/pas.go create mode 100644 p987/model/pdk.go create mode 100644 p987/model/psm.go create mode 100644 p987/model/sccm.go create mode 100644 p987/model/signal.go diff --git a/p987/api/api.go b/p987/api/api.go index 27f6586a..a394367e 100644 --- a/p987/api/api.go +++ b/p987/api/api.go @@ -36,9 +36,4 @@ func InitializeRoutes(router *gin.Engine) { router.GET("/p987/ping", Ping) router.GET("/p987/messages/:id", GetCANMessage) router.GET("/p987/signals/:id", GetCANBySignalID) - // The decoder registry is the thing most worth inspecting while - // bringing the car up: it answers "is this id in the DBC, and what - // should it produce?" without needing a frame to arrive first. - router.GET("/p987/dbc", GetDBC) - router.GET("/p987/dbc/:id", GetDBCMessage) } diff --git a/p987/api/can.go b/p987/api/can.go index a1b7cee9..573d307b 100644 --- a/p987/api/can.go +++ b/p987/api/can.go @@ -4,11 +4,9 @@ import ( "encoding/hex" "encoding/json" "errors" - "fmt" "net/http" "github.com/gaucho-racing/mapache/p987/config" - "github.com/gaucho-racing/mapache/p987/dbc" "github.com/gaucho-racing/mapache/p987/model" "github.com/gaucho-racing/mapache/p987/service" @@ -20,36 +18,18 @@ import ( // Bytes is hex-encoded (not the default base64) so the dashboard's hex // grid can render 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"` - MessageName string `json:"message_name,omitempty"` - Fields []canSignalTrace `json:"fields"` - Signals []mapache.Signal `json:"signals"` -} - -// canSignalTrace shows where each signal came from inside the frame. -// gr26 traces byte-aligned fields; here the unit is a DBC signal, so the -// trace carries bit position and the scaling that produced the value. -type canSignalTrace struct { - Name string `json:"name"` - SignalName string `json:"signal_name"` - StartBit int `json:"start_bit"` - Length int `json:"length"` - Endian string `json:"endian"` - Sign string `json:"sign"` - Factor float64 `json:"factor"` - Offset float64 `json:"offset"` - Unit string `json:"unit,omitempty"` - RawValue int64 `json:"raw_value"` - Value float64 `json:"value"` + 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"` } func GetCANMessage(c *gin.Context) { @@ -103,73 +83,18 @@ func respondWithCAN( _ = json.Unmarshal(can.Metadata, &meta) } - name, fields := decodeSignalTrace(can) - 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"), - MessageName: name, - Fields: fields, - Signals: signals, + 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: decodeFieldTrace(can), + Signals: signals, }) } - -// decodeSignalTrace re-runs the decoder to expose per-signal placement. -// Returns no fields for ids the DBC doesn't describe — the reason is -// already recorded in can.Metadata. -func decodeSignalTrace(can model.CAN) (string, []canSignalTrace) { - db, err := dbc.Cayman987() - if err != nil { - return "", nil - } - msg, ok := db.Messages[uint32(can.CANID)] - if !ok { - return "", nil - } - - decoded := msg.Decode(can.Bytes) - byName := make(map[string]dbc.Decoded, len(decoded)) - for _, d := range decoded { - byName[d.Name] = d - } - - out := make([]canSignalTrace, 0, len(msg.Signals)) - for _, s := range msg.Signals { - d, present := byName[s.Name] - if !present { - // Multiplexed, or it doesn't fit this frame. Skipped by the - // decoder, so it has no value to report. - continue - } - endian := "big" - if s.LittleEndian { - endian = "little" - } - sign := "unsigned" - if s.Signed { - sign = "signed" - } - out = append(out, canSignalTrace{ - Name: s.Name, - SignalName: fmt.Sprintf("%s_%s", can.NodeID, s.Name), - StartBit: s.StartBit, - Length: s.Length, - Endian: endian, - Sign: sign, - Factor: s.Factor, - Offset: s.Offset, - Unit: s.Unit, - RawValue: d.Raw, - Value: d.Value, - }) - } - return msg.Name, out -} diff --git a/p987/api/can_trace.go b/p987/api/can_trace.go new file mode 100644 index 00000000..07ffaa62 --- /dev/null +++ b/p987/api/can_trace.go @@ -0,0 +1,69 @@ +package api + +import ( + "encoding/hex" + "fmt" + + "github.com/gaucho-racing/mapache/p987/model" + + mapache "github.com/gaucho-racing/mapache/mapache-go/v3" +) + +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"` +} + +// decodeFieldTrace re-runs the decoder to expose per-field metadata. +// Returns nil for unknown ids and decode failures — the reason is already +// recorded in can.Metadata. +func decodeFieldTrace(can model.CAN) []canFieldTrace { + messageStruct := model.GetMessage(can.NodeID, 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/p987/api/dbc.go b/p987/api/dbc.go deleted file mode 100644 index f04f367a..00000000 --- a/p987/api/dbc.go +++ /dev/null @@ -1,109 +0,0 @@ -package api - -import ( - "net/http" - "sort" - "strconv" - "strings" - - "github.com/gaucho-racing/mapache/p987/dbc" - - "github.com/gin-gonic/gin" -) - -type dbcMessageResponse struct { - ID uint32 `json:"id"` - HexID string `json:"hex_id"` - Name string `json:"name"` - Length int `json:"length"` - Signals []dbcSignalResponse `json:"signals"` -} - -type dbcSignalResponse struct { - Name string `json:"name"` - StartBit int `json:"start_bit"` - Length int `json:"length"` - Endian string `json:"endian"` - Signed bool `json:"signed"` - Factor float64 `json:"factor"` - Offset float64 `json:"offset"` - Unit string `json:"unit,omitempty"` - Multiplexed bool `json:"multiplexed"` -} - -// GetDBC lists every message in the loaded database, ordered by id. -func GetDBC(c *gin.Context) { - db, err := dbc.Cayman987() - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - out := make([]dbcMessageResponse, 0, len(db.Messages)) - for _, m := range db.Messages { - out = append(out, toDBCResponse(m)) - } - sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID }) - - c.JSON(http.StatusOK, gin.H{ - "messages": out, - "message_count": len(db.Messages), - "signal_count": db.SignalCount(), - "multiplexed_count": db.MultiplexedCount(), - }) -} - -// GetDBCMessage looks up one message by decimal or 0x-prefixed hex id. -func GetDBCMessage(c *gin.Context) { - db, err := dbc.Cayman987() - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - raw := c.Param("id") - base, digits := 10, raw - if strings.HasPrefix(strings.ToLower(raw), "0x") { - base, digits = 16, raw[2:] - } - id, err := strconv.ParseUint(digits, base, 32) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "id must be a decimal or 0x-prefixed hex can id"}) - return - } - - msg, ok := db.Messages[uint32(id)] - if !ok { - c.JSON(http.StatusNotFound, gin.H{"error": "no dbc entry for that can id"}) - return - } - c.JSON(http.StatusOK, toDBCResponse(msg)) -} - -func toDBCResponse(m *dbc.Message) dbcMessageResponse { - signals := make([]dbcSignalResponse, 0, len(m.Signals)) - for _, s := range m.Signals { - endian := "big" - if s.LittleEndian { - endian = "little" - } - signals = append(signals, dbcSignalResponse{ - Name: s.Name, - StartBit: s.StartBit, - Length: s.Length, - Endian: endian, - Signed: s.Signed, - Factor: s.Factor, - Offset: s.Offset, - Unit: s.Unit, - Multiplexed: s.Multiplexed, - }) - } - return dbcMessageResponse{ - ID: m.ID, - HexID: "0x" + strconv.FormatUint(uint64(m.ID), 16), - Name: m.Name, - Length: m.Length, - Signals: signals, - } -} diff --git a/p987/dbc/cayman_987.dbc b/p987/dbc/cayman_987.dbc deleted file mode 100644 index c856edf9..00000000 --- a/p987/dbc/cayman_987.dbc +++ /dev/null @@ -1,521 +0,0 @@ -VERSION "" - -NS_ : - NS_DESC_ - CM_ - BA_DEF_ - BA_ - VAL_ - CAT_DEF_ - CAT_ - FILTER - BA_DEF_DEF_ - EV_DATA_ - ENVVAR_DATA_ - SGTYPE_ - SGTYPE_VAL_ - BA_DEF_SGTYPE_ - BA_SGTYPE_ - SIG_TYPE_REF_ - VAL_TABLE_ - SIG_GROUP_ - SIG_VALTYPE_ - SIGTYPE_VALTYPE_ - BO_TX_BU_ - BA_DEF_REL_ - BA_REL_ - BA_DEF_DEF_REL_ - BU_SG_REL_ - BU_EV_REL_ - BU_BO_REL_ - SG_MUL_VAL_ - -BS_: - -BU_: DME PSM PDK SCCM PAS Gateway KLIMO DIAG - - -; ═══════════════════════════════════════════════════════════════════ -; Porsche 987 Cayman — Powertrain CAN Bus DBC -; ═══════════════════════════════════════════════════════════════════ -; Decoded from cayman_startup_idle.csv (61,890 msgs, 35 CAN IDs) -; Cross-referenced with Porsche 997.1 DBC. -; -; Confidence markers: -; [✓] = validated against CSV — formula produces correct physical values -; [~] = ported from 997 DBC — structurally correct, scaling may need check -; [?] = placeholder — ID exists but signal layout is unverified -; ═══════════════════════════════════════════════════════════════════ - - -; ─── 0x0C2 (194) — SCCM1: Steering Angle ────────────────────────── -BO_ 194 SCCM1: 8 SCCM - SG_ SCCM_SteeringAngleSign : 15|1@1+ (1,0) [0|1] "" SCCM - SG_ SCCM_SteeringAngleRateSign : 31|1@1+ (1,0) [0|1] "" SCCM - SG_ SCCM_SteeringAngleRate : 18|13@1+ (0.175,0) [0|0] "deg/sec" SCCM - SG_ SCCM_SteeringAngle : 2|13@1+ (0.175,0) [0|0] "deg" SCCM - SG_ SCCM_SensorID : 32|8@1+ (1,0) [0|255] "" SCCM - SG_ SCCM_Counter : 44|4@1+ (1,0) [0|15] "" SCCM - SG_ SCCM_Checksum : 56|8@1+ (1,0) [0|255] "" SCCM - - -; ─── 0x140 (320) — DME Heartbeat [✓] ───────────────────────────── -; D4 = D1 XOR D2 XOR D3 (verified 21/21 unique payloads) -BO_ 320 Heartbeat: 4 DME - SG_ HB_Page : 0|8@1+ (1,0) [0|255] "" DME - SG_ HB_SourceID : 8|8@1+ (1,0) [0|255] "" DME - SG_ HB_Counter : 16|4@1+ (1,0) [0|15] "" DME - SG_ HB_Checksum : 24|8@1+ (1,0) [0|255] "" DME - - -; ─── 0x14A (330) — PSM1: Vehicle Speed, Brake/ESP Status [~] ──── -BO_ 330 PSM1: 8 PSM - SG_ ASR_Requirement : 0|1@1+ (1,0) [0|1] "" PSM - SG_ MSR_Requirement : 1|1@1+ (1,0) [0|1] "" PSM - SG_ ABS_Status : 2|1@1+ (1,0) [0|1] "" PSM - SG_ Brake_Intervention : 3|1@1+ (1,0) [0|1] "" PSM - SG_ ESP_Intervention : 4|1@1+ (1,0) [0|1] "" PSM - SG_ ASR_Switching : 5|2@1+ (1,0) [0|3] "" PSM - SG_ ESP_Control : 7|1@1+ (1,0) [0|1] "" PSM - SG_ ABS_Error : 8|1@1+ (1,0) [0|1] "" PSM - SG_ ESP_Error : 9|1@1+ (1,0) [0|1] "" PSM - SG_ EBV_Error : 10|1@1+ (1,0) [0|1] "" PSM - SG_ PSM_FootBrake : 11|1@1+ (1,0) [0|1] "" PSM - SG_ PSM_FootBrake2 : 12|1@1+ (1,0) [0|1] "" PSM - SG_ PSM_Disabled : 13|1@1+ (1,0) [0|1] "" PSM - SG_ Brake_Fluid_Switch : 14|1@1+ (1,0) [0|1] "" PSM - SG_ PSM_HandBrake : 15|1@1+ (1,0) [0|1] "" PSM - SG_ ESP_Diag_Mode : 16|1@1+ (1,0) [0|1] "" PSM - SG_ Vref : 16|16@1+ (0.01,0) [0|655.35] "km/h" PSM - SG_ PSM_TorqueReqSlow : 32|8@1+ (1,0) [0|255] "" PSM - SG_ PSM_TorqueReqFast : 40|8@1+ (1,0) [0|255] "" PSM - SG_ Engagement_Torque : 48|8@1+ (0.39,0) [0|99.45] "%" PSM - SG_ PSM_LateralAccel : 56|8@1+ (1,0) [0|255] "" PSM - -CM_ SG_ 330 Vref "Vehicle reference speed. (D4*256 + D3) / 100 = km/h. Verified against OBD2 GPS speed (0-48 mph town drive)."; - - - -; ─── 0x165 (357) — PAS: Key Position [✓] ──────────────────────── -; Observed: Off (424×), Ignition On (212×), Engine Start (5×) -BO_ 357 PAS: 1 PAS - SG_ PAS_KeyPresent : 0|1@1+ (1,0) [0|1] "" PAS - SG_ PAS_KeyPosition : 2|2@1+ (1,0) [0|3] "" PAS - -VAL_ 357 PAS_KeyPosition 2 "Engine Start" 1 "Ignition On" 0 "Ignition Off" ; - - -; ─── 0x210 (528) — SCCM2: Cruise Control Buttons [~] ───────────── -BO_ 528 SCCM2: 4 SCCM - SG_ SCCM_CruiseEnable : 8|1@1+ (1,0) [0|1] "" SCCM - SG_ SCCM_CruiseDown : 9|1@1+ (1,0) [0|1] "" SCCM - SG_ SCCM_CruiseTowards : 10|1@1+ (1,0) [0|1] "" SCCM - SG_ SCCM_CruiseAway : 11|1@1+ (1,0) [0|1] "" SCCM - SG_ SCCM_CruiseTowardsHold : 12|1@1+ (1,0) [0|1] "" SCCM - SG_ SCCM_CruiseAwayHold : 13|1@1+ (1,0) [0|1] "" SCCM - SG_ SCCM_CruiseAvailable : 15|1@1+ (1,0) [0|1] "" SCCM - SG_ SCCM_CruiseUp : 17|1@1+ (1,0) [0|1] "" SCCM - SG_ SCCM_CruiseCount1 : 4|4@1+ (1,0) [0|15] "" SCCM - SG_ SCCM_CruiseCount2 : 20|4@1+ (1,0) [0|15] "" SCCM - - -; ─── 0x242 (578) — DME1: Engine Speed, Torque, Pedal ───────────── -; -; RPM is at D3-D4 (bit 16), Intel byte order: (D4 << 8 | D3) * 0.25. -; This exactly matches the 997 DBC definition (0.25 RPM/bit). -; -; Verified against cayman_startup_idle.csv: -; Warm idle: D4=0x0A D3=0xA4 → (0x0AA4) * 0.25 = 681 RPM -; DME2 idle target: 680 RPM ✓ (perfect match) -; Cranking: ~124-138 RPM ✓ (starter motor speed) -; Max range: 0–16384 RPM ✓ (ample headroom above 7400 redline) -; -; D6 (APP) = 0x00 at warm idle = 0% pedal ✓ -; D2 (EngineTorque) varies with engine load — torque-related. -; -BO_ 578 DME1: 8 DME - SG_ DME1_Counter : 0|4@1+ (1,0) [0|15] "" DME - SG_ DME_EngineTorque : 8|8@1+ (0.39,0) [0|99.45] "%" DME - SG_ DME_RPM : 16|16@1+ (0.25,0) [0|16383.75] "rpm" DME - SG_ DME_Interventions : 32|8@1+ (0.39,0) [0|99.45] "%" DME - SG_ DME_APP : 40|8@1+ (0.39215,0) [0|99.998] "%" DME - SG_ DME_TorqueLoss : 48|8@1+ (0.39,0) [0|99.45] "%" DME - SG_ DME_DriverTrq : 56|8@1+ (0.781,0) [0|199.155] "%" DME - -CM_ SG_ 578 DME_RPM "Engine speed at D3-D4 (bit 16), Intel byte order. 0.25 RPM/bit — verified: warm idle = 681 RPM vs 680 target. Cranking = ~130 RPM. Matches 997 DBC exactly. Max range: 16383 RPM."; -CM_ SG_ 578 DME_EngineTorque "Actual engine torque. 0.39 %/bit. Validated: 14% at warm idle (plausible)."; -CM_ SG_ 578 DME_APP "Accelerator pedal position. Verified: 0% at warm idle (foot off). 0.39215 %/bit."; - - -; ─── 0x245 (581) — DME2: Coolant Temp, Idle Target [✓] ────────── -BO_ 581 DME2: 8 DME - SG_ DME2_MUL_Code m0 : 0|6@1+ (1,0) [0|63] "" DME - SG_ DME2_MUL_Code m1 : 0|6@1+ (1,0) [0|63] "" DME - SG_ DME2_MUL_Code m2 : 0|6@1+ (1,0) [0|63] "" DME - SG_ DME2_MUL_Code m3 : 0|6@1+ (1,0) [0|63] "" DME - SG_ DME2_MUX : 6|2@1+ (1,0) [0|3] "" DME - SG_ DME_CoolantTemp : 8|8@1+ (0.75,-48) [-48|143.25] "°C" DME - SG_ DME2_B2_B3 m0 : 16|16@1+ (0.25,0) [0|16383.75] "" DME - SG_ DME2_B2_B3 m1 : 16|16@1+ (1,0) [0|65535] "" DME - SG_ DME2_B2_B3 m2 : 16|16@1+ (1,0) [0|65535] "" DME - SG_ DME2_B2_B3 m3 : 16|16@1+ (1,0) [0|65535] "" DME - SG_ DME_IdleSpeedTarget : 32|8@1+ (10,0) [0|2550] "rpm" DME - SG_ DME_MomentBase : 40|8@1+ (0.39,0) [0|99.45] "%" DME - SG_ DME2_Counter : 48|4@1+ (1,0) [0|15] "" DME - SG_ DME2_TorqueIndexed : 56|8@1+ (0.39215,0) [0|99.998] "%" DME - -VAL_ 581 DME2_MUX 3 "Page3" 2 "Page2_Gearbox" 1 "Page1_Engine" 0 "Page0_Base" ; - -CM_ SG_ 581 DME_CoolantTemp "Coolant temperature. Verified: 66.8–69.0°C in capture. Formula: D2 × 0.75 − 48."; -CM_ SG_ 581 DME_IdleSpeedTarget "Idle speed target. Verified: 680–750 RPM in capture. Formula: D5 × 10."; - - -; ─── 0x246 (582) — DME3: Gear, Ambient Pressure [~] ────────────── -BO_ 582 DME3: 8 DME - SG_ DME_EngagedGear : 0|3@1+ (1,0) [0|7] "" DME - SG_ DME_KickdownActive : 3|1@1+ (1,0) [0|1] "" DME - SG_ DME_CompressorRunning : 4|1@1+ (1,0) [0|1] "" DME - SG_ DME_CompressorFault : 5|1@1+ (1,0) [0|1] "" DME - SG_ Sport_Mode_Error : 6|1@1+ (1,0) [0|1] "" DME - SG_ Ambient_Pressure_Error : 7|1@1+ (1,0) [0|1] "" DME - SG_ DME_GearRequirement : 8|3@1+ (1,0) [0|7] "" DME - SG_ DME_GearRequest : 11|3@1+ (1,0) [0|7] "" DME - SG_ DME_TRQ_Target_Error : 14|1@1+ (1,0) [0|1] "" DME - SG_ DME_Overboost : 15|1@1+ (1,0) [0|1] "" DME - SG_ DME_GB_TargetTrq : 16|8@1+ (0.39,0) [0|99.45] "%" DME - SG_ DME_AccelPedalAngle : 24|8@1+ (0.4,0) [0|99.998] "%" DME - SG_ DME_GB_TrqActual : 32|8@1+ (0.39,0) [0|99.45] "%" DME - SG_ DME_AmbientPressure : 40|8@1+ (5,0) [0|1275] "mbar" DME - SG_ DME3_Counter : 48|4@1+ (1,0) [0|15] "" DME - SG_ DME3_Muxed m0 : 56|8@1+ (0.3937,0) [0|100.39] "%" DME - SG_ DME3_Muxed m1 : 56|8@1+ (0.39215,0) [0|99.998] "%" DME - SG_ DME3_Muxed m2 : 56|8@1+ (1,-40) [-40|215] "°C" DME - SG_ DME3_Muxed m3 : 56|8@1+ (0.75,-96) [-96|95.25] "°" DME - -VAL_ 582 DME_EngagedGear 7 "Reverse" 6 "6th" 5 "5th" 4 "4th" 3 "3rd" 2 "2nd" 1 "1st" 0 "N/P" ; - -CM_ SG_ 582 DME_AmbientPressure "Ambient pressure. Decodes to 990–995 mbar (sea level). D6 × 5."; - - -; ─── 0x24A (586) — PSM2: Wheel Speeds [~] ─────────────────────── -BO_ 586 PSM2: 8 PSM - SG_ PSM_WheelSpeedFL : 0|16@1+ (0.01,0) [0|655.35] "km/h" PSM - SG_ PSM_WheelSpeedFR : 16|16@1+ (0.01,0) [0|655.35] "km/h" PSM - SG_ PSM_WheelSpeedRL : 32|16@1+ (0.01,0) [0|655.35] "km/h" PSM - SG_ PSM_WheelSpeedRR : 48|16@1+ (0.01,0) [0|655.35] "km/h" PSM - -CM_ SG_ 586 PSM_WheelSpeedFL "Wheel speed. Full 16-bit per wheel, 0.01 km/h per bit. Verified against OBD2 GPS and vehicle speed. Formula: (D2*256 + D1) / 100 for FL, etc."; - - - -; ─── 0x303 (771) — DME: Lambda / AFR [?] ───────────────────────── -; 7-byte message, variable data, not in 997 DBC. -; D1 = counter (0x00–0x05), D3-D4 vary as 16-bit, D6 is checksum-like. -BO_ 771 DME_Lambda: 7 DME - SG_ DME_Lambda_Counter : 0|4@1+ (1,0) [0|15] "" DME - SG_ DME_Lambda_Value : 16|16@1+ (0.0001,0) [0|6.5535] "" DME - SG_ DME_Lambda_Status : 32|8@1+ (1,0) [0|255] "" DME - SG_ DME_Lambda_Checksum : 40|8@1+ (1,0) [0|255] "" DME - SG_ DME_Lambda_Flags : 48|8@1+ (1,0) [0|255] "" DME - -CM_ BO_ 771 "Lambda / AFR / intake data. 7-byte DLC unique to 987. Signal layout is approximate — verify with wideband O2."; - - -; ─── 0x308 (776) — Body Control: Lights, Fans, Outside Temp [~] ── -BO_ 776 DRIVEMODE: 8 DME - SG_ LT_Rad_Fan_PWM : 0|2@1+ (1,0) [0|3] "" DME - SG_ RT_Rad_Fan_PWM : 2|2@1+ (1,0) [0|3] "" DME - SG_ Trunk_Lid_Open : 4|1@1+ (1,0) [0|1] "" DME - SG_ Sport_Mode : 5|1@1+ (1,0) [0|1] "" DME - SG_ Wiper_Status : 6|1@1+ (1,0) [0|1] "" DME - SG_ Radio_Key : 7|4@1+ (1,0) [0|15] "" DME - SG_ Low_Beam : 11|1@1+ (1,0) [0|1] "" DME - SG_ Reverse_Light : 12|1@1+ (1,0) [0|1] "" DME - SG_ High_Beam : 14|1@1+ (1,0) [0|1] "" DME - SG_ Outside_Temp : 32|8@1+ (0.5,-50) [-50|77.5] "°C" DME - - -; ─── 0x31F (799) — DME: Engine Running Status [?] ──────────────── -; Constant payload throughout capture: 06 60 34 FF 00 00 0F 00 -BO_ 799 DME_Status: 8 DME - SG_ DME_Status_ID : 0|16@1+ (1,0) [0|65535] "" DME - SG_ DME_Status_Flags1 : 16|8@1+ (1,0) [0|255] "" DME - SG_ DME_Status_Flags2 : 24|8@1+ (1,0) [0|255] "" DME - SG_ DME_Status_Reserved : 32|16@1+ (1,0) [0|65535] "" DME - SG_ DME_Status_Counter : 48|16@1+ (1,0) [0|65535] "" DME - -CM_ BO_ 799 "Engine running status broadcast. Constant data during idle — likely ECU health + counters."; - - -; ─── 0x441 (1089) — DME4: Oil, Boost, Alerts [✓] ──────────────── -BO_ 1089 DME4: 8 DME - SG_ DME_CEL_Flashing : 0|1@1+ (1,0) [0|1] "" DME - SG_ DME_CEL_Steady : 1|1@1+ (1,0) [0|1] "" DME - SG_ DME_FuelReserve : 2|1@1+ (1,0) [0|1] "" DME - SG_ DME_ReducedPower : 3|1@1+ (1,0) [0|1] "" DME - SG_ DME_EngCompFanAlert : 4|1@1+ (1,0) [0|1] "" DME - SG_ DME_OilTempSensFault : 5|1@1+ (1,0) [0|1] "" DME - SG_ DME_OilPressureAlert : 6|1@1+ (1,0) [0|1] "" DME - SG_ DME_ChargingAlert : 7|1@1+ (1,0) [0|1] "" DME - SG_ DME_RadFanSpeedReq : 8|7@1+ (1,0) [0|127] "%" DME - SG_ DME_EngineRunning : 15|1@1+ (1,0) [0|1] "" DME - SG_ DME_FuelConsumption1 : 16|8@1+ (1,0) [0|255] "µl" DME - SG_ DME_FuelConsumption2 : 24|8@1+ (1,0) [0|255] "µl" DME - SG_ DME_BoostPressure : 32|8@1+ (0.01,0) [0|2.55] "bar" DME - SG_ DME_OilTemp : 40|8@1+ (0.75,-48) [-48|143.25] "°C" DME - SG_ DME_OilPressure : 48|8@1+ (0.04,0) [0|10.2] "bar" DME - SG_ DME_CoolantLevelSW : 56|1@1+ (1,0) [0|1] "" DME - SG_ DME_EngineCompTemp : 57|6@1- (1,-48) [-48|15] "°C" DME - SG_ DME_EngCompTempFail : 63|1@1+ (1,0) [0|1] "" DME - -CM_ SG_ 1089 DME_OilTemp "Oil temperature. Verified: 42.0–43.5°C in capture. D6 × 0.75 − 48."; -CM_ SG_ 1089 DME_BoostPressure "Boost pressure at D5 (byte 4, bit 32). Reads 0.00 bar on NA 987 Cayman. 0.01 bar/bit. On 997 Turbo this carries actual boost; shared CAN layout with NA cars."; -CM_ SG_ 1089 DME_CEL_Steady "Check-engine light steady. Flagged in this capture — DTC may be present."; -CM_ SG_ 1089 DME_EngineRunning "1 = engine speed > 40 RPM. Verified: 2150/2163 msgs show running."; - - -; ─── 0x44A (1098) — PSM3 [?] ──────────────────────────────────── -BO_ 1098 PSM3: 8 PSM - SG_ PSM3_B0 : 0|8@1+ (1,0) [0|255] "" PSM - SG_ PSM3_B1 : 8|8@1+ (1,0) [0|255] "" PSM - SG_ PSM3_B2 : 16|8@1+ (1,0) [0|255] "" PSM - SG_ PSM3_B3 : 24|8@1+ (1,0) [0|255] "" PSM - SG_ PSM3_B4 : 32|8@1+ (1,0) [0|255] "" PSM - SG_ PSM3_B5 : 40|8@1+ (1,0) [0|255] "" PSM - SG_ PSM3_B6 : 48|8@1+ (1,0) [0|255] "" PSM - SG_ PSM3_B7 : 56|8@1+ (1,0) [0|255] "" PSM - - -; ─── 0x44B (1099) — PSM4: Yaw, Accel, Brake Pressure [~] ──────── -BO_ 1099 PSM4: 8 PSM - SG_ PSM_BrakePressure : 0|8@1+ (1,0) [0|255] "Bar" PSM - SG_ Yaw_Rate : 16|9@1+ (0.0021326,0) [0|1.091] "rad/s" PSM - SG_ Yaw_Rate_Sign : 25|1@1+ (1,0) [0|1] "" PSM - SG_ Longitudinal_Accel : 56|8@1+ (0.015,-1.8) [-1.8|2.025] "g" PSM - - -; ─── 0x44C (1100) — PDK: Gearbox Status [?] ───────────────────── -BO_ 1100 PDK1: 8 PDK - SG_ PDK_SelectedGear : 0|3@1+ (1,0) [0|7] "" PDK - SG_ PDK_ShiftFork1 : 8|8@1+ (1,0) [0|255] "" PDK - SG_ PDK_ShiftFork2 : 16|8@1+ (1,0) [0|255] "" PDK - SG_ PDK_ClutchStatus : 24|8@1+ (1,0) [0|255] "" PDK - SG_ PDK_OilTemp : 32|8@1+ (0.75,-48) [-48|143.25] "°C" PDK - SG_ PDK_Counter : 48|4@1+ (1,0) [0|15] "" PDK - SG_ PDK_Checksum : 56|8@1+ (1,0) [0|255] "" PDK - - -; ─── 0x44F (1103) — PDK: Fault Flags [?] ──────────────────────── -; Always 0x0000 in this capture = no PDK faults. -BO_ 1103 PDK_Flags: 2 PDK - SG_ PDK_ErrorFlags : 0|16@1+ (1,0) [0|65535] "" PDK - -CM_ BO_ 1103 "PDK fault flags. All zero in this capture — gearbox healthy."; - - -; ─── 0x470 (1136) — DME: Torque Coordination [?] ───────────────── -BO_ 1136 DME_Torque: 8 DME - SG_ DME_Torque_Counter : 0|4@1+ (1,0) [0|15] "" DME - SG_ DME_Torque_Req1 : 8|8@1+ (0.39,0) [0|99.45] "%" DME - SG_ DME_Torque_Req2 : 16|8@1+ (0.39,0) [0|99.45] "%" DME - SG_ DME_Torque_Req3 : 24|8@1+ (0.39,0) [0|99.45] "%" DME - SG_ DME_Torque_Max : 32|8@1+ (0.39,0) [0|99.45] "%" DME - SG_ DME_Torque_Min : 40|8@1+ (0.39,0) [0|99.45] "%" DME - SG_ DME_Torque_Checksum : 56|8@1+ (1,0) [0|255] "" DME - - -; ─── 0x502 (1282) — CLUSTER1: Instrument Cluster [~] ───────────── -BO_ 1282 CLUSTER1: 8 DME - SG_ CLUSTER1_B0 : 0|8@1+ (1,0) [0|255] "" DME - SG_ CLUSTER1_B1 : 8|8@1+ (1,0) [0|255] "" DME - SG_ CLUSTER1_Flags : 16|5@1+ (1,0) [0|31] "" DME - SG_ CLUSTER_ClutchSW : 21|1@1+ (1,0) [0|1] "" DME - SG_ CLUSTER1_B2u : 22|2@1+ (1,0) [0|3] "" DME - SG_ CLUSTER_AmbBrightness : 24|8@1+ (0.3922,0) [0|100] "%" DME - SG_ CLUSTER1_B4 : 32|8@1+ (1,0) [0|255] "" DME - SG_ CLUSTER1_B5 : 40|8@1+ (1,0) [0|255] "" DME - SG_ CLUSTER1_B6 : 48|8@1+ (1,0) [0|255] "" DME - SG_ CLUSTER1_B7 : 56|8@1+ (1,0) [0|255] "" DME - - -; ─── 0x513 (1299) — Immobilizer Challenge [?] ──────────────────── -; One-shot, 5 bytes. Appears only at startup. -BO_ 1299 Immobilizer: 5 DME - SG_ Immo_Challenge : 0|40@1+ (1,0) [0|1099511627775] "" DME - - -; ─── 0x600 (1536) — KLIMA: Climate Control [~] ─────────────────── -BO_ 1536 KLIMA: 8 KLIMO - SG_ HVAC_Fan_Increase : 0|1@1+ (1,0) [0|1] "" KLIMO - SG_ HVAC_Display_On : 2|1@1+ (1,0) [0|1] "" KLIMO - SG_ KLIMA_CompressorReq : 3|1@1+ (1,0) [0|1] "" KLIMO - SG_ KLIMA_B1_Bits : 8|4@1+ (1,0) [0|15] "" KLIMO - SG_ KLIMA_RearDefrost : 12|1@1+ (1,0) [0|1] "" KLIMO - SG_ KLIMA_BlowerStage : 13|3@1+ (1,0) [0|7] "" KLIMO - SG_ KLIMA_RefrigPressure : 16|8@1+ (0.2,0) [0|51] "Bar" KLIMO - SG_ KLIMA_B3_Temp : 24|8@1+ (0.5,-50) [-50|77.5] "°C" KLIMO - SG_ KLIMA_BlowerSpeed : 32|8@1+ (1,0) [0|255] "" KLIMO - SG_ KLIMA_InsideTemp : 40|8@1+ (0.5,-50) [-50|77.5] "°C" KLIMO - SG_ KLIMA_B6_Temp : 48|8@1+ (0.5,-50) [-50|77.5] "°C" KLIMO - SG_ KLIMA_B7 : 56|8@1+ (1,0) [0|255] "" KLIMO - - -; ─── 0x62A / 0x66B (1578 / 1643) — ECU Identification [?] ─────── -; Both carry same constant payload: 41 41 08 42 55 67 10 93 -BO_ 1578 ECU_ID1: 8 DME - SG_ ECU_ID1_Bytes : 0|64@1+ (1,0) [0|1.84e19] "" DME - -BO_ 1583 ECU_Coding: 8 DME - SG_ ECU_Coding_Bytes : 0|64@1+ (1,0) [0|1.84e19] "" DME - -CM_ BO_ 1578 "ECU identification. Constant: 41 41 08 42 55 67 10 93. Appears at startup only."; -CM_ BO_ 1583 "ECU calibration/coding data. Constant: 59 8F 9E 02 23 00 80 C7."; - - -; ─── 0x669 (1641) — DME6: Odometer, Country Code [~] ───────────── -BO_ 1641 DME6: 8 DME - SG_ DME_Odometer : 0|20@1+ (1,0) [0|1048575] "km" DME - SG_ DME_CountryCode : 24|7@1+ (1,0) [0|127] "" DME - SG_ DME6_StatusBit : 32|1@1+ (1,0) [0|1] "" DME - SG_ DME6_Counter : 40|8@1+ (1,0) [0|255] "" DME - SG_ DME6_Data : 48|16@1+ (1,0) [0|65535] "" DME - - -; ─── 0x66B — same as 0x62A, see above ──────────────────────────── - - -; ─── 0x70B (1803) — Gateway: Module Config [?] ─────────────────── -BO_ 1803 Gateway_Cfg: 8 Gateway - SG_ GW_Config_Data : 0|64@1+ (1,0) [0|1.84e19] "" Gateway - -CM_ BO_ 1803 "Gateway module configuration. Constant during capture."; - - -; ─── 0x70D (1805) — Gateway: Network Config [?] ────────────────── -BO_ 1805 Gateway_Net: 8 Gateway - SG_ GW_Network_Data : 0|64@1+ (1,0) [0|1.84e19] "" Gateway - -CM_ BO_ 1805 "Gateway network configuration. Transitions 0x00→active during startup."; - - -; ─── 0x716 (1814) — DME8: Software Version [~] ─────────────────── -; Payload: 35 65 00 00 00 00 00 00 (ASCII "5e") -BO_ 1814 DME8_Version: 8 DME - SG_ DME_SW_Byte0 : 0|8@1+ (1,0) [0|255] "" DME - SG_ DME_SW_Byte1 : 8|8@1+ (1,0) [0|255] "" DME - SG_ DME_SW_Byte2 : 16|8@1+ (1,0) [0|255] "" DME - SG_ DME_SW_Byte3 : 24|8@1+ (1,0) [0|255] "" DME - SG_ DME_SW_Byte4 : 32|8@1+ (1,0) [0|255] "" DME - SG_ DME_SW_Byte5 : 40|8@1+ (1,0) [0|255] "" DME - SG_ DME_SW_Byte6 : 48|8@1+ (1,0) [0|255] "" DME - SG_ DME_SW_Byte7 : 56|8@1+ (1,0) [0|255] "" DME - -CM_ BO_ 1814 "Motronic software version. ASCII '5e' in bytes 0-1."; - - -; ─── 0x718 (1816) — PSM5 [?] ──────────────────────────────────── -BO_ 1816 PSM5: 8 PSM - SG_ PSM5_B0 : 0|8@1+ (1,0) [0|255] "" PSM - SG_ PSM5_B1 : 8|8@1+ (1,0) [0|255] "" PSM - SG_ PSM5_B2 : 16|8@1+ (1,0) [0|255] "" PSM - SG_ PSM5_B3 : 24|8@1+ (1,0) [0|255] "" PSM - SG_ PSM5_B4 : 32|8@1+ (1,0) [0|255] "" PSM - SG_ PSM5_B5 : 40|8@1+ (1,0) [0|255] "" PSM - SG_ PSM5_B6 : 48|8@1+ (1,0) [0|255] "" PSM - SG_ PSM5_B7 : 56|8@1+ (1,0) [0|255] "" PSM - - -; ─── 0x719 (1817) — Gateway: Wake/Sleep [?] ────────────────────── -; D4 transitions 0x0B → 0x5B during startup. -BO_ 1817 Gateway_State: 8 Gateway - SG_ GW_State_B0 : 0|8@1+ (1,0) [0|255] "" Gateway - SG_ GW_State_B1 : 8|8@1+ (1,0) [0|255] "" Gateway - SG_ GW_State_B2 : 16|8@1+ (1,0) [0|255] "" Gateway - SG_ GW_State_Change : 24|8@1+ (1,0) [0|255] "" Gateway - SG_ GW_State_B4_B7 : 32|32@1+ (1,0) [0|4294967295] "" Gateway - -CM_ BO_ 1817 "Gateway wake/sleep state. D4 changes 0x0B→0x5B at startup transition."; - - -; ─── 0x71A (1818) — SCCM3 [?] ──────────────────────────────────── -BO_ 1818 SCCM3: 8 SCCM - SG_ SCCM3_B0 : 0|8@1+ (1,0) [0|255] "" SCCM - SG_ SCCM3_B1 : 8|8@1+ (1,0) [0|255] "" SCCM - SG_ SCCM3_B2 : 16|8@1+ (1,0) [0|255] "" SCCM - SG_ SCCM3_B3 : 24|8@1+ (1,0) [0|255] "" SCCM - SG_ SCCM3_B4 : 32|8@1+ (1,0) [0|255] "" SCCM - SG_ SCCM3_B5 : 40|8@1+ (1,0) [0|255] "" SCCM - SG_ SCCM3_B6 : 48|8@1+ (1,0) [0|255] "" SCCM - SG_ SCCM3_B7 : 56|8@1+ (1,0) [0|255] "" SCCM - - -; ═══════════════════════════════════════════════════════════════════ -; Transmit cycle times (approximate, inferred from message counts) -; ═══════════════════════════════════════════════════════════════════ - -BA_DEF_ SG_ "GenSigSendType" ENUM "Cyclic","OnWrite","OnWriteWithRepetition","OnChange","OnChangeWithRepetition","IfActive","IfActiveWithRepetition","NoSigSendType"; -BA_DEF_ BO_ "GenMsgCycleTime" INT 0 65535; -BA_DEF_ BO_ "GenMsgSendType" ENUM "Cyclic","NotUsed","NotUsed","NotUsed","NotUsed","Cyclic","NotUsed","IfActive","NoMsgSendType"; -BA_DEF_ "DBName" STRING ; -BA_DEF_DEF_ "GenSigSendType" "Cyclic"; -BA_DEF_DEF_ "GenMsgCycleTime" 0; -BA_DEF_DEF_ "GenMsgSendType" "NoMsgSendType"; -BA_DEF_DEF_ "DBName" ""; - -BA_ "DBName" "Porsche_987_Cayman_Powertrain"; - -; ~10ms streaming -BA_ "GenMsgCycleTime" BO_ 320 10; -BA_ "GenMsgSendType" BO_ 320 0; -BA_ "GenMsgCycleTime" BO_ 578 10; -BA_ "GenMsgSendType" BO_ 578 0; -BA_ "GenMsgCycleTime" BO_ 581 10; -BA_ "GenMsgSendType" BO_ 581 0; -BA_ "GenMsgCycleTime" BO_ 582 10; -BA_ "GenMsgSendType" BO_ 582 0; -BA_ "GenMsgCycleTime" BO_ 771 10; -BA_ "GenMsgSendType" BO_ 771 0; -BA_ "GenMsgCycleTime" BO_ 1089 20; -BA_ "GenMsgSendType" BO_ 1089 0; -BA_ "GenMsgCycleTime" BO_ 1100 10; -BA_ "GenMsgSendType" BO_ 1100 0; -BA_ "GenMsgCycleTime" BO_ 1136 10; -BA_ "GenMsgSendType" BO_ 1136 0; - -; ~20ms -BA_ "GenMsgCycleTime" BO_ 194 10; -BA_ "GenMsgSendType" BO_ 194 0; -BA_ "GenMsgCycleTime" BO_ 330 20; -BA_ "GenMsgSendType" BO_ 330 0; -BA_ "GenMsgCycleTime" BO_ 586 20; -BA_ "GenMsgSendType" BO_ 586 0; -BA_ "GenMsgCycleTime" BO_ 1099 10; -BA_ "GenMsgSendType" BO_ 1099 0; -BA_ "GenMsgCycleTime" BO_ 528 20; -BA_ "GenMsgSendType" BO_ 528 0; - -; ~100-200ms -BA_ "GenMsgCycleTime" BO_ 357 200; -BA_ "GenMsgSendType" BO_ 357 0; -BA_ "GenMsgCycleTime" BO_ 776 100; -BA_ "GenMsgSendType" BO_ 776 0; -BA_ "GenMsgCycleTime" BO_ 799 100; -BA_ "GenMsgSendType" BO_ 799 0; -BA_ "GenMsgCycleTime" BO_ 1103 100; -BA_ "GenMsgSendType" BO_ 1103 0; -BA_ "GenMsgCycleTime" BO_ 1536 100; -BA_ "GenMsgSendType" BO_ 1536 0; - -; ~1s -BA_ "GenMsgCycleTime" BO_ 1282 1000; -BA_ "GenMsgSendType" BO_ 1282 0; - -; Startup-only (event-driven) -BA_ "GenMsgSendType" BO_ 1578 5; -BA_ "GenMsgSendType" BO_ 1583 5; -BA_ "GenMsgSendType" BO_ 1641 5; -BA_ "GenMsgSendType" BO_ 1814 5; -BA_ "GenMsgSendType" BO_ 1803 5; -BA_ "GenMsgSendType" BO_ 1805 5; -BA_ "GenMsgSendType" BO_ 1817 5; -BA_ "GenMsgSendType" BO_ 1299 5; diff --git a/p987/dbc/dbc.go b/p987/dbc/dbc.go deleted file mode 100644 index 8b478d78..00000000 --- a/p987/dbc/dbc.go +++ /dev/null @@ -1,186 +0,0 @@ -// Package dbc parses a subset of the DBC format and decodes CAN frames -// against it. -// -// gr26 describes its messages with mapache-go's Message/Field types, which -// model a frame as a sequence of whole-byte fields. That works for GR's own -// CAN, where the layout was designed alongside the decoder. It cannot -// describe stock Porsche CAN: signals there start at arbitrary bit offsets -// and run arbitrary bit lengths (SCCM_SteeringAngle is 13 bits starting at -// bit 2), and they carry a scale and offset. So p987 decodes from the DBC -// itself rather than from hand-written field lists. -package dbc - -import ( - "bufio" - "fmt" - "io" - "regexp" - "strconv" - "strings" -) - -// Signal is one decoded value within a message. -type Signal struct { - Name string - StartBit int - Length int - // LittleEndian is DBC byte order @1 (Intel). @0 is Motorola. - LittleEndian bool - Signed bool - Factor float64 - Offset float64 - Unit string - // Multiplexed marks a signal that is only present for a particular - // multiplexer value (the "m0" indicator). See Message.Multiplexed. - Multiplexed bool -} - -// Message is one CAN arbitration id and the signals it carries. -type Message struct { - ID uint32 - Name string - Length int - Signals []Signal -} - -// Database is a parsed DBC file, indexed by arbitration id. -type Database struct { - Messages map[uint32]*Message -} - -var ( - messageRe = regexp.MustCompile(`^BO_\s+(\d+)\s+([A-Za-z0-9_]+)\s*:\s*(\d+)\s+([A-Za-z0-9_]+)`) - // Signal name may be followed by a multiplexer indicator (M or m). - signalRe = regexp.MustCompile(`^\s*SG_\s+([A-Za-z0-9_]+)\s*(M|m\d+)?\s*:\s*(\d+)\|(\d+)@([01])([+-])\s*\(([^,]+),([^)]+)\)\s*\[([^|]*)\|([^\]]*)\]\s*"([^"]*)"`) -) - -// Parse reads a DBC file. Lines it does not recognize (BU_, CM_, VAL_, -// attribute definitions) are skipped: this decodes frames, it is not a -// general-purpose DBC editor. -func Parse(r io.Reader) (*Database, error) { - db := &Database{Messages: make(map[uint32]*Message)} - - scanner := bufio.NewScanner(r) - scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) - - var current *Message - line := 0 - for scanner.Scan() { - line++ - text := scanner.Text() - - if m := messageRe.FindStringSubmatch(text); m != nil { - id, err := strconv.ParseUint(m[1], 10, 32) - if err != nil { - return nil, fmt.Errorf("line %d: bad message id %q: %w", line, m[1], err) - } - length, err := strconv.Atoi(m[3]) - if err != nil { - return nil, fmt.Errorf("line %d: bad message length %q: %w", line, m[3], err) - } - msg := &Message{ID: uint32(id), Name: m[2], Length: length} - db.Messages[msg.ID] = msg - current = msg - continue - } - - // Must be the SG_ token itself, not a keyword that merely starts - // with it — the NS_ header block lists SG_MUL_VAL_. - if trimmed := strings.TrimSpace(text); isSignalLine(trimmed) { - if current == nil { - return nil, fmt.Errorf("line %d: signal outside any message", line) - } - sig, err := parseSignal(text) - if err != nil { - return nil, fmt.Errorf("line %d: %w", line, err) - } - current.Signals = append(current.Signals, sig) - continue - } - - // A blank line ends the current message block; anything else at - // column 0 starts a new section. - if strings.TrimSpace(text) == "" || !strings.HasPrefix(text, " ") { - current = nil - } - } - if err := scanner.Err(); err != nil { - return nil, err - } - if len(db.Messages) == 0 { - return nil, fmt.Errorf("no messages found") - } - return db, nil -} - -// isSignalLine reports whether a trimmed line opens with the SG_ keyword -// followed by a separator. -func isSignalLine(trimmed string) bool { - const kw = "SG_" - if !strings.HasPrefix(trimmed, kw) || len(trimmed) <= len(kw) { - return false - } - return trimmed[len(kw)] == ' ' || trimmed[len(kw)] == '\t' -} - -func parseSignal(text string) (Signal, error) { - m := signalRe.FindStringSubmatch(text) - if m == nil { - return Signal{}, fmt.Errorf("malformed signal: %q", strings.TrimSpace(text)) - } - - startBit, err := strconv.Atoi(m[3]) - if err != nil { - return Signal{}, fmt.Errorf("bad start bit %q: %w", m[3], err) - } - length, err := strconv.Atoi(m[4]) - if err != nil { - return Signal{}, fmt.Errorf("bad length %q: %w", m[4], err) - } - if length < 1 || length > 64 { - return Signal{}, fmt.Errorf("signal %s: length %d out of range", m[1], length) - } - factor, err := strconv.ParseFloat(strings.TrimSpace(m[7]), 64) - if err != nil { - return Signal{}, fmt.Errorf("bad factor %q: %w", m[7], err) - } - offset, err := strconv.ParseFloat(strings.TrimSpace(m[8]), 64) - if err != nil { - return Signal{}, fmt.Errorf("bad offset %q: %w", m[8], err) - } - - return Signal{ - Name: m[1], - StartBit: startBit, - Length: length, - LittleEndian: m[5] == "1", - Signed: m[6] == "-", - Factor: factor, - Offset: offset, - Unit: m[11], - Multiplexed: m[2] != "", - }, nil -} - -// SignalCount is the total number of signals across every message. -func (d *Database) SignalCount() int { - n := 0 - for _, m := range d.Messages { - n += len(m.Signals) - } - return n -} - -// MultiplexedCount is the number of signals skipped at decode time -// because they carry a multiplexer indicator. -func (d *Database) MultiplexedCount() int { - n := 0 - for _, m := range d.Messages { - for _, s := range m.Signals { - if s.Multiplexed { - n++ - } - } - } - return n -} diff --git a/p987/dbc/decode.go b/p987/dbc/decode.go deleted file mode 100644 index 5545f29e..00000000 --- a/p987/dbc/decode.go +++ /dev/null @@ -1,129 +0,0 @@ -package dbc - -import ( - _ "embed" - "strings" - "sync" -) - -//go:embed cayman_987.dbc -var caymanDBC string - -var ( - loadOnce sync.Once - loaded *Database - loadErr error -) - -// Cayman987 returns the embedded 987 database, parsed once. Embedding -// rather than reading a path keeps the image self-contained and makes a -// malformed DBC a startup failure instead of a runtime surprise. -func Cayman987() (*Database, error) { - loadOnce.Do(func() { - loaded, loadErr = Parse(strings.NewReader(caymanDBC)) - }) - return loaded, loadErr -} - -// Decoded is one signal decoded out of a frame. -type Decoded struct { - Name string - Value float64 - Raw int64 - Unit string -} - -// Decode extracts every non-multiplexed signal in msg from data. -// -// Multiplexed signals are skipped. Resolving them requires the message's -// multiplexer switch signal (the "M" indicator), and the 987 DBC declares -// multiplexed signals without ever declaring the switch — so there is no -// correct way to know which variant a given frame carries. Decoding them -// anyway would emit three wrong values for every right one. -// -// Signals that extend past the end of the frame are skipped rather than -// zero-filled: a short frame means the data is not what the DBC describes, -// and a plausible-looking zero is worse than a missing signal. -func (m *Message) Decode(data []byte) []Decoded { - out := make([]Decoded, 0, len(m.Signals)) - for _, s := range m.Signals { - if s.Multiplexed { - continue - } - raw, ok := s.extract(data) - if !ok { - continue - } - out = append(out, Decoded{ - Name: s.Name, - Value: float64(raw)*s.Factor + s.Offset, - Raw: raw, - Unit: s.Unit, - }) - } - return out -} - -// extract pulls the signal's raw integer out of the frame, applying sign -// extension. ok is false when the signal does not fit in the data. -func (s Signal) extract(data []byte) (int64, bool) { - var bits uint64 - if s.LittleEndian { - var ok bool - bits, ok = extractLittleEndian(data, s.StartBit, s.Length) - if !ok { - return 0, false - } - } else { - var ok bool - bits, ok = extractBigEndian(data, s.StartBit, s.Length) - if !ok { - return 0, false - } - } - - if s.Signed && s.Length < 64 && bits&(1<<(s.Length-1)) != 0 { - bits |= ^uint64(0) << s.Length - } - return int64(bits), true -} - -// extractLittleEndian reads Intel byte order: start bit is the signal's -// least significant bit, and bit numbering runs LSB-first within each byte -// and then upward through the bytes. -func extractLittleEndian(data []byte, startBit, length int) (uint64, bool) { - if startBit < 0 || length < 1 || startBit+length > len(data)*8 { - return 0, false - } - var v uint64 - for i := 0; i < length; i++ { - bit := startBit + i - if data[bit/8]>>(bit%8)&1 == 1 { - v |= 1 << i - } - } - return v, true -} - -// extractBigEndian reads Motorola byte order: start bit is the signal's -// most significant bit, and consecutive bits walk downward within a byte -// then continue at the top of the next byte. -func extractBigEndian(data []byte, startBit, length int) (uint64, bool) { - if startBit < 0 || length < 1 || startBit >= len(data)*8 { - return 0, false - } - var v uint64 - bit := startBit - for i := 0; i < length; i++ { - if bit/8 >= len(data) || bit < 0 { - return 0, false - } - v = v<<1 | uint64(data[bit/8]>>(bit%8)&1) - if bit%8 == 0 { - bit += 15 // down to the next byte, back up to its MSB - } else { - bit-- - } - } - return v, true -} diff --git a/p987/main.go b/p987/main.go index f6040f6d..765998d6 100644 --- a/p987/main.go +++ b/p987/main.go @@ -23,13 +23,6 @@ func main() { database.Init() } - // Parse the DBC before subscribing so the first frame doesn't race the - // load, and so a malformed file fails at startup rather than silently - // decoding nothing. - if err := service.InitDecoder(); err != nil { - logger.SugarLogger.Fatalf("Failed to load DBC: %v", err) - } - mqtt.SetMessageHandler(service.HandleInboundMessage) if err := mqtt.Init(context.Background()); err != nil { logger.SugarLogger.Fatalf("Failed to initialize MQTT: %v", err) diff --git a/p987/model/dme.go b/p987/model/dme.go new file mode 100644 index 00000000..2a7d025c --- /dev/null +++ b/p987/model/dme.go @@ -0,0 +1,493 @@ +package model + +import mp "github.com/gaucho-racing/mapache/mapache-go/v3" + +// Heartbeat is 0x140 from DME. +var Heartbeat = mp.Message{ + mp.NewField("hb_page", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("HB_Page", f.Value, 0, 8, false, 1, 0), + } + }), + mp.NewField("hb_sourceid", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("HB_SourceID", f.Value, 0, 8, false, 1, 0), + } + }), + mp.NewField("hb_counter", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("HB_Counter", f.Value, 0, 4, false, 1, 0), + } + }), + mp.NewField("hb_checksum", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("HB_Checksum", f.Value, 0, 8, false, 1, 0), + } + }), +} + +// DME1 is 0x242 from DME. +var DME1 = mp.Message{ + mp.NewField("dme1_counter", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("DME1_Counter", f.Value, 0, 4, false, 1, 0), + } + }), + mp.NewField("dme_enginetorque", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("DME_EngineTorque", f.Value, 0, 8, false, 0.39, 0), // % + } + }), + mp.NewField("dme_rpm", 2, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("DME_RPM", f.Value, 0, 16, false, 0.25, 0), // rpm + } + }), + mp.NewField("dme_interventions", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("DME_Interventions", f.Value, 0, 8, false, 0.39, 0), // % + } + }), + mp.NewField("dme_app", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("DME_APP", f.Value, 0, 8, false, 0.39215, 0), // % + } + }), + mp.NewField("dme_torqueloss", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("DME_TorqueLoss", f.Value, 0, 8, false, 0.39, 0), // % + } + }), + mp.NewField("dme_drivertrq", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("DME_DriverTrq", f.Value, 0, 8, false, 0.781, 0), // % + } + }), +} + +// DME2 is 0x245 from DME. +// +// The DBC declares multiplexed signals here without ever declaring the +// multiplexer switch, so there is no way to tell which variant a frame +// carries. Not decoded: DME2_B2_B3, DME2_MUL_Code. +var DME2 = mp.Message{ + mp.NewField("dme2_mux", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("DME2_MUX", f.Value, 6, 2, false, 1, 0), + } + }), + mp.NewField("dme_coolanttemp", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("DME_CoolantTemp", f.Value, 0, 8, false, 0.75, -48), // °C + } + }), + mp.NewField("_reserved", 2, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return nil + }), + mp.NewField("dme_idlespeedtarget", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("DME_IdleSpeedTarget", f.Value, 0, 8, false, 10, 0), // rpm + } + }), + mp.NewField("dme_momentbase", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("DME_MomentBase", f.Value, 0, 8, false, 0.39, 0), // % + } + }), + mp.NewField("dme2_counter", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("DME2_Counter", f.Value, 0, 4, false, 1, 0), + } + }), + mp.NewField("dme2_torqueindexed", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("DME2_TorqueIndexed", f.Value, 0, 8, false, 0.39215, 0), // % + } + }), +} + +// DME3 is 0x246 from DME. +// +// The DBC declares multiplexed signals here without ever declaring the +// multiplexer switch, so there is no way to tell which variant a frame +// carries. Not decoded: DME3_Muxed. +var DME3 = mp.Message{ + mp.NewField("bytes_0_0", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("DME_EngagedGear", f.Value, 0, 3, false, 1, 0), + flag("DME_KickdownActive", f.Value, 3), + flag("DME_CompressorRunning", f.Value, 4), + flag("DME_CompressorFault", f.Value, 5), + flag("Sport_Mode_Error", f.Value, 6), + flag("Ambient_Pressure_Error", f.Value, 7), + } + }), + mp.NewField("bytes_1_1", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("DME_GearRequirement", f.Value, 0, 3, false, 1, 0), + sig("DME_GearRequest", f.Value, 3, 3, false, 1, 0), + flag("DME_TRQ_Target_Error", f.Value, 6), + flag("DME_Overboost", f.Value, 7), + } + }), + mp.NewField("dme_gb_targettrq", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("DME_GB_TargetTrq", f.Value, 0, 8, false, 0.39, 0), // % + } + }), + mp.NewField("dme_accelpedalangle", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("DME_AccelPedalAngle", f.Value, 0, 8, false, 0.4, 0), // % + } + }), + mp.NewField("dme_gb_trqactual", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("DME_GB_TrqActual", f.Value, 0, 8, false, 0.39, 0), // % + } + }), + mp.NewField("dme_ambientpressure", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("DME_AmbientPressure", f.Value, 0, 8, false, 5, 0), // mbar + } + }), + mp.NewField("dme3_counter", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("DME3_Counter", f.Value, 0, 4, false, 1, 0), + } + }), + mp.NewField("_reserved", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return nil + }), +} + +// DME_Lambda is 0x303 from DME. +// Lambda / AFR / intake data. 7-byte DLC unique to 987. Signal layout is approximate — verify with wideband O2. +var DME_Lambda = mp.Message{ + mp.NewField("dme_lambda_counter", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("DME_Lambda_Counter", f.Value, 0, 4, false, 1, 0), + } + }), + mp.NewField("_reserved", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return nil + }), + mp.NewField("dme_lambda_value", 2, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("DME_Lambda_Value", f.Value, 0, 16, false, 0.0001, 0), + } + }), + mp.NewField("dme_lambda_status", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("DME_Lambda_Status", f.Value, 0, 8, false, 1, 0), + } + }), + mp.NewField("dme_lambda_checksum", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("DME_Lambda_Checksum", f.Value, 0, 8, false, 1, 0), + } + }), + mp.NewField("dme_lambda_flags", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("DME_Lambda_Flags", f.Value, 0, 8, false, 1, 0), + } + }), +} + +// DRIVEMODE is 0x308 from DME. +var DRIVEMODE = mp.Message{ + mp.NewField("bytes_0_1", 2, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("LT_Rad_Fan_PWM", f.Value, 0, 2, false, 1, 0), + sig("RT_Rad_Fan_PWM", f.Value, 2, 2, false, 1, 0), + flag("Trunk_Lid_Open", f.Value, 4), + flag("Sport_Mode", f.Value, 5), + flag("Wiper_Status", f.Value, 6), + sig("Radio_Key", f.Value, 7, 4, false, 1, 0), + flag("Low_Beam", f.Value, 11), + flag("Reverse_Light", f.Value, 12), + flag("High_Beam", f.Value, 14), + } + }), + mp.NewField("_reserved", 2, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return nil + }), + mp.NewField("outside_temp", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("Outside_Temp", f.Value, 0, 8, false, 0.5, -50), // °C + } + }), + mp.NewField("_reserved", 3, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return nil + }), +} + +// DME_Status is 0x31F from DME. +// Engine running status broadcast. Constant data during idle — likely ECU health + counters. +var DME_Status = mp.Message{ + mp.NewField("dme_status_id", 2, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("DME_Status_ID", f.Value, 0, 16, false, 1, 0), + } + }), + mp.NewField("dme_status_flags1", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("DME_Status_Flags1", f.Value, 0, 8, false, 1, 0), + } + }), + mp.NewField("dme_status_flags2", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("DME_Status_Flags2", f.Value, 0, 8, false, 1, 0), + } + }), + mp.NewField("dme_status_reserved", 2, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("DME_Status_Reserved", f.Value, 0, 16, false, 1, 0), + } + }), + mp.NewField("dme_status_counter", 2, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("DME_Status_Counter", f.Value, 0, 16, false, 1, 0), + } + }), +} + +// DME4 is 0x441 from DME. +var DME4 = mp.Message{ + mp.NewField("bytes_0_0", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + flag("DME_CEL_Flashing", f.Value, 0), + flag("DME_CEL_Steady", f.Value, 1), + flag("DME_FuelReserve", f.Value, 2), + flag("DME_ReducedPower", f.Value, 3), + flag("DME_EngCompFanAlert", f.Value, 4), + flag("DME_OilTempSensFault", f.Value, 5), + flag("DME_OilPressureAlert", f.Value, 6), + flag("DME_ChargingAlert", f.Value, 7), + } + }), + mp.NewField("bytes_1_1", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("DME_RadFanSpeedReq", f.Value, 0, 7, false, 1, 0), // % + flag("DME_EngineRunning", f.Value, 7), + } + }), + mp.NewField("dme_fuelconsumption1", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("DME_FuelConsumption1", f.Value, 0, 8, false, 1, 0), // µl + } + }), + mp.NewField("dme_fuelconsumption2", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("DME_FuelConsumption2", f.Value, 0, 8, false, 1, 0), // µl + } + }), + mp.NewField("dme_boostpressure", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("DME_BoostPressure", f.Value, 0, 8, false, 0.01, 0), // bar + } + }), + mp.NewField("dme_oiltemp", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("DME_OilTemp", f.Value, 0, 8, false, 0.75, -48), // °C + } + }), + mp.NewField("dme_oilpressure", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("DME_OilPressure", f.Value, 0, 8, false, 0.04, 0), // bar + } + }), + mp.NewField("bytes_7_7", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + flag("DME_CoolantLevelSW", f.Value, 0), + sig("DME_EngineCompTemp", f.Value, 1, 6, true, 1, -48), // °C + flag("DME_EngCompTempFail", f.Value, 7), + } + }), +} + +// DME_Torque is 0x470 from DME. +var DME_Torque = mp.Message{ + mp.NewField("dme_torque_counter", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("DME_Torque_Counter", f.Value, 0, 4, false, 1, 0), + } + }), + mp.NewField("dme_torque_req1", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("DME_Torque_Req1", f.Value, 0, 8, false, 0.39, 0), // % + } + }), + mp.NewField("dme_torque_req2", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("DME_Torque_Req2", f.Value, 0, 8, false, 0.39, 0), // % + } + }), + mp.NewField("dme_torque_req3", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("DME_Torque_Req3", f.Value, 0, 8, false, 0.39, 0), // % + } + }), + mp.NewField("dme_torque_max", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("DME_Torque_Max", f.Value, 0, 8, false, 0.39, 0), // % + } + }), + mp.NewField("dme_torque_min", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("DME_Torque_Min", f.Value, 0, 8, false, 0.39, 0), // % + } + }), + mp.NewField("_reserved", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return nil + }), + mp.NewField("dme_torque_checksum", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("DME_Torque_Checksum", f.Value, 0, 8, false, 1, 0), + } + }), +} + +// CLUSTER1 is 0x502 from DME. +var CLUSTER1 = mp.Message{ + mp.NewField("cluster1_b0", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("CLUSTER1_B0", f.Value, 0, 8, false, 1, 0), + } + }), + mp.NewField("cluster1_b1", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("CLUSTER1_B1", f.Value, 0, 8, false, 1, 0), + } + }), + mp.NewField("bytes_2_2", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("CLUSTER1_Flags", f.Value, 0, 5, false, 1, 0), + flag("CLUSTER_ClutchSW", f.Value, 5), + sig("CLUSTER1_B2u", f.Value, 6, 2, false, 1, 0), + } + }), + mp.NewField("cluster_ambbrightness", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("CLUSTER_AmbBrightness", f.Value, 0, 8, false, 0.3922, 0), // % + } + }), + mp.NewField("cluster1_b4", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("CLUSTER1_B4", f.Value, 0, 8, false, 1, 0), + } + }), + mp.NewField("cluster1_b5", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("CLUSTER1_B5", f.Value, 0, 8, false, 1, 0), + } + }), + mp.NewField("cluster1_b6", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("CLUSTER1_B6", f.Value, 0, 8, false, 1, 0), + } + }), + mp.NewField("cluster1_b7", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("CLUSTER1_B7", f.Value, 0, 8, false, 1, 0), + } + }), +} + +// Immobilizer is 0x513 from DME. +var Immobilizer = mp.Message{ + mp.NewField("immo_challenge", 5, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("Immo_Challenge", f.Value, 0, 40, false, 1, 0), + } + }), +} + +// ECU_ID1 is 0x62A from DME. +// ECU identification. Constant: 41 41 08 42 55 67 10 93. Appears at startup only. +var ECU_ID1 = mp.Message{ + mp.NewField("ecu_id1_bytes", 8, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { // ECU_ID1_Bytes: opaque 64-bit blob, kept in the raw frame + return nil + }), +} + +// ECU_Coding is 0x62F from DME. +// ECU calibration/coding data. Constant: 59 8F 9E 02 23 00 80 C7. +var ECU_Coding = mp.Message{ + mp.NewField("ecu_coding_bytes", 8, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { // ECU_Coding_Bytes: opaque 64-bit blob, kept in the raw frame + return nil + }), +} + +// DME6 is 0x669 from DME. +var DME6 = mp.Message{ + mp.NewField("dme_odometer", 3, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("DME_Odometer", f.Value, 0, 20, false, 1, 0), // km + } + }), + mp.NewField("dme_countrycode", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("DME_CountryCode", f.Value, 0, 7, false, 1, 0), + } + }), + mp.NewField("dme6_statusbit", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + flag("DME6_StatusBit", f.Value, 0), + } + }), + mp.NewField("dme6_counter", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("DME6_Counter", f.Value, 0, 8, false, 1, 0), + } + }), + mp.NewField("dme6_data", 2, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("DME6_Data", f.Value, 0, 16, false, 1, 0), + } + }), +} + +// DME8_Version is 0x716 from DME. +// Motronic software version. ASCII '5e' in bytes 0-1. +var DME8_Version = mp.Message{ + mp.NewField("dme_sw_byte0", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("DME_SW_Byte0", f.Value, 0, 8, false, 1, 0), + } + }), + mp.NewField("dme_sw_byte1", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("DME_SW_Byte1", f.Value, 0, 8, false, 1, 0), + } + }), + mp.NewField("dme_sw_byte2", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("DME_SW_Byte2", f.Value, 0, 8, false, 1, 0), + } + }), + mp.NewField("dme_sw_byte3", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("DME_SW_Byte3", f.Value, 0, 8, false, 1, 0), + } + }), + mp.NewField("dme_sw_byte4", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("DME_SW_Byte4", f.Value, 0, 8, false, 1, 0), + } + }), + mp.NewField("dme_sw_byte5", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("DME_SW_Byte5", f.Value, 0, 8, false, 1, 0), + } + }), + mp.NewField("dme_sw_byte6", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("DME_SW_Byte6", f.Value, 0, 8, false, 1, 0), + } + }), + mp.NewField("dme_sw_byte7", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("DME_SW_Byte7", f.Value, 0, 8, false, 1, 0), + } + }), +} diff --git a/p987/model/gateway.go b/p987/model/gateway.go new file mode 100644 index 00000000..8bf00284 --- /dev/null +++ b/p987/model/gateway.go @@ -0,0 +1,49 @@ +package model + +import mp "github.com/gaucho-racing/mapache/mapache-go/v3" + +// Gateway_Cfg is 0x70B from Gateway. +// Gateway module configuration. Constant during capture. +var Gateway_Cfg = mp.Message{ + mp.NewField("gw_config_data", 8, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { // GW_Config_Data: opaque 64-bit blob, kept in the raw frame + return nil + }), +} + +// Gateway_Net is 0x70D from Gateway. +// Gateway network configuration. Transitions 0x00→active during startup. +var Gateway_Net = mp.Message{ + mp.NewField("gw_network_data", 8, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { // GW_Network_Data: opaque 64-bit blob, kept in the raw frame + return nil + }), +} + +// Gateway_State is 0x719 from Gateway. +// Gateway wake/sleep state. D4 changes 0x0B→0x5B at startup transition. +var Gateway_State = mp.Message{ + mp.NewField("gw_state_b0", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("GW_State_B0", f.Value, 0, 8, false, 1, 0), + } + }), + mp.NewField("gw_state_b1", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("GW_State_B1", f.Value, 0, 8, false, 1, 0), + } + }), + mp.NewField("gw_state_b2", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("GW_State_B2", f.Value, 0, 8, false, 1, 0), + } + }), + mp.NewField("gw_state_change", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("GW_State_Change", f.Value, 0, 8, false, 1, 0), + } + }), + mp.NewField("gw_state_b4_b7", 4, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("GW_State_B4_B7", f.Value, 0, 32, false, 1, 0), + } + }), +} diff --git a/p987/model/klima.go b/p987/model/klima.go new file mode 100644 index 00000000..6429ebdc --- /dev/null +++ b/p987/model/klima.go @@ -0,0 +1,51 @@ +package model + +import mp "github.com/gaucho-racing/mapache/mapache-go/v3" + +// KLIMA is 0x600 from KLIMO. +var KLIMA = mp.Message{ + mp.NewField("bytes_0_0", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + flag("HVAC_Fan_Increase", f.Value, 0), + flag("HVAC_Display_On", f.Value, 2), + flag("KLIMA_CompressorReq", f.Value, 3), + } + }), + mp.NewField("bytes_1_1", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("KLIMA_B1_Bits", f.Value, 0, 4, false, 1, 0), + flag("KLIMA_RearDefrost", f.Value, 4), + sig("KLIMA_BlowerStage", f.Value, 5, 3, false, 1, 0), + } + }), + mp.NewField("klima_refrigpressure", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("KLIMA_RefrigPressure", f.Value, 0, 8, false, 0.2, 0), // Bar + } + }), + mp.NewField("klima_b3_temp", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("KLIMA_B3_Temp", f.Value, 0, 8, false, 0.5, -50), // °C + } + }), + mp.NewField("klima_blowerspeed", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("KLIMA_BlowerSpeed", f.Value, 0, 8, false, 1, 0), + } + }), + mp.NewField("klima_insidetemp", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("KLIMA_InsideTemp", f.Value, 0, 8, false, 0.5, -50), // °C + } + }), + mp.NewField("klima_b6_temp", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("KLIMA_B6_Temp", f.Value, 0, 8, false, 0.5, -50), // °C + } + }), + mp.NewField("klima_b7", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("KLIMA_B7", f.Value, 0, 8, false, 1, 0), + } + }), +} diff --git a/p987/model/message.go b/p987/model/message.go new file mode 100644 index 00000000..7c949caa --- /dev/null +++ b/p987/model/message.go @@ -0,0 +1,75 @@ +package model + +import mp "github.com/gaucho-racing/mapache/mapache-go/v3" + +// messageMap is the vehicle CAN decoder registry, transcribed from +// cayman_987.dbc in the TCM-987 repo. Ids not listed here are still +// persisted as raw frames. +var messageMap = map[int]mp.Message{ + // DME + 0x140: Heartbeat, + 0x242: DME1, + 0x245: DME2, + 0x246: DME3, + 0x303: DME_Lambda, + 0x308: DRIVEMODE, + 0x31F: DME_Status, + 0x441: DME4, + 0x470: DME_Torque, + 0x502: CLUSTER1, + 0x513: Immobilizer, + 0x62A: ECU_ID1, + 0x62F: ECU_Coding, + 0x669: DME6, + 0x716: DME8_Version, + // Gateway + 0x70B: Gateway_Cfg, + 0x70D: Gateway_Net, + 0x719: Gateway_State, + // KLIMO + 0x600: KLIMA, + // PAS + 0x165: PAS, + // PDK + 0x44C: PDK1, + 0x44F: PDK_Flags, + // PSM + 0x14A: PSM1, + 0x24A: PSM2, + 0x44A: PSM3, + 0x44B: PSM4, + 0x718: PSM5, + // SCCM + 0x0C2: SCCM1, + 0x210: SCCM2, + 0x71A: SCCM3, +} + +// tcmMessageMap holds the TCM's synthetic frames, which are only valid on +// the "tcm" bus. The split matters: shelter injects its 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. +var tcmMessageMap = map[int]mp.Message{ + MsgIDTCMStatus: TCMStatus, + MsgIDTCMResources: TCMResourceUtil, +} + +// BusTCM is the label the relay publishes under for frames that never +// touched a physical CAN bus. +const BusTCM = "tcm" + +// GetMessage returns the decoder for an id on a given bus, or nil when +// nothing is registered for it. +func GetMessage(bus string, id int) mp.Message { + if bus == BusTCM { + if msg, ok := tcmMessageMap[id]; ok { + return msg + } + return nil + } + if msg, ok := messageMap[id]; ok { + return msg + } + return nil +} diff --git a/p987/model/pas.go b/p987/model/pas.go new file mode 100644 index 00000000..8d0dfdbb --- /dev/null +++ b/p987/model/pas.go @@ -0,0 +1,13 @@ +package model + +import mp "github.com/gaucho-racing/mapache/mapache-go/v3" + +// PAS is 0x165 from PAS. +var PAS = mp.Message{ + mp.NewField("bytes_0_0", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + flag("PAS_KeyPresent", f.Value, 0), + sig("PAS_KeyPosition", f.Value, 2, 2, false, 1, 0), + } + }), +} diff --git a/p987/model/pdk.go b/p987/model/pdk.go new file mode 100644 index 00000000..05c098e5 --- /dev/null +++ b/p987/model/pdk.go @@ -0,0 +1,55 @@ +package model + +import mp "github.com/gaucho-racing/mapache/mapache-go/v3" + +// PDK1 is 0x44C from PDK. +var PDK1 = mp.Message{ + mp.NewField("pdk_selectedgear", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("PDK_SelectedGear", f.Value, 0, 3, false, 1, 0), + } + }), + mp.NewField("pdk_shiftfork1", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("PDK_ShiftFork1", f.Value, 0, 8, false, 1, 0), + } + }), + mp.NewField("pdk_shiftfork2", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("PDK_ShiftFork2", f.Value, 0, 8, false, 1, 0), + } + }), + mp.NewField("pdk_clutchstatus", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("PDK_ClutchStatus", f.Value, 0, 8, false, 1, 0), + } + }), + mp.NewField("pdk_oiltemp", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("PDK_OilTemp", f.Value, 0, 8, false, 0.75, -48), // °C + } + }), + mp.NewField("_reserved", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return nil + }), + mp.NewField("pdk_counter", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("PDK_Counter", f.Value, 0, 4, false, 1, 0), + } + }), + mp.NewField("pdk_checksum", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("PDK_Checksum", f.Value, 0, 8, false, 1, 0), + } + }), +} + +// PDK_Flags is 0x44F from PDK. +// PDK fault flags. All zero in this capture — gearbox healthy. +var PDK_Flags = mp.Message{ + mp.NewField("pdk_errorflags", 2, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("PDK_ErrorFlags", f.Value, 0, 16, false, 1, 0), + } + }), +} diff --git a/p987/model/psm.go b/p987/model/psm.go new file mode 100644 index 00000000..80716437 --- /dev/null +++ b/p987/model/psm.go @@ -0,0 +1,194 @@ +package model + +import mp "github.com/gaucho-racing/mapache/mapache-go/v3" + +// PSM1 is 0x14A from PSM. +var PSM1 = mp.Message{ + mp.NewField("bytes_0_0", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + flag("ASR_Requirement", f.Value, 0), + flag("MSR_Requirement", f.Value, 1), + flag("ABS_Status", f.Value, 2), + flag("Brake_Intervention", f.Value, 3), + flag("ESP_Intervention", f.Value, 4), + sig("ASR_Switching", f.Value, 5, 2, false, 1, 0), + flag("ESP_Control", f.Value, 7), + } + }), + mp.NewField("bytes_1_1", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + flag("ABS_Error", f.Value, 0), + flag("ESP_Error", f.Value, 1), + flag("EBV_Error", f.Value, 2), + flag("PSM_FootBrake", f.Value, 3), + flag("PSM_FootBrake2", f.Value, 4), + flag("PSM_Disabled", f.Value, 5), + flag("Brake_Fluid_Switch", f.Value, 6), + flag("PSM_HandBrake", f.Value, 7), + } + }), + mp.NewField("bytes_2_3", 2, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + flag("ESP_Diag_Mode", f.Value, 0), + sig("Vref", f.Value, 0, 16, false, 0.01, 0), // km/h + } + }), + mp.NewField("psm_torquereqslow", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("PSM_TorqueReqSlow", f.Value, 0, 8, false, 1, 0), + } + }), + mp.NewField("psm_torquereqfast", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("PSM_TorqueReqFast", f.Value, 0, 8, false, 1, 0), + } + }), + mp.NewField("engagement_torque", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("Engagement_Torque", f.Value, 0, 8, false, 0.39, 0), // % + } + }), + mp.NewField("psm_lateralaccel", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("PSM_LateralAccel", f.Value, 0, 8, false, 1, 0), + } + }), +} + +// PSM2 is 0x24A from PSM. +var PSM2 = mp.Message{ + mp.NewField("psm_wheelspeedfl", 2, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("PSM_WheelSpeedFL", f.Value, 0, 16, false, 0.01, 0), // km/h + } + }), + mp.NewField("psm_wheelspeedfr", 2, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("PSM_WheelSpeedFR", f.Value, 0, 16, false, 0.01, 0), // km/h + } + }), + mp.NewField("psm_wheelspeedrl", 2, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("PSM_WheelSpeedRL", f.Value, 0, 16, false, 0.01, 0), // km/h + } + }), + mp.NewField("psm_wheelspeedrr", 2, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("PSM_WheelSpeedRR", f.Value, 0, 16, false, 0.01, 0), // km/h + } + }), +} + +// PSM3 is 0x44A from PSM. +var PSM3 = mp.Message{ + mp.NewField("psm3_b0", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("PSM3_B0", f.Value, 0, 8, false, 1, 0), + } + }), + mp.NewField("psm3_b1", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("PSM3_B1", f.Value, 0, 8, false, 1, 0), + } + }), + mp.NewField("psm3_b2", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("PSM3_B2", f.Value, 0, 8, false, 1, 0), + } + }), + mp.NewField("psm3_b3", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("PSM3_B3", f.Value, 0, 8, false, 1, 0), + } + }), + mp.NewField("psm3_b4", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("PSM3_B4", f.Value, 0, 8, false, 1, 0), + } + }), + mp.NewField("psm3_b5", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("PSM3_B5", f.Value, 0, 8, false, 1, 0), + } + }), + mp.NewField("psm3_b6", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("PSM3_B6", f.Value, 0, 8, false, 1, 0), + } + }), + mp.NewField("psm3_b7", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("PSM3_B7", f.Value, 0, 8, false, 1, 0), + } + }), +} + +// PSM4 is 0x44B from PSM. +var PSM4 = mp.Message{ + mp.NewField("psm_brakepressure", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("PSM_BrakePressure", f.Value, 0, 8, false, 1, 0), // Bar + } + }), + mp.NewField("_reserved", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return nil + }), + mp.NewField("bytes_2_3", 2, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("Yaw_Rate", f.Value, 0, 9, false, 0.0021326, 0), // rad/s + flag("Yaw_Rate_Sign", f.Value, 9), + } + }), + mp.NewField("_reserved", 3, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return nil + }), + mp.NewField("longitudinal_accel", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("Longitudinal_Accel", f.Value, 0, 8, false, 0.015, -1.8), // g + } + }), +} + +// PSM5 is 0x718 from PSM. +var PSM5 = mp.Message{ + mp.NewField("psm5_b0", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("PSM5_B0", f.Value, 0, 8, false, 1, 0), + } + }), + mp.NewField("psm5_b1", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("PSM5_B1", f.Value, 0, 8, false, 1, 0), + } + }), + mp.NewField("psm5_b2", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("PSM5_B2", f.Value, 0, 8, false, 1, 0), + } + }), + mp.NewField("psm5_b3", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("PSM5_B3", f.Value, 0, 8, false, 1, 0), + } + }), + mp.NewField("psm5_b4", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("PSM5_B4", f.Value, 0, 8, false, 1, 0), + } + }), + mp.NewField("psm5_b5", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("PSM5_B5", f.Value, 0, 8, false, 1, 0), + } + }), + mp.NewField("psm5_b6", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("PSM5_B6", f.Value, 0, 8, false, 1, 0), + } + }), + mp.NewField("psm5_b7", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("PSM5_B7", f.Value, 0, 8, false, 1, 0), + } + }), +} diff --git a/p987/model/sccm.go b/p987/model/sccm.go new file mode 100644 index 00000000..7a757f9c --- /dev/null +++ b/p987/model/sccm.go @@ -0,0 +1,110 @@ +package model + +import mp "github.com/gaucho-racing/mapache/mapache-go/v3" + +// SCCM1 is 0xC2 from SCCM. +var SCCM1 = mp.Message{ + mp.NewField("bytes_0_1", 2, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("SCCM_SteeringAngle", f.Value, 2, 13, false, 0.175, 0), // deg + flag("SCCM_SteeringAngleSign", f.Value, 15), + } + }), + mp.NewField("bytes_2_3", 2, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("SCCM_SteeringAngleRate", f.Value, 2, 13, false, 0.175, 0), // deg/sec + flag("SCCM_SteeringAngleRateSign", f.Value, 15), + } + }), + mp.NewField("sccm_sensorid", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("SCCM_SensorID", f.Value, 0, 8, false, 1, 0), + } + }), + mp.NewField("sccm_counter", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("SCCM_Counter", f.Value, 4, 4, false, 1, 0), + } + }), + mp.NewField("_reserved", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return nil + }), + mp.NewField("sccm_checksum", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("SCCM_Checksum", f.Value, 0, 8, false, 1, 0), + } + }), +} + +// SCCM2 is 0x210 from SCCM. +var SCCM2 = mp.Message{ + mp.NewField("sccm_cruisecount1", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("SCCM_CruiseCount1", f.Value, 4, 4, false, 1, 0), + } + }), + mp.NewField("bytes_1_1", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + flag("SCCM_CruiseEnable", f.Value, 0), + flag("SCCM_CruiseDown", f.Value, 1), + flag("SCCM_CruiseTowards", f.Value, 2), + flag("SCCM_CruiseAway", f.Value, 3), + flag("SCCM_CruiseTowardsHold", f.Value, 4), + flag("SCCM_CruiseAwayHold", f.Value, 5), + flag("SCCM_CruiseAvailable", f.Value, 7), + } + }), + mp.NewField("bytes_2_2", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + flag("SCCM_CruiseUp", f.Value, 1), + sig("SCCM_CruiseCount2", f.Value, 4, 4, false, 1, 0), + } + }), + mp.NewField("_reserved", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return nil + }), +} + +// SCCM3 is 0x71A from SCCM. +var SCCM3 = mp.Message{ + mp.NewField("sccm3_b0", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("SCCM3_B0", f.Value, 0, 8, false, 1, 0), + } + }), + mp.NewField("sccm3_b1", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("SCCM3_B1", f.Value, 0, 8, false, 1, 0), + } + }), + mp.NewField("sccm3_b2", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("SCCM3_B2", f.Value, 0, 8, false, 1, 0), + } + }), + mp.NewField("sccm3_b3", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("SCCM3_B3", f.Value, 0, 8, false, 1, 0), + } + }), + mp.NewField("sccm3_b4", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("SCCM3_B4", f.Value, 0, 8, false, 1, 0), + } + }), + mp.NewField("sccm3_b5", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("SCCM3_B5", f.Value, 0, 8, false, 1, 0), + } + }), + mp.NewField("sccm3_b6", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("SCCM3_B6", f.Value, 0, 8, false, 1, 0), + } + }), + mp.NewField("sccm3_b7", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("SCCM3_B7", f.Value, 0, 8, false, 1, 0), + } + }), +} diff --git a/p987/model/signal.go b/p987/model/signal.go new file mode 100644 index 00000000..e044e4cf --- /dev/null +++ b/p987/model/signal.go @@ -0,0 +1,34 @@ +package model + +import mp "github.com/gaucho-racing/mapache/mapache-go/v3" + +// Stock Porsche signals do not sit on byte boundaries — SCCM_SteeringAngle +// is 13 bits starting at bit 2 — 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. Shifts are relative to the start of the field, not the +// start of the frame. + +// raw pulls length bits starting shift bits into the field value, +// sign-extending when the DBC marks the signal signed. +func raw(v, shift, length int, signed bool) int { + if length >= 64 { + return v + } + x := (v >> shift) & (1<> shift & 1 + return mp.Signal{Name: name, Value: float64(r), RawValue: r} +} diff --git a/p987/model/tcm.go b/p987/model/tcm.go index bbc2cf91..7756b253 100644 --- a/p987/model/tcm.go +++ b/p987/model/tcm.go @@ -1,115 +1,98 @@ package model -import "encoding/binary" +import ( + "fmt" -// The TCM publishes two synthetic frames under the "tcm" bus label. They -// never touched a physical CAN bus, so they are not in the DBC and are -// decoded here instead. + mp "github.com/gaucho-racing/mapache/mapache-go/v3" +) + +// The TCM publishes these under the "tcm" bus label. They never touched a +// physical CAN bus, so they are not in the 987 DBC. const ( MsgIDTCMStatus = 0x200 MsgIDTCMResources = 0x201 ) -// Decoded is the decoder output, matching dbc.Decoded so the dispatch path -// treats DBC frames and TCM frames identically. -type Decoded struct { - Name string - Value float64 - Raw int64 - Unit string -} - -// TCM Status is 8 bytes published every 5s: +// TCMStatus is a synthetic 8-byte message the relay publishes every 5s +// summarizing on-vehicle connectivity. status_bits is a flat bitfield; +// each bit is exposed as its own boolean signal so consumers can query +// "is X reachable?" without bit-twiddling. // -// [0] status_bits -// [1:3] mapache_ping u16 LE, ms -// [3:8] reserved -// -// Each status bit becomes its own boolean signal so consumers can ask "is -// X reachable?" without bit-twiddling. -func DecodeTCMStatus(data []byte) ([]Decoded, bool) { - if len(data) < 3 { - return nil, false - } - bits := data[0] - out := []Decoded{ - bit(bits, 0, "connection_ok"), - bit(bits, 1, "mqtt_ok"), - bit(bits, 2, "mapache_ok"), - bit(bits, 3, "clock_ok"), - } - ping := binary.LittleEndian.Uint16(data[1:3]) - return append(out, Decoded{Name: "mapache_ping", Value: float64(ping), Raw: int64(ping), Unit: "ms"}), true +// connection_ok — TCM has general internet (DNS reachable) +// mqtt_ok — cloud MQTT broker is connected +// mapache_ok — cloud Mapache is responding (recent pong) +// clock_ok — local clock past the 2003-10-31 cutoff (RTC/NTP synced) +// mapache_ping — RTT to Mapache in ms, from the most recent pong +var TCMStatus = mp.Message{ + mp.NewField("status_bits", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + flag("connection_ok", f.Value, 0), + flag("mqtt_ok", f.Value, 1), + flag("mapache_ok", f.Value, 2), + flag("clock_ok", f.Value, 3), + } + }), + mp.NewField("mapache_ping", 2, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + {Name: "mapache_ping", Value: float64(f.Value), RawValue: f.Value}, + } + }), + mp.NewField("_reserved", 5, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return nil + }), } -func bit(v byte, n uint, name string) Decoded { - raw := int64(v >> n & 1) - return Decoded{Name: name, Value: float64(raw), Raw: raw} +// TCMResourceUtil is the relay's 29-byte resource frame, published every +// 10s. Deliberately not TCM-26's 44-byte Jetson layout: a Pi Zero 2 W is +// quad-core with no discrete GPU counters and no power-rail sensors, so +// those fields would be permanently zero. The freed space carries throttle +// flags instead, which is the failure mode this board actually has — +// under-voltage corrupts SD cards and shows up in no other metric. +var TCMResourceUtil = mp.Message{ + cpuField(0), cpuField(1), cpuField(2), cpuField(3), + mp.NewField("cpu_total_util", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{{Name: "cpu_total_util", Value: float64(f.Value), RawValue: f.Value}} + }), + mp.NewField("ram_total", 2, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{{Name: "ram_total", Value: float64(f.Value), RawValue: f.Value}} + }), + mp.NewField("ram_used", 2, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{{Name: "ram_used", Value: float64(f.Value), RawValue: f.Value}} + }), + mp.NewField("ram_util", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{{Name: "ram_util", Value: float64(f.Value), RawValue: f.Value}} + }), + mp.NewField("disk_total", 4, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{{Name: "disk_total", Value: float64(f.Value), RawValue: f.Value}} + }), + mp.NewField("disk_used", 4, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{{Name: "disk_used", Value: float64(f.Value), RawValue: f.Value}} + }), + mp.NewField("disk_util", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{{Name: "disk_util", Value: float64(f.Value), RawValue: f.Value}} + }), + mp.NewField("cpu_temp", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{{Name: "cpu_temp", Value: float64(f.Value), RawValue: f.Value}} + }), + mp.NewField("throttle_flags", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + flag("undervoltage", f.Value, 0), + flag("undervoltage_since_boot", f.Value, 1), + flag("thermal_throttled", f.Value, 2), + flag("thermal_throttled_since_boot", f.Value, 3), + } + }), } -// resourcesPayloadSize is the TCM-987 0x201 layout. It is deliberately not -// TCM-26's 44-byte Jetson layout: a Pi Zero 2 W is quad-core with no -// discrete GPU counters and no power-rail sensors, so those fields would -// be permanently zero. The freed space carries throttle flags instead, -// which is the failure mode this board actually has — under-voltage -// corrupts SD cards and shows up in no other metric. -// -// [0:12] 4 × (freq u16 LE MHz, util u8 %) -// [12] cpu_total_util u8 % -// [13:15] ram_total u16 LE MB -// [15:17] ram_used u16 LE MB -// [17] ram_util u8 % -// [18:22] disk_total u32 LE MB -// [22:26] disk_used u32 LE MB -// [26] disk_util u8 % -// [27] cpu_temp u8 °C -// [28] throttle_flags u8 -const resourcesPayloadSize = 29 - -// ReportedCPUs must match the relay's model.ReportedCPUs. -const ReportedCPUs = 4 - -var cpuNames = [ReportedCPUs]struct{ freq, util string }{ - {"cpu_0_freq", "cpu_0_util"}, - {"cpu_1_freq", "cpu_1_util"}, - {"cpu_2_freq", "cpu_2_util"}, - {"cpu_3_freq", "cpu_3_util"}, -} - -func DecodeTCMResources(data []byte) ([]Decoded, bool) { - if len(data) < resourcesPayloadSize { - return nil, false - } - - out := make([]Decoded, 0, 20) - for i := 0; i < ReportedCPUs; i++ { - off := i * 3 - freq := binary.LittleEndian.Uint16(data[off : off+2]) - out = append(out, - Decoded{Name: cpuNames[i].freq, Value: float64(freq), Raw: int64(freq), Unit: "MHz"}, - Decoded{Name: cpuNames[i].util, Value: float64(data[off+2]), Raw: int64(data[off+2]), Unit: "%"}, - ) - } - - ramTotal := binary.LittleEndian.Uint16(data[13:15]) - ramUsed := binary.LittleEndian.Uint16(data[15:17]) - diskTotal := binary.LittleEndian.Uint32(data[18:22]) - diskUsed := binary.LittleEndian.Uint32(data[22:26]) - throttle := data[28] - - out = append(out, - Decoded{Name: "cpu_total_util", Value: float64(data[12]), Raw: int64(data[12]), Unit: "%"}, - Decoded{Name: "ram_total", Value: float64(ramTotal), Raw: int64(ramTotal), Unit: "MB"}, - Decoded{Name: "ram_used", Value: float64(ramUsed), Raw: int64(ramUsed), Unit: "MB"}, - Decoded{Name: "ram_util", Value: float64(data[17]), Raw: int64(data[17]), Unit: "%"}, - Decoded{Name: "disk_total", Value: float64(diskTotal), Raw: int64(diskTotal), Unit: "MB"}, - Decoded{Name: "disk_used", Value: float64(diskUsed), Raw: int64(diskUsed), Unit: "MB"}, - Decoded{Name: "disk_util", Value: float64(data[26]), Raw: int64(data[26]), Unit: "%"}, - Decoded{Name: "cpu_temp", Value: float64(data[27]), Raw: int64(data[27]), Unit: "C"}, - bit(throttle, 0, "undervoltage"), - bit(throttle, 1, "undervoltage_since_boot"), - bit(throttle, 2, "thermal_throttled"), - bit(throttle, 3, "thermal_throttled_since_boot"), - ) - return out, true +// cpuField builds the repeated per-core (freq u16, util u8) triple. The Pi +// Zero 2 W's BCM2710A1 is quad-core, so the relay sends exactly four. +func cpuField(n int) mp.Field { + freq := fmt.Sprintf("cpu_%d_freq", n) + util := fmt.Sprintf("cpu_%d_util", n) + return mp.NewField(fmt.Sprintf("cpu_%d", n), 3, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig(freq, f.Value, 0, 16, false, 1, 0), + sig(util, f.Value, 16, 8, false, 1, 0), + } + }) } diff --git a/p987/service/message.go b/p987/service/message.go index 1f90d5c5..7e623e51 100644 --- a/p987/service/message.go +++ b/p987/service/message.go @@ -9,7 +9,6 @@ import ( "strings" "time" - "github.com/gaucho-racing/mapache/p987/dbc" "github.com/gaucho-racing/mapache/p987/model" "github.com/gaucho-racing/mapache/p987/mqtt" "github.com/gaucho-racing/mapache/p987/pkg/logger" @@ -33,21 +32,6 @@ func IsValidProducedAt(tsMicros int) bool { return !time.UnixMicro(int64(tsMicros)).Before(minValidProducedAt) } -var decoder *dbc.Database - -// InitDecoder parses the embedded DBC. Called before MQTT so the first -// frame doesn't race the load. -func InitDecoder() error { - db, err := dbc.Cayman987() - if err != nil { - return err - } - decoder = db - logger.SugarLogger.Infof("[DBC] Loaded %d messages, %d signals (%d multiplexed, skipped)", - len(db.Messages), db.SignalCount(), db.MultiplexedCount()) - return nil -} - // HandleInboundMessage routes one MQTT message. // // Topic shape is p987/{vehicle_id}/{bus}/{can_id}, exactly four segments. @@ -96,7 +80,7 @@ func ProcessFrame(vehicleID, bus string, canID, timestamp int, data []byte) (mod producedAt := time.UnixMicro(int64(timestamp)) var ( - decoded []model.Decoded + decoded []mapache.Signal meta []byte ) @@ -123,79 +107,43 @@ func ProcessFrame(vehicleID, bus string, canID, timestamp int, data []byte) (mod ProducedAt: producedAt, } - now := time.Now().Truncate(time.Microsecond) - signals := make([]mapache.Signal, 0, len(decoded)) - for _, d := range decoded { - signals = append(signals, mapache.Signal{ + if len(decoded) > 0 { + now := time.Now().Truncate(time.Microsecond) + for i := range decoded { // Prefixed with the bus for the same reason gr26 prefixes with // the node: it keeps names unique across buses and lets the // signal-to-frame join recover the segment from the name. - Name: fmt.Sprintf("%s_%s", bus, d.Name), - Value: d.Value, - RawValue: int(d.Raw), - Timestamp: timestamp, - VehicleID: vehicleID, - ProducedAt: producedAt, - CreatedAt: now, - }) - } - return can, signals -} - -// decodeFrame picks a decoder: TCM housekeeping frames are synthetic and -// described here, everything else comes from the DBC. -func decodeFrame(bus string, canID int, data []byte) ([]model.Decoded, []byte) { - if bus == busTCM { - switch canID { - case model.MsgIDTCMStatus: - if d, ok := model.DecodeTCMStatus(data); ok { - return d, MustJSON(map[string]any{"status": "ok"}) - } - return nil, MustJSON(map[string]any{ - "status": "decode_error", - "note": fmt.Sprintf("tcm status frame is %d bytes, want at least 3", len(data)), - }) - case model.MsgIDTCMResources: - if d, ok := model.DecodeTCMResources(data); ok { - return d, MustJSON(map[string]any{"status": "ok"}) - } - return nil, MustJSON(map[string]any{ - "status": "decode_error", - "note": fmt.Sprintf("tcm resources frame is %d bytes, want 29", len(data)), - }) + decoded[i].Name = fmt.Sprintf("%s_%s", bus, decoded[i].Name) + decoded[i].Timestamp = timestamp + decoded[i].VehicleID = vehicleID + decoded[i].ProducedAt = producedAt + decoded[i].CreatedAt = now } } + return can, decoded +} - if decoder == nil { - return nil, MustJSON(map[string]any{"status": "decoder_unavailable"}) - } - - msg, ok := decoder.Messages[uint32(canID)] - if !ok { +// decodeFrame looks up the decoder for this id on this bus and runs it. +// Unknown ids and decode failures return no signals but a status blob, so +// the raw frame is still stored — that is how an unknown id gets +// reverse-engineered later. +func decodeFrame(bus string, canID int, data []byte) ([]mapache.Signal, []byte) { + messageStruct := model.GetMessage(bus, canID) + if messageStruct == nil { return nil, MustJSON(map[string]any{ "status": "unknown_can_id", - "note": fmt.Sprintf("no dbc entry for can id 0x%X", canID), + "note": fmt.Sprintf("no decoder registered for can id 0x%X on bus %s", canID, bus), }) } - if len(data) < msg.Length { + if err := messageStruct.FillFromBytes(data); err != nil { return nil, MustJSON(map[string]any{ - "status": "short_frame", - "note": fmt.Sprintf("%s expects %d bytes, got %d", msg.Name, msg.Length, len(data)), + "status": "decode_error", + "note": err.Error(), }) } - - out := msg.Decode(data) - decoded := make([]model.Decoded, 0, len(out)) - for _, d := range out { - decoded = append(decoded, model.Decoded{Name: d.Name, Value: d.Value, Raw: d.Raw, Unit: d.Unit}) - } - return decoded, MustJSON(map[string]any{"status": "ok", "message": msg.Name}) + return messageStruct.ExportSignals(), MustJSON(map[string]any{"status": "ok"}) } -// busTCM is the bus label the relay uses for frames that never touched a -// physical CAN bus. -const busTCM = "tcm" - func HandleMessage(vehicleID string, bus string, canID int, message []byte) { if len(message) < headerSize { logger.SugarLogger.Infof("[MQ] Message too short, ignoring %d bytes", len(message))