From 76a78fece7f5f5367a067873041d4017ac3fa358 Mon Sep 17 00:00:00 2001 From: Bharat Kathi Date: Tue, 18 Aug 2026 11:24:18 -0700 Subject: [PATCH 01/11] feat: add unified relay service --- .github/workflows/relay.yml | 159 +++++++++++++++++++++++++ .gitignore | 3 + docker-compose.yml | 50 ++++++++ example.env | 18 +++ relay/.air.toml | 21 ++++ relay/.gitignore | 18 +++ relay/Dockerfile | 26 +++++ relay/Dockerfile.dev | 7 ++ relay/config/banner.go | 11 ++ relay/config/config.go | 123 ++++++++++++++++++++ relay/database/db.go | 53 +++++++++ relay/go.mod | 33 ++++++ relay/go.sum | 64 +++++++++++ relay/main.go | 34 ++++++ relay/model/message.go | 19 +++ relay/model/ping.go | 14 +++ relay/model/resources.go | 33 ++++++ relay/mqtt/mqtt.go | 158 +++++++++++++++++++++++++ relay/service/can.go | 125 ++++++++++++++++++++ relay/service/clock.go | 15 +++ relay/service/dbqueue.go | 124 ++++++++++++++++++++ relay/service/ping.go | 96 ++++++++++++++++ relay/service/resources.go | 74 ++++++++++++ relay/service/resources_linux.go | 191 +++++++++++++++++++++++++++++++ relay/service/resources_stub.go | 9 ++ relay/service/retention.go | 47 ++++++++ relay/service/socketcan_linux.go | 92 +++++++++++++++ relay/service/socketcan_stub.go | 15 +++ relay/service/tcm_state.go | 120 +++++++++++++++++++ relay/service/tcm_status.go | 73 ++++++++++++ relay/utils/config.go | 60 ++++++++++ relay/utils/logger.go | 18 +++ scripts/release.sh | 134 ++++++++++++++++++++++ scripts/setup-can.sh | 26 +++++ 34 files changed, 2063 insertions(+) create mode 100644 .github/workflows/relay.yml create mode 100644 .gitignore create mode 100644 docker-compose.yml create mode 100644 example.env create mode 100644 relay/.air.toml create mode 100644 relay/.gitignore create mode 100644 relay/Dockerfile create mode 100644 relay/Dockerfile.dev create mode 100644 relay/config/banner.go create mode 100644 relay/config/config.go create mode 100644 relay/database/db.go create mode 100644 relay/go.mod create mode 100644 relay/go.sum create mode 100644 relay/main.go create mode 100644 relay/model/message.go create mode 100644 relay/model/ping.go create mode 100644 relay/model/resources.go create mode 100644 relay/mqtt/mqtt.go create mode 100644 relay/service/can.go create mode 100644 relay/service/clock.go create mode 100644 relay/service/dbqueue.go create mode 100644 relay/service/ping.go create mode 100644 relay/service/resources.go create mode 100644 relay/service/resources_linux.go create mode 100644 relay/service/resources_stub.go create mode 100644 relay/service/retention.go create mode 100644 relay/service/socketcan_linux.go create mode 100644 relay/service/socketcan_stub.go create mode 100644 relay/service/tcm_state.go create mode 100644 relay/service/tcm_status.go create mode 100644 relay/utils/config.go create mode 100644 relay/utils/logger.go create mode 100755 scripts/release.sh create mode 100755 scripts/setup-can.sh diff --git a/.github/workflows/relay.yml b/.github/workflows/relay.yml new file mode 100644 index 0000000..a7d93be --- /dev/null +++ b/.github/workflows/relay.yml @@ -0,0 +1,159 @@ +name: relay +run-name: Triggered by ${{ github.event_name }} to ${{ github.ref }} by @${{ github.actor }} + +on: + push: + branches: + - "**" + tags: + - "**" + +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: relay + platforms: ${{ matrix.platform }} + outputs: type=image,name=ghcr.io/gaucho-racing/tcm-987/relay,push-by-digest=true,name-canonical=true,push=true + # Per-ref cache scopes so simultaneous main+tag pushes from a + # release commit can't race on the same cache namespace and + # poison each other's layers. Tag/feature builds fall back to + # main's cache so they're not cold from scratch. + cache-from: | + type=gha,scope=build-${{ steps.platform.outputs.pair }}-${{ github.ref_name }} + type=gha,scope=build-${{ steps.platform.outputs.pair }}-main + cache-to: type=gha,scope=build-${{ 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/tcm-987/relay + 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/tcm-987/relay@sha256:%s ' *) + + - name: Inspect image + run: | + docker buildx imagetools inspect ghcr.io/gaucho-racing/tcm-987/relay:${{ steps.meta.outputs.version }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..40d56fd --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.env +.DS_Store +can_log.csv diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..3633614 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,50 @@ +name: tcm987-dev + +# All services run on host networking — socketcan interfaces (can0/vcan0) +# live in the host network namespace, so the relay must share it to see +# them. This is the dev compose: the relay builds from local source under +# air for hot reload, no restart policy so crashes are visible in +# `docker compose logs`. nanomq is included so `mosquitto_sub -t 'p987/#'` +# works locally; production can drop it (leave LOCAL_MQTT_HOST empty). + +services: + relay: + build: + context: . + dockerfile: relay/Dockerfile.dev + network_mode: host + extra_hosts: + - "host.docker.internal:host-gateway" + volumes: + - ./relay:/app/relay + - relay_go_cache:/go + - relay_data:/data + environment: + ENV: "DEV" + VEHICLE_ID: ${VEHICLE_ID} + VEHICLE_UPLOAD_KEY: ${VEHICLE_UPLOAD_KEY} + DATABASE_PATH: /data/relay.db + LOCAL_MQTT_HOST: localhost + LOCAL_MQTT_PORT: "1883" + CLOUD_MQTT_HOST: ${CLOUD_MQTT_HOST} + CLOUD_MQTT_PORT: ${CLOUD_MQTT_PORT} + CLOUD_MQTT_USER: ${CLOUD_MQTT_USER} + CLOUD_MQTT_PASSWORD: ${CLOUD_MQTT_PASSWORD} + CAN_INTERFACES: ${CAN_INTERFACES} + VIRTUAL_CAN_PORTS: "8100" + LOCAL_PUBLISH_INTERVAL: "20" + CLOUD_PUBLISH_INTERVAL: "100" + PING_INTERVAL: "5000" + DB_QUEUE_SIZE: "50000" + DB_BATCH_SIZE: "5000" + RETENTION_HOURS: "72" + depends_on: + - nanomq + + nanomq: + image: emqx/nanomq:latest + network_mode: host + +volumes: + relay_go_cache: + relay_data: diff --git a/example.env b/example.env new file mode 100644 index 0000000..cd74589 --- /dev/null +++ b/example.env @@ -0,0 +1,18 @@ +# Copy to `.env`. The dev compose inlines all other config — only the +# values below are read from .env at runtime. + +# Per-vehicle identity +VEHICLE_ID="cayman" +VEHICLE_UPLOAD_KEY="0" + +# socketcan interfaces to read, as iface:bus_label pairs +# ("can0:pcan,can1:kcan"). Leave empty on hosts without CAN hardware — +# the virtual UDP port on 8100 still works for injecting test frames. +CAN_INTERFACES="can0:pcan" + +# Cloud MQTT broker — leave CLOUD_MQTT_HOST empty to disable cloud publish. +# To test against a Mapache stack on this host, use host.docker.internal. +CLOUD_MQTT_HOST="" +CLOUD_MQTT_PORT="1883" +CLOUD_MQTT_USER="changeme" +CLOUD_MQTT_PASSWORD="changeme" diff --git a/relay/.air.toml b/relay/.air.toml new file mode 100644 index 0000000..3d5c83f --- /dev/null +++ b/relay/.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/relay/.gitignore b/relay/.gitignore new file mode 100644 index 0000000..af1ec96 --- /dev/null +++ b/relay/.gitignore @@ -0,0 +1,18 @@ +.env +tmp/ + +# Local SQLite database (WAL mode leaves sidecar files) +*.db +*.db-wal +*.db-shm + +# Go build artifacts +*.exe +*.test +*.out +coverage.html +go.work + +.vscode/ +.idea/ +.DS_Store diff --git a/relay/Dockerfile b/relay/Dockerfile new file mode 100644 index 0000000..65d1c2c --- /dev/null +++ b/relay/Dockerfile @@ -0,0 +1,26 @@ +FROM --platform=$BUILDPLATFORM golang:1.23-alpine AS builder + +WORKDIR /app + +COPY go.mod ./ +COPY go.sum ./ +RUN go mod download + +COPY . ./ +ARG TARGETOS +ARG TARGETARCH +RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -o /tcm_relay + +## +## Deploy +## +FROM alpine:3.21 + +RUN apk --no-cache add ca-certificates tzdata +ENV TZ=UTC + +COPY --from=builder /tcm_relay /tcm_relay + +VOLUME /data + +ENTRYPOINT ["/tcm_relay"] diff --git a/relay/Dockerfile.dev b/relay/Dockerfile.dev new file mode 100644 index 0000000..9b527c6 --- /dev/null +++ b/relay/Dockerfile.dev @@ -0,0 +1,7 @@ +FROM golang:1.26-bookworm + +RUN go install github.com/air-verse/air@latest + +WORKDIR /app/relay + +CMD ["air", "-c", ".air.toml"] diff --git a/relay/config/banner.go b/relay/config/banner.go new file mode 100644 index 0000000..1cf22c6 --- /dev/null +++ b/relay/config/banner.go @@ -0,0 +1,11 @@ +package config + +import "github.com/fatih/color" + +func PrintStartupBanner() { + banner := color.New(color.Bold, color.FgHiMagenta).PrintlnFunc() + banner("TCM-987 Relay") + version := color.New(color.Bold, color.FgMagenta).PrintlnFunc() + version("Running v" + Version + " [ENV: " + Env + "]") + println() +} diff --git a/relay/config/config.go b/relay/config/config.go new file mode 100644 index 0000000..af3a91f --- /dev/null +++ b/relay/config/config.go @@ -0,0 +1,123 @@ +package config + +import ( + "os" + "strings" + "time" + + cmap "github.com/orcaman/concurrent-map/v2" +) + +var Version = "0.1.0" +var Env = os.Getenv("ENV") + +// TopicRoot is the vehicle-generation namespace for all MQTT topics, +// matching the Mapache p987 ingest service's subscription filter. +const TopicRoot = "p987" + +// VirtualBusLabel is the topic bus segment for frames that never touched a +// physical CAN bus — TCM housekeeping (0x200/0x201) and anything arriving +// on a virtual CAN port (e.g. shelter's 0x210/0x211). Keeping these in +// their own namespace means their CAN IDs can never collide with the +// car's own arbitration IDs. +const VirtualBusLabel = "tcm" + +var VehicleID = os.Getenv("VEHICLE_ID") +var VehicleUploadKeyString = os.Getenv("VEHICLE_UPLOAD_KEY") +var VehicleUploadKey uint16 + +var DatabasePath = os.Getenv("DATABASE_PATH") + +var LocalMQTTHost = os.Getenv("LOCAL_MQTT_HOST") +var LocalMQTTPort = os.Getenv("LOCAL_MQTT_PORT") +var LocalMQTTUser = os.Getenv("LOCAL_MQTT_USER") +var LocalMQTTPassword = os.Getenv("LOCAL_MQTT_PASSWORD") + +var CloudMQTTHost = os.Getenv("CLOUD_MQTT_HOST") +var CloudMQTTPort = os.Getenv("CLOUD_MQTT_PORT") +var CloudMQTTUser = os.Getenv("CLOUD_MQTT_USER") +var CloudMQTTPassword = os.Getenv("CLOUD_MQTT_PASSWORD") + +// CANInterface maps a socketcan interface to the bus label used as the +// third MQTT topic segment. The label distinguishes physical buses whose +// 11-bit ID spaces are independent (pcan vs kcan), so it participates in +// topics, throttle keys, and the Mapache-side decoder registry. +type CANInterface struct { + Name string + Label string +} + +// CANInterfaces parses CAN_INTERFACES, a comma-separated list of +// iface:label pairs ("can0:pcan,can1:kcan"). A bare interface name uses +// itself as the label. +var CANInterfaces = parseInterfaceList(os.Getenv("CAN_INTERFACES")) + +func parseInterfaceList(s string) []CANInterface { + if s == "" { + return nil + } + parts := strings.Split(s, ",") + out := make([]CANInterface, 0, len(parts)) + for _, p := range parts { + p = strings.TrimSpace(p) + if p == "" { + continue + } + name, label, found := strings.Cut(p, ":") + if !found || label == "" { + label = name + } + out = append(out, CANInterface{Name: name, Label: label}) + } + return out +} + +// VirtualCANPorts is a comma-separated list of UDP ports to listen on for +// synthetic CAN frames produced by on-tcm software services (e.g. +// shelter). Frames arrive in the TCM-26 72-byte wire format and publish +// under VirtualBusLabel. +var VirtualCANPorts = parsePortList(os.Getenv("VIRTUAL_CAN_PORTS")) + +func parsePortList(s string) []string { + if s == "" { + return nil + } + parts := strings.Split(s, ",") + out := make([]string, 0, len(parts)) + for _, p := range parts { + p = strings.TrimSpace(p) + if p != "" { + out = append(out, p) + } + } + return out +} + +// Per-bus-and-CAN-ID publish throttles, in milliseconds. Local and cloud +// are tracked independently so an in-car consumer can run high-rate while +// the cellular uplink stays modest. +var LocalPublishInterval = os.Getenv("LOCAL_PUBLISH_INTERVAL") +var LocalPublishIntervalInt int +var CloudPublishInterval = os.Getenv("CLOUD_PUBLISH_INTERVAL") +var CloudPublishIntervalInt int + +var LastLocalPublish = cmap.ConcurrentMap[string, uint64]{} +var LastCloudPublish = cmap.ConcurrentMap[string, uint64]{} + +var PingIntervalRaw = os.Getenv("PING_INTERVAL") +var PingInterval time.Duration + +// DB queue sizing. The channel buffer is preallocated at startup +// (elemsize × cap), so keep DB_QUEUE_SIZE modest on small boards — the +// default 50k slots ≈ 5 MB and still buffers ~40s of full-bus traffic +// against the 1s flush cadence. +var DBQueueSizeRaw = os.Getenv("DB_QUEUE_SIZE") +var DBQueueSize int +var DBBatchSizeRaw = os.Getenv("DB_BATCH_SIZE") +var DBBatchSize int + +// RetentionHours bounds how long synced messages (and old pings) stay in +// the local database before the hourly purge deletes them. <= 0 disables +// purging entirely. +var RetentionHoursRaw = os.Getenv("RETENTION_HOURS") +var RetentionHours int diff --git a/relay/database/db.go b/relay/database/db.go new file mode 100644 index 0000000..9a56fd5 --- /dev/null +++ b/relay/database/db.go @@ -0,0 +1,53 @@ +package database + +import ( + "fmt" + "relay/config" + "relay/model" + "relay/utils" + + "github.com/glebarez/sqlite" + cmap "github.com/orcaman/concurrent-map/v2" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +var DB *gorm.DB + +func InitializeDB() { + // WAL + NORMAL is the SD-card-friendly durability point: crash-safe, + // no fsync per commit. busy_timeout covers cross-process access from + // shelter (relay and shelter share this file). + dsn := fmt.Sprintf( + "file:%s?_pragma=journal_mode(WAL)&_pragma=synchronous(NORMAL)&_pragma=busy_timeout(5000)", + config.DatabasePath, + ) + + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{ + Logger: gormLogger(), + }) + if err != nil { + utils.SugarLogger.Fatalf("[DB] Failed to open database at %s: %v", config.DatabasePath, err) + } + + utils.SugarLogger.Infoln("[DB] Connected to database") + + if err := db.AutoMigrate(&model.P987Message{}, &model.Ping{}); err != nil { + utils.SugarLogger.Fatalln("[DB] AutoMigration failed:", err) + } + + utils.SugarLogger.Infoln("[DB] AutoMigration complete") + DB = db +} + +func gormLogger() logger.Interface { + if config.Env == "DEV" { + return logger.Default.LogMode(logger.Warn) + } + return logger.Default.LogMode(logger.Error) +} + +func InitializeMap() { + config.LastLocalPublish = cmap.New[uint64]() + config.LastCloudPublish = cmap.New[uint64]() +} diff --git a/relay/go.mod b/relay/go.mod new file mode 100644 index 0000000..fe1634e --- /dev/null +++ b/relay/go.mod @@ -0,0 +1,33 @@ +module relay + +go 1.23.6 + +require ( + github.com/eclipse/paho.mqtt.golang v1.5.0 + github.com/fatih/color v1.18.0 + github.com/glebarez/sqlite v1.11.0 + github.com/orcaman/concurrent-map/v2 v2.0.1 + go.uber.org/zap v1.27.0 + golang.org/x/sys v0.31.0 + gorm.io/gorm v1.25.12 +) + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/glebarez/go-sqlite v1.21.2 // indirect + github.com/google/uuid v1.3.0 // indirect + github.com/gorilla/websocket v1.5.3 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + go.uber.org/multierr v1.10.0 // indirect + golang.org/x/net v0.27.0 // indirect + golang.org/x/sync v0.7.0 // indirect + golang.org/x/text v0.16.0 // indirect + modernc.org/libc v1.22.5 // indirect + modernc.org/mathutil v1.5.0 // indirect + modernc.org/memory v1.5.0 // indirect + modernc.org/sqlite v1.23.1 // indirect +) diff --git a/relay/go.sum b/relay/go.sum new file mode 100644 index 0000000..113a4dd --- /dev/null +++ b/relay/go.sum @@ -0,0 +1,64 @@ +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/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/eclipse/paho.mqtt.golang v1.5.0 h1:EH+bUVJNgttidWFkLLVKaQPGmkTUfQQqjOsyvMGvD6o= +github.com/eclipse/paho.mqtt.golang v1.5.0/go.mod h1:du/2qNQVqJf/Sqs4MEL77kR8QTqANF7XU7Fk0aOTAgk= +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/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo= +github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k= +github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw= +github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ= +github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ= +github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo= +github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= +github.com/google/uuid v1.3.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/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +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/orcaman/concurrent-map/v2 v2.0.1 h1:jOJ5Pg2w1oeB6PeDurIYf6k9PQ+aTITr/6lP/L/zp6c= +github.com/orcaman/concurrent-map/v2 v2.0.1/go.mod h1:9Eq3TG2oBe5FirmYWQfYO5iH1q0Jv47PLaNK++uCdOM= +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/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +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/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= +go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= +go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys= +golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE= +golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +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.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= +golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= +golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8= +gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ= +modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE= +modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY= +modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ= +modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= +modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds= +modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU= +modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM= +modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk= diff --git a/relay/main.go b/relay/main.go new file mode 100644 index 0000000..5acbab5 --- /dev/null +++ b/relay/main.go @@ -0,0 +1,34 @@ +package main + +import ( + "relay/config" + "relay/database" + "relay/mqtt" + "relay/service" + "relay/utils" +) + +func main() { + config.PrintStartupBanner() + utils.InitializeLogger() + defer utils.Logger.Sync() + + utils.VerifyConfig() + database.InitializeDB() + database.InitializeMap() + service.InitDBQueue() + service.InitializeRetention() + mqtt.InitializeMQTT() + + service.InitializePings() + service.InitializeResourceQuery() + // State watchers must start before the publisher so the first + // publish has live readings rather than zero defaults. + service.InitializeTCMState() + service.InitializeTCMStatus() + + for _, port := range config.VirtualCANPorts { + go service.ListenVirtualCAN(port) + } + service.RunSocketCAN() +} diff --git a/relay/model/message.go b/relay/model/message.go new file mode 100644 index 0000000..63b1162 --- /dev/null +++ b/relay/model/message.go @@ -0,0 +1,19 @@ +package model + +// P987Message mirrors TCM-26's Gr26Message so shelter's claim/upload path +// (and Mapache's parquet replay) work against the same column set. +// SourceNode carries the bus label; TargetNode is unused on stock Porsche +// CAN and kept only for schema compatibility. +type P987Message struct { + Timestamp int `json:"timestamp" gorm:"index:p987_message_unsynced_ts,where:synced = 0"` + VehicleID string `json:"vehicle_id"` + Topic string `json:"topic"` + Data []byte `json:"data" gorm:"type:blob"` + Synced int `json:"synced"` + SourceNode string `json:"source_node"` + TargetNode string `json:"target_node"` +} + +func (P987Message) TableName() string { + return "p987_message" +} diff --git a/relay/model/ping.go b/relay/model/ping.go new file mode 100644 index 0000000..ef9f4bc --- /dev/null +++ b/relay/model/ping.go @@ -0,0 +1,14 @@ +package model + +// Since we are dealing with a local database (on-vehicle), we know that +// the ping micros will always be unique for that car. +type Ping struct { + VehicleID string `json:"vehicle_id"` + Ping int `json:"ping" gorm:"primaryKey"` + Pong int `json:"pong"` + Latency int `json:"latency"` +} + +func (Ping) TableName() string { + return "ping" +} diff --git a/relay/model/resources.go b/relay/model/resources.go new file mode 100644 index 0000000..7c9a4f7 --- /dev/null +++ b/relay/model/resources.go @@ -0,0 +1,33 @@ +package model + +// ResourceMetrics keeps TCM-26's 44-byte 0x201 wire layout so the +// Mapache-side decoder can be shared. Fields with no Pi equivalent +// (GPU, power rails, CPUs 4-5 on a quad-core) stay zero. +type ResourceMetrics struct { + CPU0Freq int `json:"cpu_0_freq"` // 2 bytes, MHz + CPU0Util int `json:"cpu_0_util"` // 1 byte, % + CPU1Freq int `json:"cpu_1_freq"` // 2 bytes + CPU1Util int `json:"cpu_1_util"` // 1 byte + CPU2Freq int `json:"cpu_2_freq"` // 2 bytes + CPU2Util int `json:"cpu_2_util"` // 1 byte + CPU3Freq int `json:"cpu_3_freq"` // 2 bytes + CPU3Util int `json:"cpu_3_util"` // 1 byte + CPU4Freq int `json:"cpu_4_freq"` // 2 bytes + CPU4Util int `json:"cpu_4_util"` // 1 byte + CPU5Freq int `json:"cpu_5_freq"` // 2 bytes + CPU5Util int `json:"cpu_5_util"` // 1 byte + CPUTotalUtil int `json:"cpu_total_util"` // 1 byte, % + RAMTotal int `json:"ram_total"` // 2 bytes, MB + RAMUsed int `json:"ram_used"` // 2 bytes, MB + RAMUtil int `json:"ram_util"` // 1 byte, % + GPUUtil int `json:"gpu_util"` // 1 byte + GPUFreq int `json:"gpu_freq"` // 2 bytes + DiskTotal int `json:"disk_total"` // 4 bytes, MB + DiskUsed int `json:"disk_used"` // 4 bytes, MB + DiskUtil int `json:"disk_util"` // 1 byte, % + CPUTemp int `json:"cpu_temp"` // 1 byte, °C + GPUTemp int `json:"gpu_temp"` // 1 byte + VoltageDraw int `json:"voltage_draw"` // 2 bytes + CurrentDraw int `json:"current_draw"` // 2 bytes + PowerDraw int `json:"power_draw"` // 2 bytes +} diff --git a/relay/mqtt/mqtt.go b/relay/mqtt/mqtt.go new file mode 100644 index 0000000..8884f97 --- /dev/null +++ b/relay/mqtt/mqtt.go @@ -0,0 +1,158 @@ +package mqtt + +import ( + "fmt" + "relay/config" + "relay/utils" + "time" + + mq "github.com/eclipse/paho.mqtt.golang" +) + +// Client is the local broker connection (nil if LOCAL_MQTT_HOST is unset — +// there's no in-car consumer without a local broker, so it's optional). +// CloudClient is the cloud broker connection (nil if CLOUD_MQTT_HOST is unset). +var Client mq.Client +var CloudClient mq.Client + +var subscribedTopics = make(map[string]mq.MessageHandler) + +const ( + connectTimeout = 15 * time.Second + connectRetryInterval = 5 * time.Second +) + +func InitializeMQTT() { + // A configured local broker must be reachable at startup — fatal if + // not. The container restart policy is our retry mechanism for the + // local hop. + if config.LocalMQTTHost != "" { + Client = newClient( + "local", + config.LocalMQTTHost, config.LocalMQTTPort, + config.LocalMQTTUser, config.LocalMQTTPassword, + false, + ) + token := Client.Connect() + if !token.WaitTimeout(connectTimeout) { + utils.SugarLogger.Fatalf("[MQ][local] Connect to %s:%s timed out after %s", config.LocalMQTTHost, config.LocalMQTTPort, connectTimeout) + } + if err := token.Error(); err != nil { + utils.SugarLogger.Fatalln("[MQ][local] Failed to connect:", err) + } + } else { + utils.SugarLogger.Infoln("[MQ][local] LOCAL_MQTT_HOST unset, local publish disabled") + } + + // Cloud broker is best-effort — retry forever in background so we don't + // block startup on cloud reachability. + if config.CloudMQTTHost != "" { + CloudClient = newClient( + "cloud", + config.CloudMQTTHost, config.CloudMQTTPort, + config.CloudMQTTUser, config.CloudMQTTPassword, + true, + ) + CloudClient.Connect() + } else { + utils.SugarLogger.Infoln("[MQ][cloud] CLOUD_MQTT_HOST unset, cloud publish disabled") + } +} + +func newClient(label, host, port, user, password string, connectRetry bool) mq.Client { + opts := mq.NewClientOptions() + opts.AddBroker(fmt.Sprintf("tcp://%s:%s", host, port)) + opts.SetUsername(user) + opts.SetPassword(password) + opts.SetAutoReconnect(true) + opts.SetClientID(fmt.Sprintf("%s-tcm-%s-%06d", config.VehicleID, label, time.Now().UnixNano()%1000000)) + opts.SetOnConnectHandler(onConnectFn(label)) + opts.SetConnectionLostHandler(onConnectionLostFn(label)) + opts.SetReconnectingHandler(onReconnectFn(label)) + opts.SetMaxReconnectInterval(30 * time.Second) + opts.SetConnectTimeout(connectTimeout) + opts.SetOrderMatters(false) + // ConnectRetry retries the initial connection (paho's AutoReconnect only + // kicks in after a successful first connect). + if connectRetry { + opts.SetConnectRetry(true) + opts.SetConnectRetryInterval(connectRetryInterval) + } + return mq.NewClient(opts) +} + +// Publish sends payload to both brokers (best-effort). Use this for +// low-rate telemetry that doesn't need independent per-broker throttling +// (pings, status, resources). +func Publish(topic string, qos byte, retained bool, payload []byte) { + PublishLocal(topic, qos, retained, payload) + PublishCloud(topic, qos, retained, payload) +} + +func PublishLocal(topic string, qos byte, retained bool, payload []byte) { + if Client == nil { + return + } + publishOne(Client, topic, qos, retained, payload) +} + +func PublishCloud(topic string, qos byte, retained bool, payload []byte) { + if CloudClient == nil { + return + } + publishOne(CloudClient, topic, qos, retained, payload) +} + +func publishOne(client mq.Client, topic string, qos byte, retained bool, payload []byte) { + // Skip while disconnected so we don't queue into a paho client that's + // mid-(re)connect. Durability comes from the local database + shelter, + // not from MQTT — QoS 0 fire-and-forget everywhere. + if !client.IsConnected() { + return + } + client.Publish(topic, qos, retained, payload) +} + +// Subscribe subscribes on the cloud broker only — there's nothing useful +// for the relay to consume locally. No-op if cloud isn't configured. +func Subscribe(topic string, handler mq.MessageHandler) { + if CloudClient == nil { + utils.SugarLogger.Warnf("[MQ][cloud] Cannot subscribe to %s: CLOUD_MQTT_HOST not configured", topic) + return + } + subscribedTopics[topic] = handler + if token := CloudClient.Subscribe(topic, 0, handler); token.Wait() && token.Error() != nil { + // Not fatal — onConnect will resubscribe once the broker is reachable. + utils.SugarLogger.Warnf("[MQ][cloud] Failed to subscribe to %s: %v", topic, token.Error()) + return + } + utils.SugarLogger.Infof("[MQ][cloud] Subscribed to topic: %s", topic) +} + +func onConnectFn(label string) mq.OnConnectHandler { + return func(client mq.Client) { + utils.SugarLogger.Infof("[MQ][%s] Connected to broker", label) + if label != "cloud" { + return + } + for topic, handler := range subscribedTopics { + if token := client.Subscribe(topic, 0, handler); token.Wait() && token.Error() != nil { + utils.SugarLogger.Errorf("[MQ][cloud] Failed to resubscribe to %s: %v", topic, token.Error()) + continue + } + utils.SugarLogger.Infof("[MQ][cloud] Resubscribed to topic: %s", topic) + } + } +} + +func onConnectionLostFn(label string) mq.ConnectionLostHandler { + return func(client mq.Client, err error) { + utils.SugarLogger.Errorf("[MQ][%s] Connection lost: %v", label, err) + } +} + +func onReconnectFn(label string) mq.ReconnectHandler { + return func(client mq.Client, opts *mq.ClientOptions) { + utils.SugarLogger.Infof("[MQ][%s] Reconnecting...", label) + } +} diff --git a/relay/service/can.go b/relay/service/can.go new file mode 100644 index 0000000..0f26898 --- /dev/null +++ b/relay/service/can.go @@ -0,0 +1,125 @@ +package service + +import ( + "encoding/binary" + "fmt" + "net" + "relay/config" + "relay/mqtt" + "relay/utils" + "strconv" + "time" + + cmap "github.com/orcaman/concurrent-map/v2" +) + +// PublishData persists a frame and fans it out to both brokers. +// +// Topic format: p987/{vehicle_id}/{bus}/0x{can_id_hex}. The bus label +// replaces GR26's node segment — on stock Porsche CAN the sender ECU is a +// pure function of the arbitration ID, so the only routing info the ID +// can't carry is which physical bus it came from. +// +// MQTT payload: [0:8] timestamp u64 BE µs | [8:10] upload key u16 BE | +// [10:] raw CAN data. QoS 0, retain=false — durability comes from the +// database write, which happens before any throttle check so every frame +// is persisted regardless of connectivity. +func PublishData(busLabel string, canID uint32, data []byte) { + topic := fmt.Sprintf("%s/%s/%s/0x%03x", config.TopicRoot, config.VehicleID, busLabel, canID) + timestamp := uint64(time.Now().UnixMicro()) + + QueueDBWrite(int(timestamp), config.VehicleID, topic, data, busLabel, "") + + buf := make([]byte, 10+len(data)) + binary.BigEndian.PutUint64(buf[0:8], timestamp) + binary.BigEndian.PutUint16(buf[8:10], config.VehicleUploadKey) + copy(buf[10:], data) + + // Throttle key includes the bus label — the same 11-bit ID can exist + // on two physical buses with independent ID spaces. + throttleKey := fmt.Sprintf("%s/%d", busLabel, canID) + if shouldPublishOn(throttleKey, timestamp, config.LastLocalPublish, config.LocalPublishIntervalInt) { + mqtt.PublishLocal(topic, 0, false, buf) + } + if shouldPublishOn(throttleKey, timestamp, config.LastCloudPublish, config.CloudPublishIntervalInt) { + mqtt.PublishCloud(topic, 0, false, buf) + } +} + +func shouldPublishOn( + key string, + ts uint64, + last cmap.ConcurrentMap[string, uint64], + intervalMs int, +) bool { + lastTs, ok := last.Get(key) + if ok && ts-lastTs <= uint64(intervalMs*1000) { + return false + } + // Update before publishing so a slow MQTT call can't push us past the + // next interval window. + last.Set(key, ts) + return true +} + +// ListenVirtualCAN binds a UDP port and dispatches synthetic CAN frames +// from on-tcm software services (e.g. shelter heartbeats) through +// PublishData under the "tcm" bus label. Wire format is TCM-26's 72-byte +// struct: +// +// [0:4] CAN ID u32 LE +// [4] bus u8 (ignored — virtual frames are always bus "tcm") +// [5] length u8 (actual byte count, 0-64) +// [6:70] data +// [70:72] alignment padding +func ListenVirtualCAN(port string) { + shouldLog := config.Env == "DEV" + + portInt, err := strconv.Atoi(port) + if err != nil { + utils.SugarLogger.Fatalf("[VCAN:%s] Failed to convert port to int: %v", port, err) + } + addr := net.UDPAddr{ + Port: portInt, + IP: net.ParseIP("0.0.0.0"), + } + conn, err := net.ListenUDP("udp", &addr) + if err != nil { + utils.SugarLogger.Fatalf("[VCAN:%s] Failed to create UDP connection: %v", port, err) + } + defer conn.Close() + utils.SugarLogger.Infof("[VCAN:%s] listening", port) + + for { + buffer := make([]byte, 1024) + n, remoteAddr, err := conn.ReadFromUDP(buffer) + if err != nil { + utils.SugarLogger.Errorf("[VCAN:%s] Error reading from UDP: %v", port, err) + continue + } + if shouldLog { + utils.SugarLogger.Infof("[VCAN:%s] Received %d bytes from %s", port, n, remoteAddr.String()) + } + + if n < 72 { + utils.SugarLogger.Infof("[VCAN:%s] Invalid packet size: expected at least 72 bytes, got %d", port, n) + continue + } + + canID := binary.LittleEndian.Uint32(buffer[0:4]) + length := int(buffer[5]) + if length > 64 || length+6 > n { + utils.SugarLogger.Infof("[VCAN:%s] Payload length %d exceeds packet size %d, skipping", port, length, n) + continue + } + + payload := make([]byte, length) + copy(payload, buffer[6:6+length]) + + if shouldLog { + utils.SugarLogger.Infof("[VCAN:%s] CAN ID: 0x%03x, Length: %d", port, canID, length) + } + + go PublishData(config.VirtualBusLabel, canID, payload) + } +} diff --git a/relay/service/clock.go b/relay/service/clock.go new file mode 100644 index 0000000..33e6b18 --- /dev/null +++ b/relay/service/clock.go @@ -0,0 +1,15 @@ +package service + +import "time" + +// minValidTime mirrors Mapache's minValidProducedAt. A Pi with no RTC and +// no internet boots to 1970 — anything before this date is pre-clock +// garbage. Keep the two cutoffs in lockstep. +var minValidTime = time.Date(2003, 10, 31, 0, 0, 0, 0, time.UTC) + +const clockCheckInterval = 30 * time.Second + +// ClockPlausible reports whether the local clock is at or after minValidTime. +func ClockPlausible() bool { + return !time.Now().Before(minValidTime) +} diff --git a/relay/service/dbqueue.go b/relay/service/dbqueue.go new file mode 100644 index 0000000..3eb358b --- /dev/null +++ b/relay/service/dbqueue.go @@ -0,0 +1,124 @@ +package service + +import ( + "relay/config" + "relay/database" + "relay/model" + "relay/utils" + "sync" + "time" +) + +type DBQueue struct { + messages chan model.P987Message + batchSize int + flushTime time.Duration + mu sync.RWMutex + stopped bool + wg sync.WaitGroup +} + +var dbQueue *DBQueue + +func InitDBQueue() { + dbQueue = &DBQueue{ + messages: make(chan model.P987Message, config.DBQueueSize), + batchSize: config.DBBatchSize, + flushTime: 1000 * time.Millisecond, + } + + dbQueue.wg.Add(1) + go dbQueue.worker() + + utils.SugarLogger.Infof("[DB] Initialized queue with buffer %d, batch size %d", config.DBQueueSize, dbQueue.batchSize) +} + +func QueueDBWrite(timestamp int, vehicleID, topic string, data []byte, sourceNode string, targetNode string) { + dbQueue.mu.RLock() + stopped := dbQueue.stopped + dbQueue.mu.RUnlock() + + if stopped { + return + } + + msg := model.P987Message{ + Timestamp: timestamp, + VehicleID: vehicleID, + Topic: topic, + Data: data, + Synced: 0, + SourceNode: sourceNode, + TargetNode: targetNode, + } + + select { + case dbQueue.messages <- msg: + default: + utils.SugarLogger.Warnf("[DB] Queue full, dropping message") + } +} + +func (q *DBQueue) worker() { + defer q.wg.Done() + + batch := make([]model.P987Message, 0, q.batchSize) + ticker := time.NewTicker(q.flushTime) + defer ticker.Stop() + + for { + select { + case msg, ok := <-q.messages: + if !ok { + if len(batch) > 0 { + q.writeBatch(batch) + } + return + } + + batch = append(batch, msg) + + if len(batch) >= q.batchSize { + q.writeBatch(batch) + batch = batch[:0] + } + + case <-ticker.C: + if len(batch) > 0 { + q.writeBatch(batch) + batch = batch[:0] + } + } + } +} + +func (q *DBQueue) writeBatch(batch []model.P987Message) { + if len(batch) == 0 { + return + } + + start := time.Now() + result := database.DB.CreateInBatches(&batch, len(batch)) + duration := time.Since(start) + + if result.Error != nil { + utils.SugarLogger.Errorf("[DB] Failed to batch insert %d messages: %v", len(batch), result.Error) + } else { + utils.SugarLogger.Infof("[DB] Inserted %d messages in %v", len(batch), duration) + } +} + +func StopDBQueue() { + if dbQueue == nil { + return + } + + dbQueue.mu.Lock() + dbQueue.stopped = true + dbQueue.mu.Unlock() + + close(dbQueue.messages) + dbQueue.wg.Wait() + + utils.SugarLogger.Infof("[DB] Stopped") +} diff --git a/relay/service/ping.go b/relay/service/ping.go new file mode 100644 index 0000000..9e19a18 --- /dev/null +++ b/relay/service/ping.go @@ -0,0 +1,96 @@ +package service + +import ( + "encoding/binary" + "fmt" + "relay/config" + "relay/database" + "relay/model" + "relay/mqtt" + "relay/utils" + "time" + + mq "github.com/eclipse/paho.mqtt.golang" +) + +func InitializePings() { + go SubscribePong() + go func() { + for { + PublishPing() + time.Sleep(config.PingInterval) + } + }() + go func() { + warnAfter := config.PingInterval * 2 + for { + lastPing := FindLastSuccessfulPing() + ageMs := time.Now().UnixMilli() - int64(lastPing.Ping) + if ageMs > warnAfter.Milliseconds() { + utils.SugarLogger.Warnf("Last successful ping was %.2fs ago", float64(ageMs)/1000) + } + time.Sleep(2345 * time.Millisecond) + } + }() +} + +func SubscribePong() { + topic := fmt.Sprintf("%s/%s/tcm/pong", config.TopicRoot, config.VehicleID) + mqtt.Subscribe(topic, func(client mq.Client, msg mq.Message) { + if len(msg.Payload()) < 16 { + return + } + ping := binary.BigEndian.Uint64(msg.Payload()[:8]) + pong := binary.BigEndian.Uint64(msg.Payload()[8:]) + now := time.Now() + uploadLatency := now.UnixMicro() - int64(ping) + + // Cache freshness + latency in the shared state so + // publishTCMStatus reads without a DB round-trip. Clamp to u16 + // to match the wire field width on TCM Status. + latencyMs := uploadLatency / 1000 + if latencyMs < 0 { + latencyMs = 0 + } + if latencyMs > 65535 { + latencyMs = 65535 + } + state.setPong(now, uint16(latencyMs)) + + go UpdatePong(int(ping), int(pong), int(uploadLatency)) + utils.SugarLogger.Infof("[MQ] Received pong in %d ms", latencyMs) + }) +} + +func PublishPing() { + topic := fmt.Sprintf("%s/%s/tcm/ping", config.TopicRoot, config.VehicleID) + micros := time.Now().UnixMicro() + go CreatePing(int(micros)) + payload := make([]byte, 10) + binary.BigEndian.PutUint64(payload[0:8], uint64(micros)) + binary.BigEndian.PutUint16(payload[8:10], config.VehicleUploadKey) + mqtt.Publish(topic, 0, false, payload) +} + +func CreatePing(ping int) { + result := database.DB.Create(&model.Ping{ + VehicleID: config.VehicleID, + Ping: ping, + }) + if result.Error != nil { + utils.SugarLogger.Errorln("Failed to create ping:", result.Error) + } +} + +func UpdatePong(ping int, pong int, latency int) { + result := database.DB.Model(&model.Ping{}).Where("ping = ?", ping).Update("pong", pong).Update("latency", latency) + if result.Error != nil { + utils.SugarLogger.Errorln("Failed to update pong:", result.Error) + } +} + +func FindLastSuccessfulPing() model.Ping { + var ping model.Ping + database.DB.Where("latency > 0").Order("ping DESC").First(&ping) + return ping +} diff --git a/relay/service/resources.go b/relay/service/resources.go new file mode 100644 index 0000000..285d5f5 --- /dev/null +++ b/relay/service/resources.go @@ -0,0 +1,74 @@ +package service + +import ( + "encoding/binary" + "errors" + "fmt" + "relay/config" + "relay/model" + "relay/mqtt" + "relay/utils" + "time" +) + +var errResourcesUnsupported = errors.New("resource metrics unsupported on this platform") + +func InitializeResourceQuery() { + go func() { + for { + metrics, err := QueryResourceMetrics() + if errors.Is(err, errResourcesUnsupported) { + utils.SugarLogger.Warnln("Resource metrics unsupported on this platform, disabling") + return + } + if err != nil { + utils.SugarLogger.Errorf("Error querying resource metrics: %v", err) + } else { + utils.SugarLogger.Infof("Resource metrics: %+v", metrics) + PublishResources(metrics) + } + time.Sleep(10 * time.Second) + } + }() +} + +// PublishResources keeps TCM-26's 44-byte 0x201 layout so the +// Mapache-side decoder can be shared across both TCMs. +func PublishResources(metrics model.ResourceMetrics) { + topic := fmt.Sprintf("%s/%s/tcm/0x201", config.TopicRoot, config.VehicleID) + + dataPayload := make([]byte, 44) + binary.LittleEndian.PutUint16(dataPayload[:2], uint16(metrics.CPU0Freq)) + dataPayload[2] = byte(metrics.CPU0Util) + binary.LittleEndian.PutUint16(dataPayload[3:5], uint16(metrics.CPU1Freq)) + dataPayload[5] = byte(metrics.CPU1Util) + binary.LittleEndian.PutUint16(dataPayload[6:8], uint16(metrics.CPU2Freq)) + dataPayload[8] = byte(metrics.CPU2Util) + binary.LittleEndian.PutUint16(dataPayload[9:11], uint16(metrics.CPU3Freq)) + dataPayload[11] = byte(metrics.CPU3Util) + binary.LittleEndian.PutUint16(dataPayload[12:14], uint16(metrics.CPU4Freq)) + dataPayload[14] = byte(metrics.CPU4Util) + binary.LittleEndian.PutUint16(dataPayload[15:17], uint16(metrics.CPU5Freq)) + dataPayload[17] = byte(metrics.CPU5Util) + dataPayload[18] = byte(metrics.CPUTotalUtil) + binary.LittleEndian.PutUint16(dataPayload[19:21], uint16(metrics.RAMTotal)) + binary.LittleEndian.PutUint16(dataPayload[21:23], uint16(metrics.RAMUsed)) + dataPayload[23] = byte(metrics.RAMUtil) + dataPayload[24] = byte(metrics.GPUUtil) + binary.LittleEndian.PutUint16(dataPayload[25:27], uint16(metrics.GPUFreq)) + binary.LittleEndian.PutUint32(dataPayload[27:31], uint32(metrics.DiskTotal)) + binary.LittleEndian.PutUint32(dataPayload[31:35], uint32(metrics.DiskUsed)) + dataPayload[35] = byte(metrics.DiskUtil) + dataPayload[36] = byte(metrics.CPUTemp) + dataPayload[37] = byte(metrics.GPUTemp) + binary.LittleEndian.PutUint16(dataPayload[38:40], uint16(metrics.VoltageDraw)) + binary.LittleEndian.PutUint16(dataPayload[40:42], uint16(metrics.CurrentDraw)) + binary.LittleEndian.PutUint16(dataPayload[42:44], uint16(metrics.PowerDraw)) + + payload := make([]byte, 10, 54) + binary.BigEndian.PutUint64(payload[0:8], uint64(time.Now().UnixMicro())) + binary.BigEndian.PutUint16(payload[8:10], config.VehicleUploadKey) + payload = append(payload, dataPayload...) + + mqtt.Publish(topic, 0, false, payload) +} diff --git a/relay/service/resources_linux.go b/relay/service/resources_linux.go new file mode 100644 index 0000000..4da5a88 --- /dev/null +++ b/relay/service/resources_linux.go @@ -0,0 +1,191 @@ +//go:build linux + +package service + +import ( + "fmt" + "os" + "path/filepath" + "relay/config" + "relay/model" + "strconv" + "strings" + "sync" + + "golang.org/x/sys/unix" +) + +const maxReportedCPUs = 6 + +type cpuSample struct { + total uint64 + idle uint64 +} + +var cpuSampleMu sync.Mutex +var prevCPUSamples map[string]cpuSample + +// QueryResourceMetrics reads Pi resource stats straight from /proc and +// /sys — no jtop equivalent needed. CPU utilization is the delta since +// the previous call (the 10s poll cadence is the smoothing window), so +// the first call reports 0%. +func QueryResourceMetrics() (model.ResourceMetrics, error) { + var m model.ResourceMetrics + + utils, total, err := readCPUUtilization() + if err != nil { + return m, fmt.Errorf("cpu utilization: %w", err) + } + m.CPUTotalUtil = total + perCore := [maxReportedCPUs]*int{&m.CPU0Util, &m.CPU1Util, &m.CPU2Util, &m.CPU3Util, &m.CPU4Util, &m.CPU5Util} + perFreq := [maxReportedCPUs]*int{&m.CPU0Freq, &m.CPU1Freq, &m.CPU2Freq, &m.CPU3Freq, &m.CPU4Freq, &m.CPU5Freq} + for i := 0; i < maxReportedCPUs; i++ { + if i < len(utils) { + *perCore[i] = utils[i] + } + *perFreq[i] = readCPUFreqMHz(i) + } + + ramTotal, ramUsed, err := readMemInfo() + if err != nil { + return m, fmt.Errorf("meminfo: %w", err) + } + m.RAMTotal = ramTotal + m.RAMUsed = ramUsed + if ramTotal > 0 { + m.RAMUtil = ramUsed * 100 / ramTotal + } + + diskTotal, diskUsed := readDiskUsage(filepath.Dir(config.DatabasePath)) + m.DiskTotal = diskTotal + m.DiskUsed = diskUsed + if diskTotal > 0 { + m.DiskUtil = diskUsed * 100 / diskTotal + } + + m.CPUTemp = readCPUTemp() + + return m, nil +} + +// readCPUUtilization parses /proc/stat and computes per-core + aggregate +// busy percentages against the previous snapshot. +func readCPUUtilization() (perCore []int, total int, err error) { + data, err := os.ReadFile("/proc/stat") + if err != nil { + return nil, 0, err + } + + current := make(map[string]cpuSample) + for _, line := range strings.Split(string(data), "\n") { + fields := strings.Fields(line) + if len(fields) < 5 || !strings.HasPrefix(fields[0], "cpu") { + continue + } + var sample cpuSample + for i, f := range fields[1:] { + v, parseErr := strconv.ParseUint(f, 10, 64) + if parseErr != nil { + continue + } + sample.total += v + // idle (field 4) + iowait (field 5) + if i == 3 || i == 4 { + sample.idle += v + } + } + current[fields[0]] = sample + } + + cpuSampleMu.Lock() + prev := prevCPUSamples + prevCPUSamples = current + cpuSampleMu.Unlock() + + utilOf := func(key string) int { + cur, ok := current[key] + if !ok { + return 0 + } + p, ok := prev[key] + if !ok { + return 0 + } + dTotal := cur.total - p.total + dIdle := cur.idle - p.idle + if dTotal == 0 { + return 0 + } + return int(100 * (dTotal - dIdle) / dTotal) + } + + total = utilOf("cpu") + for i := 0; ; i++ { + key := fmt.Sprintf("cpu%d", i) + if _, ok := current[key]; !ok { + break + } + perCore = append(perCore, utilOf(key)) + } + return perCore, total, nil +} + +func readCPUFreqMHz(core int) int { + data, err := os.ReadFile(fmt.Sprintf("/sys/devices/system/cpu/cpu%d/cpufreq/scaling_cur_freq", core)) + if err != nil { + return 0 + } + khz, err := strconv.Atoi(strings.TrimSpace(string(data))) + if err != nil { + return 0 + } + return khz / 1000 +} + +func readMemInfo() (totalMB, usedMB int, err error) { + data, err := os.ReadFile("/proc/meminfo") + if err != nil { + return 0, 0, err + } + var totalKB, availKB int + for _, line := range strings.Split(string(data), "\n") { + fields := strings.Fields(line) + if len(fields) < 2 { + continue + } + v, parseErr := strconv.Atoi(fields[1]) + if parseErr != nil { + continue + } + switch fields[0] { + case "MemTotal:": + totalKB = v + case "MemAvailable:": + availKB = v + } + } + return totalKB / 1024, (totalKB - availKB) / 1024, nil +} + +func readDiskUsage(path string) (totalMB, usedMB int) { + var stat unix.Statfs_t + if err := unix.Statfs(path, &stat); err != nil { + return 0, 0 + } + blockSize := uint64(stat.Bsize) + total := stat.Blocks * blockSize + avail := stat.Bavail * blockSize + return int(total / (1024 * 1024)), int((total - avail) / (1024 * 1024)) +} + +func readCPUTemp() int { + data, err := os.ReadFile("/sys/class/thermal/thermal_zone0/temp") + if err != nil { + return 0 + } + milli, err := strconv.Atoi(strings.TrimSpace(string(data))) + if err != nil { + return 0 + } + return milli / 1000 +} diff --git a/relay/service/resources_stub.go b/relay/service/resources_stub.go new file mode 100644 index 0000000..f6895a4 --- /dev/null +++ b/relay/service/resources_stub.go @@ -0,0 +1,9 @@ +//go:build !linux + +package service + +import "relay/model" + +func QueryResourceMetrics() (model.ResourceMetrics, error) { + return model.ResourceMetrics{}, errResourcesUnsupported +} diff --git a/relay/service/retention.go b/relay/service/retention.go new file mode 100644 index 0000000..2dc53aa --- /dev/null +++ b/relay/service/retention.go @@ -0,0 +1,47 @@ +package service + +import ( + "relay/config" + "relay/database" + "relay/model" + "relay/utils" + "time" +) + +const retentionCheckInterval = 1 * time.Hour + +// InitializeRetention purges rows shelter has already uploaded (synced != 0) +// once they age past RETENTION_HOURS, plus old ping rows. Without this the +// database grows unbounded — a road car accumulates data indefinitely, +// unlike a race weekend. +func InitializeRetention() { + if config.RetentionHours <= 0 { + utils.SugarLogger.Infoln("[RET] RETENTION_HOURS <= 0, purge disabled") + return + } + utils.SugarLogger.Infof("[RET] Purging synced rows older than %dh", config.RetentionHours) + go func() { + for { + purgeExpired() + time.Sleep(retentionCheckInterval) + } + }() +} + +func purgeExpired() { + cutoff := time.Now().Add(-time.Duration(config.RetentionHours) * time.Hour).UnixMicro() + + result := database.DB.Where("synced != 0 AND timestamp < ?", cutoff).Delete(&model.P987Message{}) + if result.Error != nil { + utils.SugarLogger.Errorf("[RET] Failed to purge messages: %v", result.Error) + } else if result.RowsAffected > 0 { + utils.SugarLogger.Infof("[RET] Purged %d synced messages", result.RowsAffected) + } + + result = database.DB.Where("ping < ?", cutoff).Delete(&model.Ping{}) + if result.Error != nil { + utils.SugarLogger.Errorf("[RET] Failed to purge pings: %v", result.Error) + } else if result.RowsAffected > 0 { + utils.SugarLogger.Infof("[RET] Purged %d pings", result.RowsAffected) + } +} diff --git a/relay/service/socketcan_linux.go b/relay/service/socketcan_linux.go new file mode 100644 index 0000000..3807851 --- /dev/null +++ b/relay/service/socketcan_linux.go @@ -0,0 +1,92 @@ +//go:build linux + +package service + +import ( + "encoding/binary" + "fmt" + "net" + "relay/config" + "relay/utils" + "time" + + "golang.org/x/sys/unix" +) + +// classic can_frame: [0:4] ID+flags u32 host-order, [4] DLC, [5:8] pad, +// [8:16] data. Our deploy targets (arm64/amd64) are all little-endian. +const canFrameSize = 16 + +const reopenBackoff = 5 * time.Second + +// RunSocketCAN starts one reader per configured interface and blocks +// forever so main has something to park on. +func RunSocketCAN() { + for _, iface := range config.CANInterfaces { + go runReader(iface) + } + select {} +} + +// runReader reads frames until an error, then reopens after a backoff — +// the interface bounces across ignition cycles and `ip link` restarts, +// and the relay has to ride through both. +func runReader(iface config.CANInterface) { + for { + if err := readFrames(iface); err != nil { + utils.SugarLogger.Errorf("[CAN:%s] %v, reopening in %s", iface.Name, err, reopenBackoff) + } + time.Sleep(reopenBackoff) + } +} + +func readFrames(iface config.CANInterface) error { + netIface, err := net.InterfaceByName(iface.Name) + if err != nil { + return fmt.Errorf("interface lookup failed: %w", err) + } + + fd, err := unix.Socket(unix.AF_CAN, unix.SOCK_RAW, unix.CAN_RAW) + if err != nil { + return fmt.Errorf("socket failed: %w", err) + } + defer unix.Close(fd) + + if err := unix.Bind(fd, &unix.SockaddrCAN{Ifindex: netIface.Index}); err != nil { + return fmt.Errorf("bind failed: %w", err) + } + + utils.SugarLogger.Infof("[CAN:%s] reading as bus %q", iface.Name, iface.Label) + + frame := make([]byte, canFrameSize) + for { + n, err := unix.Read(fd, frame) + if err != nil { + if err == unix.EINTR { + continue + } + return fmt.Errorf("read failed: %w", err) + } + if n < canFrameSize { + continue + } + + rawID := binary.LittleEndian.Uint32(frame[0:4]) + if rawID&(unix.CAN_RTR_FLAG|unix.CAN_ERR_FLAG) != 0 { + continue + } + canID := rawID & unix.CAN_SFF_MASK + if rawID&unix.CAN_EFF_FLAG != 0 { + canID = rawID & unix.CAN_EFF_MASK + } + + length := int(frame[4]) + if length > 8 { + length = 8 + } + data := make([]byte, length) + copy(data, frame[8:8+length]) + + go PublishData(iface.Label, canID, data) + } +} diff --git a/relay/service/socketcan_stub.go b/relay/service/socketcan_stub.go new file mode 100644 index 0000000..02a200a --- /dev/null +++ b/relay/service/socketcan_stub.go @@ -0,0 +1,15 @@ +//go:build !linux + +package service + +import ( + "relay/config" + "relay/utils" +) + +func RunSocketCAN() { + if len(config.CANInterfaces) > 0 { + utils.SugarLogger.Warnf("socketcan requires linux; ignoring CAN_INTERFACES (%d configured)", len(config.CANInterfaces)) + } + select {} +} diff --git a/relay/service/tcm_state.go b/relay/service/tcm_state.go new file mode 100644 index 0000000..d115756 --- /dev/null +++ b/relay/service/tcm_state.go @@ -0,0 +1,120 @@ +package service + +import ( + "net" + "relay/mqtt" + "relay/utils" + "sync" + "time" +) + +// tcmState holds the latest reading from each connectivity watcher. +// publishTCMStatus takes a single snapshot every 5s with no I/O; each +// watcher updates its slot on its own cadence so a slow check (inet's +// 2s dial timeout in the worst case) can't stall the publish hot path. +type tcmState struct { + mu sync.RWMutex + inet bool + mqtt bool + clock bool + lastPongAt time.Time + lastPongRTT uint16 +} + +var state tcmState + +func (s *tcmState) setInet(v bool) { + s.mu.Lock() + s.inet = v + s.mu.Unlock() +} + +func (s *tcmState) setMqtt(v bool) { + s.mu.Lock() + s.mqtt = v + s.mu.Unlock() +} + +func (s *tcmState) setClock(v bool) { + s.mu.Lock() + s.clock = v + s.mu.Unlock() +} + +func (s *tcmState) setPong(at time.Time, rttMs uint16) { + s.mu.Lock() + s.lastPongAt = at + s.lastPongRTT = rttMs + s.mu.Unlock() +} + +func (s *tcmState) snapshot() (inet, mqtt, clock bool, lastPongAt time.Time, lastPongRTT uint16) { + s.mu.RLock() + defer s.mu.RUnlock() + return s.inet, s.mqtt, s.clock, s.lastPongAt, s.lastPongRTT +} + +const ( + inetCheckInterval = 10 * time.Second + mqttCheckInterval = 1 * time.Second +) + +// internetCheckTarget is dialed to verify general internet connectivity. +// 8.8.8.8:53 is Google Public DNS — opening the socket proves the link is +// up, no DNS query needed. +const internetCheckTarget = "8.8.8.8:53" + +func runInetWatcher() { + state.setInet(checkInet()) + ticker := time.NewTicker(inetCheckInterval) + defer ticker.Stop() + for range ticker.C { + state.setInet(checkInet()) + } +} + +func checkInet() bool { + conn, err := net.DialTimeout("tcp", internetCheckTarget, 2*time.Second) + if err != nil { + return false + } + conn.Close() + return true +} + +func runMqttWatcher() { + state.setMqtt(checkMqtt()) + ticker := time.NewTicker(mqttCheckInterval) + defer ticker.Stop() + for range ticker.C { + state.setMqtt(checkMqtt()) + } +} + +func checkMqtt() bool { + return mqtt.CloudClient != nil && mqtt.CloudClient.IsConnected() +} + +func runClockWatcher() { + state.setClock(ClockPlausible()) + ticker := time.NewTicker(clockCheckInterval) + defer ticker.Stop() + wasPlausible := true + for range ticker.C { + ok := ClockPlausible() + state.setClock(ok) + if !ok && wasPlausible { + utils.SugarLogger.Errorf("[CLK] system clock implausible: now=%s floor=%s (RTC not set / no NTP sync?)", + time.Now().Format(time.RFC3339), minValidTime.Format(time.RFC3339)) + } else if ok && !wasPlausible { + utils.SugarLogger.Infof("[CLK] system clock recovered: now=%s", time.Now().Format(time.RFC3339)) + } + wasPlausible = ok + } +} + +func InitializeTCMState() { + go runInetWatcher() + go runMqttWatcher() + go runClockWatcher() +} diff --git a/relay/service/tcm_status.go b/relay/service/tcm_status.go new file mode 100644 index 0000000..27edfd2 --- /dev/null +++ b/relay/service/tcm_status.go @@ -0,0 +1,73 @@ +package service + +import ( + "encoding/binary" + "fmt" + "relay/config" + "relay/mqtt" + "relay/utils" + "time" +) + +const ( + tcmStatusConnectionOK = 1 << 0 // generic internet (DNS reachable) + tcmStatusMQTTOK = 1 << 1 // cloud broker connected + tcmStatusMapacheOK = 1 << 2 // cloud Mapache responding (fresh pong) + tcmStatusClockOK = 1 << 3 // local clock is plausible (RTC/NTP synced) +) + +// mapachePongFreshness derives from PING_INTERVAL: 2× allows a single +// missed ping, +5s slack covers jitter and RTT variance. +func mapachePongFreshness() time.Duration { + return config.PingInterval*2 + 5*time.Second +} + +// InitializeTCMStatus publishes a TCM Status (0x200) message every 5s +// summarizing connectivity. The publish path does no I/O — every bit +// comes from the shared tcmState. +func InitializeTCMStatus() { + go func() { + ticker := time.NewTicker(5 * time.Second) + defer ticker.Stop() + for range ticker.C { + publishTCMStatus() + } + }() +} + +func publishTCMStatus() { + inet, mqttOK, clock, lastPongAt, lastPongRTT := state.snapshot() + + mapacheOK := !lastPongAt.IsZero() && time.Since(lastPongAt) < mapachePongFreshness() + + var statusBits byte + if inet { + statusBits |= tcmStatusConnectionOK + } + if mqttOK { + statusBits |= tcmStatusMQTTOK + } + if mapacheOK { + statusBits |= tcmStatusMapacheOK + } + if clock { + statusBits |= tcmStatusClockOK + } + + // TCM Status payload layout (8 bytes): + // [0] status_bits + // [1:3] mapache_ping (u16, ms, little-endian) + // [3:8] reserved + dataPayload := make([]byte, 8) + dataPayload[0] = statusBits + binary.LittleEndian.PutUint16(dataPayload[1:3], lastPongRTT) + + payload := make([]byte, 10, 18) + binary.BigEndian.PutUint64(payload[0:8], uint64(time.Now().UnixMicro())) + binary.BigEndian.PutUint16(payload[8:10], config.VehicleUploadKey) + payload = append(payload, dataPayload...) + + topic := fmt.Sprintf("%s/%s/tcm/0x200", config.TopicRoot, config.VehicleID) + mqtt.Publish(topic, 0, false, payload) + utils.SugarLogger.Debugf("[TCM] published status: bits=%08b latency=%dms", statusBits, lastPongRTT) +} diff --git a/relay/utils/config.go b/relay/utils/config.go new file mode 100644 index 0000000..42c5fb8 --- /dev/null +++ b/relay/utils/config.go @@ -0,0 +1,60 @@ +package utils + +import ( + "relay/config" + "strconv" + "time" +) + +func VerifyConfig() { + if config.VehicleID == "" { + SugarLogger.Fatalln("VEHICLE_ID is not set") + } + key, err := strconv.Atoi(config.VehicleUploadKeyString) + if err != nil { + SugarLogger.Fatalln("VEHICLE_UPLOAD_KEY is not a number") + } + if key < 0 || key > 65535 { + SugarLogger.Fatalln("VEHICLE_UPLOAD_KEY is not a valid unsigned 16-bit integer") + } + config.VehicleUploadKey = uint16(key) + + if config.DatabasePath == "" { + config.DatabasePath = "relay.db" + } + + config.LocalPublishIntervalInt = parseIntWithFallback(config.LocalPublishInterval, 20, "LOCAL_PUBLISH_INTERVAL") + config.CloudPublishIntervalInt = parseIntWithFallback(config.CloudPublishInterval, 100, "CLOUD_PUBLISH_INTERVAL") + pingMs := parseIntWithFallback(config.PingIntervalRaw, 5000, "PING_INTERVAL") + config.PingInterval = time.Duration(pingMs) * time.Millisecond + + config.DBQueueSize = parseIntWithFallback(config.DBQueueSizeRaw, 50000, "DB_QUEUE_SIZE") + config.DBBatchSize = parseIntWithFallback(config.DBBatchSizeRaw, 5000, "DB_BATCH_SIZE") + config.RetentionHours = parseIntWithFallback(config.RetentionHoursRaw, 72, "RETENTION_HOURS") + + if len(config.CANInterfaces) == 0 && len(config.VirtualCANPorts) == 0 { + SugarLogger.Warnln("No CAN_INTERFACES or VIRTUAL_CAN_PORTS configured — relay has no frame sources") + } + + SugarLogger.Infof("Vehicle ID: %s", config.VehicleID) + SugarLogger.Infof("Vehicle Upload Key: %d", config.VehicleUploadKey) + SugarLogger.Infof("Database Path: %s", config.DatabasePath) + for _, iface := range config.CANInterfaces { + SugarLogger.Infof("CAN Interface: %s (bus %s)", iface.Name, iface.Label) + } + SugarLogger.Infof("Local Publish Interval: %dms", config.LocalPublishIntervalInt) + SugarLogger.Infof("Cloud Publish Interval: %dms", config.CloudPublishIntervalInt) + SugarLogger.Infof("Ping Interval: %s", config.PingInterval) +} + +func parseIntWithFallback(raw string, fallback int, name string) int { + if raw == "" { + return fallback + } + v, err := strconv.Atoi(raw) + if err != nil { + SugarLogger.Errorf("%s is not a number, using %d: %v", name, fallback, err) + return fallback + } + return v +} diff --git a/relay/utils/logger.go b/relay/utils/logger.go new file mode 100644 index 0000000..6676581 --- /dev/null +++ b/relay/utils/logger.go @@ -0,0 +1,18 @@ +package utils + +import ( + "relay/config" + + "go.uber.org/zap" +) + +var Logger *zap.Logger +var SugarLogger *zap.SugaredLogger + +func InitializeLogger() { + Logger = zap.Must(zap.NewProduction()) + if config.Env == "DEV" { + Logger = zap.Must(zap.NewDevelopment()) + } + SugarLogger = Logger.Sugar() +} diff --git a/scripts/release.sh b/scripts/release.sh new file mode 100755 index 0000000..d3d92d1 --- /dev/null +++ b/scripts/release.sh @@ -0,0 +1,134 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Cuts a tcm-987 release. +# +# ./scripts/release.sh 0.2.0 # release as v0.2.0 +# ./scripts/release.sh # interactive — shows current, prompts +# +# A release in tcm-987 means a single tag (vX.Y.Z) that tags the relay +# Docker image (its workflow picks up the release and adds a +# tag alongside `latest`). +# +# Bumps the version baked into: +# - relay/config/config.go (Version constant; surfaces in the relay banner) +# +# Mirrors TCM-26's scripts/release.sh in shape so both repos feel the +# same to release. + +usage() { + cat </dev/null; then + echo "Error: $cmd is required" + exit 1 + fi +done + +BRANCH=$(git rev-parse --abbrev-ref HEAD) +if [[ "$BRANCH" != "main" ]]; then + echo "Error: must be on main branch (currently on $BRANCH)" + exit 1 +fi + +git fetch origin main --tags --quiet +LOCAL=$(git rev-parse HEAD) +REMOTE=$(git rev-parse origin/main) +if [[ "$LOCAL" != "$REMOTE" ]]; then + echo "Error: local main is not up to date with origin/main" + echo " local: $LOCAL" + echo " remote: $REMOTE" + exit 1 +fi + +PREV=$(git tag -l 'v*' | sort -V | tail -n1) + +if [[ -z "$INPUT" ]]; then + echo "" + if [[ -n "$PREV" ]]; then + echo "Current tcm-987 release: ${PREV}" + else + echo "Current tcm-987 release: (none)" + fi + echo "" + read -rp "Enter new version: " INPUT +fi + +if [[ -z "$INPUT" ]]; then + echo "Error: version cannot be empty" + exit 1 +fi +INPUT="${INPUT#v}" +if [[ ! "$INPUT" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Error: version must be a valid semver (e.g. 0.2.0)" + exit 1 +fi +SEMVER="$INPUT" +VERSION="v${INPUT}" +TAG="${VERSION}" + +if git tag -l "$TAG" | grep -q "^${TAG}$"; then + echo "Error: tag $TAG already exists" + exit 1 +fi + +REPO_ROOT=$(git rev-parse --show-toplevel) +cd "$REPO_ROOT" + +SERVICES=("relay") + +echo "" +echo "=== Release Summary ===" +echo " Version: ${VERSION}" +echo " Tag: ${TAG}" +echo " Commit: $(git rev-parse --short HEAD)" +echo " Branch: main" +echo "" +echo " Files to update:" +echo " relay/config/config.go" +echo "" +echo " Docker images that will be tagged:" +for svc in "${SERVICES[@]}"; do + echo " ghcr.io/gaucho-racing/tcm-987/${svc}:${SEMVER}" +done +echo "" +read -rp "Proceed? (y/N) " CONFIRM +if [[ "$CONFIRM" != "y" && "$CONFIRM" != "Y" ]]; then + echo "Aborted." + exit 0 +fi + +# BSD sed (-i '') so this stays mac-friendly; TCM-26 and Mapache use the +# same form. +sed -i '' "s/^var Version = \".*\"/var Version = \"${SEMVER}\"/" relay/config/config.go + +git add relay/config/config.go +git commit -m "release: tcm-987 ${VERSION}" +git push origin main + +gh release create "$TAG" \ + --target main \ + --title "${VERSION}" \ + --generate-notes + +echo "" +echo "Done. ${TAG} released." +echo " - relay workflow will publish a ${SEMVER}-tagged image" +echo "" +echo "Watch progress:" +echo " gh run list --limit 5" diff --git a/scripts/setup-can.sh b/scripts/setup-can.sh new file mode 100755 index 0000000..c5d7983 --- /dev/null +++ b/scripts/setup-can.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Bring up a CAN interface for the relay. +# +# sudo ./scripts/setup-can.sh # can0 @ 500k, listen-only +# sudo ./scripts/setup-can.sh can0 500000 # explicit +# sudo ./scripts/setup-can.sh vcan0 # virtual interface for dev +# +# Real interfaces come up in listen-only mode: the relay is a passive +# sniffer and must never ACK or transmit on the car's bus. + +IFACE="${1:-can0}" +BITRATE="${2:-500000}" + +if [[ "$IFACE" == vcan* ]]; then + modprobe vcan + ip link add dev "$IFACE" type vcan 2>/dev/null || true + ip link set "$IFACE" up + echo "$IFACE up (virtual)" + exit 0 +fi + +ip link set "$IFACE" down 2>/dev/null || true +ip link set "$IFACE" up type can bitrate "$BITRATE" listen-only on +echo "$IFACE up @ ${BITRATE}bps (listen-only)" From c118144ef1cc48cbeabe539027e1806e52a18145 Mon Sep 17 00:00:00 2001 From: Bharat Kathi Date: Sat, 29 Aug 2026 09:02:17 -0700 Subject: [PATCH 02/11] fix: compare ping timestamps in microseconds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PublishPing stores the ping as UnixMicro, but the staleness watchdog subtracted it from UnixMilli. The resulting age was a large negative number, so the warning never fired during a Mapache outage — the exact condition it exists to report. Rate-limit the warning to once a minute now that it can actually fire. The check itself stays on its old cadence so TCM Status reacts quickly, but a road car sits unreachable for days at a time and logging every poll would put tens of thousands of lines a day onto the SD card. --- relay/service/ping.go | 39 ++++++++++++++++++++++++++++++--------- 1 file changed, 30 insertions(+), 9 deletions(-) diff --git a/relay/service/ping.go b/relay/service/ping.go index 9e19a18..a0fc624 100644 --- a/relay/service/ping.go +++ b/relay/service/ping.go @@ -21,17 +21,38 @@ func InitializePings() { time.Sleep(config.PingInterval) } }() - go func() { - warnAfter := config.PingInterval * 2 - for { - lastPing := FindLastSuccessfulPing() - ageMs := time.Now().UnixMilli() - int64(lastPing.Ping) - if ageMs > warnAfter.Milliseconds() { - utils.SugarLogger.Warnf("Last successful ping was %.2fs ago", float64(ageMs)/1000) + go runPingWatchdog() +} + +// staleWarnInterval rate-limits the offline warning. The check itself stays +// frequent so TCM Status reacts quickly, but a road car sits unreachable in +// a garage for days — logging every poll would be tens of thousands of +// lines a day onto the SD card. +const staleWarnInterval = 60 * time.Second + +func runPingWatchdog() { + warnAfter := config.PingInterval * 2 + var lastWarn time.Time + + for { + // Ping is stored as UnixMicro by PublishPing — compare in the same + // unit or the age is nonsense. + lastPing := FindLastSuccessfulPing() + stale := lastPing.Ping == 0 || + time.Now().UnixMicro()-int64(lastPing.Ping) > warnAfter.Microseconds() + + if stale && time.Since(lastWarn) >= staleWarnInterval { + if lastPing.Ping == 0 { + utils.SugarLogger.Warnln("No successful ping yet") + } else { + age := time.Now().UnixMicro() - int64(lastPing.Ping) + utils.SugarLogger.Warnf("Last successful ping was %.2fs ago", float64(age)/1e6) } - time.Sleep(2345 * time.Millisecond) + lastWarn = time.Now() } - }() + + time.Sleep(2345 * time.Millisecond) + } } func SubscribePong() { From a7453dda223a7d947366536d4f5d39ea6b934bfd Mon Sep 17 00:00:00 2001 From: Bharat Kathi Date: Sat, 29 Aug 2026 09:02:29 -0700 Subject: [PATCH 03/11] fix: preserve message batches when SQLite writes fail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit writeBatch only logged the error and the worker reset the batch immediately after, so a single transient failure discarded every frame in it — up to DB_BATCH_SIZE of them. "database is locked" is expected in normal operation once shelter holds the same file, so this would put holes in the telemetry capture rather than being a rare edge case. Retry with a short backoff before giving up. The insert is a single transaction, so a failed attempt wrote nothing and a retry cannot duplicate rows. Also close the queue under the write lock. QueueDBWrite checked the stopped flag and then sent on the channel, so a sender that passed the check before StopDBQueue closed it would panic on a closed channel. --- relay/service/dbqueue.go | 56 ++++++++++++++++++++++++++++------------ 1 file changed, 40 insertions(+), 16 deletions(-) diff --git a/relay/service/dbqueue.go b/relay/service/dbqueue.go index 3eb358b..714f5ea 100644 --- a/relay/service/dbqueue.go +++ b/relay/service/dbqueue.go @@ -20,6 +20,13 @@ type DBQueue struct { var dbQueue *DBQueue +// A batch survives ~1.5s of retries before it is dropped; the queue keeps +// buffering behind it for as long as DB_QUEUE_SIZE allows. +const ( + writeRetries = 3 + writeRetryDelay = 250 * time.Millisecond +) + func InitDBQueue() { dbQueue = &DBQueue{ messages: make(chan model.P987Message, config.DBQueueSize), @@ -34,14 +41,6 @@ func InitDBQueue() { } func QueueDBWrite(timestamp int, vehicleID, topic string, data []byte, sourceNode string, targetNode string) { - dbQueue.mu.RLock() - stopped := dbQueue.stopped - dbQueue.mu.RUnlock() - - if stopped { - return - } - msg := model.P987Message{ Timestamp: timestamp, VehicleID: vehicleID, @@ -52,6 +51,15 @@ func QueueDBWrite(timestamp int, vehicleID, topic string, data []byte, sourceNod TargetNode: targetNode, } + // Held across the send: StopDBQueue closes the channel under the write + // lock, so no sender can be mid-send when it does. + dbQueue.mu.RLock() + defer dbQueue.mu.RUnlock() + + if dbQueue.stopped { + return + } + select { case dbQueue.messages <- msg: default: @@ -92,32 +100,48 @@ func (q *DBQueue) worker() { } } +// writeBatch retries transient failures before giving up on the batch. +// `database is locked` is expected in normal operation — shelter holds the +// same SQLite file — and dropping thousands of frames for it would put +// holes in the telemetry capture. The insert is a single transaction, so a +// failed attempt wrote nothing and retrying cannot duplicate rows. func (q *DBQueue) writeBatch(batch []model.P987Message) { if len(batch) == 0 { return } start := time.Now() - result := database.DB.CreateInBatches(&batch, len(batch)) - duration := time.Since(start) - - if result.Error != nil { - utils.SugarLogger.Errorf("[DB] Failed to batch insert %d messages: %v", len(batch), result.Error) - } else { - utils.SugarLogger.Infof("[DB] Inserted %d messages in %v", len(batch), duration) + for attempt := 1; ; attempt++ { + result := database.DB.CreateInBatches(&batch, len(batch)) + if result.Error == nil { + utils.SugarLogger.Infof("[DB] Inserted %d messages in %v", len(batch), time.Since(start)) + return + } + if attempt > writeRetries { + utils.SugarLogger.Errorf("[DB] Dropping %d messages after %d failed inserts: %v", len(batch), attempt, result.Error) + return + } + utils.SugarLogger.Warnf("[DB] Insert of %d messages failed (attempt %d/%d): %v", len(batch), attempt, writeRetries, result.Error) + time.Sleep(time.Duration(attempt) * writeRetryDelay) } } +// StopDBQueue closes the queue and blocks until the worker has flushed +// everything still buffered. func StopDBQueue() { if dbQueue == nil { return } dbQueue.mu.Lock() + if dbQueue.stopped { + dbQueue.mu.Unlock() + return + } dbQueue.stopped = true + close(dbQueue.messages) dbQueue.mu.Unlock() - close(dbQueue.messages) dbQueue.wg.Wait() utils.SugarLogger.Infof("[DB] Stopped") From cd5f30302bceae280419c05db4fc8ada8c3c1729 Mon Sep 17 00:00:00 2001 From: Bharat Kathi Date: Sat, 29 Aug 2026 09:02:29 -0700 Subject: [PATCH 04/11] fix: resolve the default database path inside the declared volume The final image stage has no WORKDIR, so the built-in relative DATABASE_PATH default resolved to /relay.db and the declared /data volume went unused. Recreating or upgrading the container then discarded all buffered telemetry despite /data being mounted. --- relay/Dockerfile | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/relay/Dockerfile b/relay/Dockerfile index 65d1c2c..3f8da4e 100644 --- a/relay/Dockerfile +++ b/relay/Dockerfile @@ -21,6 +21,10 @@ ENV TZ=UTC COPY --from=builder /tcm_relay /tcm_relay +# A relative DATABASE_PATH (including the built-in default) resolves +# against the workdir — put it inside the volume so buffered telemetry +# survives a container recreate. +WORKDIR /data VOLUME /data ENTRYPOINT ["/tcm_relay"] From 1063c55e0f4629a3026fec1250ff1b22960231f5 Mon Sep 17 00:00:00 2001 From: Bharat Kathi Date: Sat, 29 Aug 2026 09:02:50 -0700 Subject: [PATCH 05/11] feat: bound the publish path and drain it on shutdown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every CAN frame spawned a goroutine to publish itself, which puts the whole bus through the scheduler on a single-board target. Publishing inline instead is only safe if publishing cannot block: paho's Publish waits on an internal channel bounded by MessageChannelDepth and gives up only after WriteTimeout (30s by default), so a broker that is connected but slow — a degrading cell uplink rather than a dropped one — would stall the socketcan reader. That stalls unix.Read, overruns the kernel CAN buffer, and drops frames before the relay ever sees them. Give each broker its own queue drained by its own worker, so a slow cloud uplink cannot block a local consumer, and shed with a periodic count rather than a line per frame. Skip the throttle bookkeeping and the payload allocation entirely when no broker wants the frame, which is the common case under the publish intervals. Guard the subscription registry with a mutex. Subscribe registers from the SubscribePong goroutine while onConnect ranges over the same map, so a fast broker connection could terminate the relay with "concurrent map iteration and map write". Handle SIGINT/SIGTERM so the write queue flushes and the database closes on the way out. Previously docker stop discarded whatever was buffered, up to a full batch of frames. --- relay/database/db.go | 18 ++++ relay/main.go | 17 +++- relay/mqtt/mqtt.go | 168 +++++++++++++++++++++++++++++-- relay/service/can.go | 92 +++++++++++------ relay/service/ping.go | 5 +- relay/service/socketcan_linux.go | 51 ++++++---- relay/service/socketcan_stub.go | 3 +- relay/service/tcm_status.go | 47 +++++---- 8 files changed, 314 insertions(+), 87 deletions(-) diff --git a/relay/database/db.go b/relay/database/db.go index 9a56fd5..23d2ca9 100644 --- a/relay/database/db.go +++ b/relay/database/db.go @@ -40,6 +40,24 @@ func InitializeDB() { DB = db } +// Close releases the SQLite handle so WAL checkpointing completes before +// the process exits. +func Close() { + if DB == nil { + return + } + sqlDB, err := DB.DB() + if err != nil { + utils.SugarLogger.Errorf("[DB] Failed to get underlying handle: %v", err) + return + } + if err := sqlDB.Close(); err != nil { + utils.SugarLogger.Errorf("[DB] Failed to close: %v", err) + return + } + utils.SugarLogger.Infoln("[DB] Closed") +} + func gormLogger() logger.Interface { if config.Env == "DEV" { return logger.Default.LogMode(logger.Warn) diff --git a/relay/main.go b/relay/main.go index 5acbab5..4fe9c4e 100644 --- a/relay/main.go +++ b/relay/main.go @@ -1,11 +1,14 @@ package main import ( + "os" + "os/signal" "relay/config" "relay/database" "relay/mqtt" "relay/service" "relay/utils" + "syscall" ) func main() { @@ -30,5 +33,17 @@ func main() { for _, port := range config.VirtualCANPorts { go service.ListenVirtualCAN(port) } - service.RunSocketCAN() + service.StartSocketCAN() + + // Park until the container is stopped, then flush the write queue + // before exiting — a full batch is DB_BATCH_SIZE frames that would + // otherwise never reach the disk. + stop := make(chan os.Signal, 1) + signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM) + sig := <-stop + utils.SugarLogger.Infof("Received %s, shutting down", sig) + + service.StopDBQueue() + mqtt.Disconnect() + database.Close() } diff --git a/relay/mqtt/mqtt.go b/relay/mqtt/mqtt.go index 8884f97..b867b35 100644 --- a/relay/mqtt/mqtt.go +++ b/relay/mqtt/mqtt.go @@ -4,9 +4,13 @@ import ( "fmt" "relay/config" "relay/utils" + "sync" + "sync/atomic" "time" mq "github.com/eclipse/paho.mqtt.golang" + "go.uber.org/zap" + "go.uber.org/zap/zapcore" ) // Client is the local broker connection (nil if LOCAL_MQTT_HOST is unset — @@ -16,13 +20,45 @@ var Client mq.Client var CloudClient mq.Client var subscribedTopics = make(map[string]mq.MessageHandler) +var subscribedTopicsMu sync.RWMutex const ( connectTimeout = 15 * time.Second connectRetryInterval = 5 * time.Second ) +// pahoLogger adapts paho's Logger interface to zap. Without this every +// connection error inside paho goes to its default NOOPLogger — with +// ConnectRetry enabled that means a wrong host, port, or credential +// retries forever in total silence. +type pahoLogger struct { + log func(args ...interface{}) + logf func(template string, args ...interface{}) +} + +func (l pahoLogger) Println(v ...interface{}) { l.log(v...) } +func (l pahoLogger) Printf(format string, v ...interface{}) { l.logf(format, v...) } + +func initPahoLogging() { + // Drop the stacktrace (it is always the same paho internals) and sample + // to one line per message per minute. An unreachable broker retries on + // a 5s interval indefinitely, and a car parked out of coverage would + // otherwise write that error to the SD card ~17k times a day. + log := utils.Logger.WithOptions( + zap.AddStacktrace(zapcore.FatalLevel), + zap.WrapCore(func(c zapcore.Core) zapcore.Core { + return zapcore.NewSamplerWithOptions(c, time.Minute, 1, 0) + }), + ).Sugar() + + mq.ERROR = pahoLogger{log.Errorln, log.Errorf} + mq.CRITICAL = pahoLogger{log.Errorln, log.Errorf} + mq.WARN = pahoLogger{log.Warnln, log.Warnf} +} + func InitializeMQTT() { + initPahoLogging() + // A configured local broker must be reachable at startup — fatal if // not. The container restart policy is our retry mechanism for the // local hop. @@ -40,6 +76,7 @@ func InitializeMQTT() { if err := token.Error(); err != nil { utils.SugarLogger.Fatalln("[MQ][local] Failed to connect:", err) } + localQueue = startQueue("local", Client) } else { utils.SugarLogger.Infoln("[MQ][local] LOCAL_MQTT_HOST unset, local publish disabled") } @@ -53,6 +90,7 @@ func InitializeMQTT() { config.CloudMQTTUser, config.CloudMQTTPassword, true, ) + cloudQueue = startQueue("cloud", CloudClient) CloudClient.Connect() } else { utils.SugarLogger.Infoln("[MQ][cloud] CLOUD_MQTT_HOST unset, cloud publish disabled") @@ -81,6 +119,94 @@ func newClient(label, host, port, user, password string, connectRetry bool) mq.C return mq.NewClient(opts) } +// publishQueueDepth bounds the per-broker backlog. paho's Publish blocks +// once its own outbound buffer fills, so a slow-but-connected uplink +// (cellular) would otherwise stall whichever goroutine called it — on the +// CAN read path that means kernel-side frame loss. +const publishQueueDepth = 4096 + +// dropReportInterval is how often a broker reports the publishes it shed +// while its queue was full. Per-drop logging would itself become the +// bottleneck at full bus rate. +const dropReportInterval = 10 * time.Second + +type publishJob struct { + topic string + qos byte + retained bool + payload []byte +} + +// publishQueue serializes publishes to one broker on a single worker. +// Brokers get independent queues so a stalled cloud uplink can't block +// the local broker that the in-car consumers read from. +type publishQueue struct { + label string + client mq.Client + jobs chan publishJob + dropped atomic.Uint64 + wg sync.WaitGroup +} + +var localQueue *publishQueue +var cloudQueue *publishQueue + +func startQueue(label string, client mq.Client) *publishQueue { + q := &publishQueue{ + label: label, + client: client, + jobs: make(chan publishJob, publishQueueDepth), + } + q.wg.Add(1) + go q.worker() + return q +} + +func (q *publishQueue) worker() { + defer q.wg.Done() + + ticker := time.NewTicker(dropReportInterval) + defer ticker.Stop() + + for { + select { + case job, ok := <-q.jobs: + if !ok { + q.reportDrops() + return + } + // Skip while disconnected so we don't queue into a paho client + // that's mid-(re)connect. Durability comes from the local + // database, not from MQTT — QoS 0 fire-and-forget everywhere. + if q.client.IsConnected() { + q.client.Publish(job.topic, job.qos, job.retained, job.payload) + } + case <-ticker.C: + q.reportDrops() + } + } +} + +func (q *publishQueue) reportDrops() { + if n := q.dropped.Swap(0); n > 0 { + utils.SugarLogger.Warnf("[MQ][%s] Queue full, dropped %d publishes", q.label, n) + } +} + +func (q *publishQueue) enqueue(topic string, qos byte, retained bool, payload []byte) { + select { + case q.jobs <- publishJob{topic: topic, qos: qos, retained: retained, payload: payload}: + default: + q.dropped.Add(1) + } +} + +// LocalEnabled and CloudEnabled report whether a broker is configured, so +// callers can skip building a payload nobody will consume. +func LocalEnabled() bool { return localQueue != nil } + +func CloudEnabled() bool { return cloudQueue != nil } + // Publish sends payload to both brokers (best-effort). Use this for // low-rate telemetry that doesn't need independent per-broker throttling // (pings, status, resources). @@ -90,27 +216,34 @@ func Publish(topic string, qos byte, retained bool, payload []byte) { } func PublishLocal(topic string, qos byte, retained bool, payload []byte) { - if Client == nil { + if localQueue == nil { return } - publishOne(Client, topic, qos, retained, payload) + localQueue.enqueue(topic, qos, retained, payload) } func PublishCloud(topic string, qos byte, retained bool, payload []byte) { - if CloudClient == nil { + if cloudQueue == nil { return } - publishOne(CloudClient, topic, qos, retained, payload) + cloudQueue.enqueue(topic, qos, retained, payload) } -func publishOne(client mq.Client, topic string, qos byte, retained bool, payload []byte) { - // Skip while disconnected so we don't queue into a paho client that's - // mid-(re)connect. Durability comes from the local database + shelter, - // not from MQTT — QoS 0 fire-and-forget everywhere. - if !client.IsConnected() { - return +// Disconnect drains both publish queues and closes the broker +// connections. Safe to call with either broker unconfigured. +func Disconnect() { + for _, q := range []*publishQueue{localQueue, cloudQueue} { + if q == nil { + continue + } + close(q.jobs) + q.wg.Wait() + if q.client.IsConnected() { + q.client.Disconnect(250) + } + utils.SugarLogger.Infof("[MQ][%s] Disconnected", q.label) } - client.Publish(topic, qos, retained, payload) + localQueue, cloudQueue = nil, nil } // Subscribe subscribes on the cloud broker only — there's nothing useful @@ -120,7 +253,10 @@ func Subscribe(topic string, handler mq.MessageHandler) { utils.SugarLogger.Warnf("[MQ][cloud] Cannot subscribe to %s: CLOUD_MQTT_HOST not configured", topic) return } + subscribedTopicsMu.Lock() subscribedTopics[topic] = handler + subscribedTopicsMu.Unlock() + if token := CloudClient.Subscribe(topic, 0, handler); token.Wait() && token.Error() != nil { // Not fatal — onConnect will resubscribe once the broker is reachable. utils.SugarLogger.Warnf("[MQ][cloud] Failed to subscribe to %s: %v", topic, token.Error()) @@ -135,7 +271,17 @@ func onConnectFn(label string) mq.OnConnectHandler { if label != "cloud" { return } + // Copy under the lock: Subscribe can be registering a handler from + // another goroutine while the connection completes, and resubscribing + // holds the token wait for as long as the broker takes to ack. + subscribedTopicsMu.RLock() + topics := make(map[string]mq.MessageHandler, len(subscribedTopics)) for topic, handler := range subscribedTopics { + topics[topic] = handler + } + subscribedTopicsMu.RUnlock() + + for topic, handler := range topics { if token := client.Subscribe(topic, 0, handler); token.Wait() && token.Error() != nil { utils.SugarLogger.Errorf("[MQ][cloud] Failed to resubscribe to %s: %v", topic, token.Error()) continue diff --git a/relay/service/can.go b/relay/service/can.go index 0f26898..4280538 100644 --- a/relay/service/can.go +++ b/relay/service/can.go @@ -2,6 +2,7 @@ package service import ( "encoding/binary" + "errors" "fmt" "net" "relay/config" @@ -13,6 +14,17 @@ import ( cmap "github.com/orcaman/concurrent-map/v2" ) +// encodePayload builds the Mapache wire format shared by every message the +// relay publishes: [0:8] timestamp u64 BE µs | [8:10] upload key u16 BE | +// [10:] message data. +func encodePayload(timestamp uint64, data []byte) []byte { + buf := make([]byte, 10+len(data)) + binary.BigEndian.PutUint64(buf[0:8], timestamp) + binary.BigEndian.PutUint16(buf[8:10], config.VehicleUploadKey) + copy(buf[10:], data) + return buf +} + // PublishData persists a frame and fans it out to both brokers. // // Topic format: p987/{vehicle_id}/{bus}/0x{can_id_hex}. The bus label @@ -20,28 +32,36 @@ import ( // pure function of the arbitration ID, so the only routing info the ID // can't carry is which physical bus it came from. // -// MQTT payload: [0:8] timestamp u64 BE µs | [8:10] upload key u16 BE | -// [10:] raw CAN data. QoS 0, retain=false — durability comes from the -// database write, which happens before any throttle check so every frame -// is persisted regardless of connectivity. +// Runs inline on the caller's goroutine: every step is a channel send or a +// map operation, and both the database write and each broker publish are +// bounded queues drained by their own workers. The database write happens +// before any throttle check so every frame is persisted regardless of +// connectivity. func PublishData(busLabel string, canID uint32, data []byte) { topic := fmt.Sprintf("%s/%s/%s/0x%03x", config.TopicRoot, config.VehicleID, busLabel, canID) timestamp := uint64(time.Now().UnixMicro()) QueueDBWrite(int(timestamp), config.VehicleID, topic, data, busLabel, "") - buf := make([]byte, 10+len(data)) - binary.BigEndian.PutUint64(buf[0:8], timestamp) - binary.BigEndian.PutUint16(buf[8:10], config.VehicleUploadKey) - copy(buf[10:], data) - // Throttle key includes the bus label — the same 11-bit ID can exist // on two physical buses with independent ID spaces. throttleKey := fmt.Sprintf("%s/%d", busLabel, canID) - if shouldPublishOn(throttleKey, timestamp, config.LastLocalPublish, config.LocalPublishIntervalInt) { + toLocal := mqtt.LocalEnabled() && + shouldPublishOn(throttleKey, timestamp, config.LastLocalPublish, config.LocalPublishIntervalInt) + toCloud := mqtt.CloudEnabled() && + shouldPublishOn(throttleKey, timestamp, config.LastCloudPublish, config.CloudPublishIntervalInt) + + // Most frames are throttled out, and an unconfigured broker consumes + // none at all — don't pay for the payload until someone wants it. + if !toLocal && !toCloud { + return + } + + buf := encodePayload(timestamp, data) + if toLocal { mqtt.PublishLocal(topic, 0, false, buf) } - if shouldPublishOn(throttleKey, timestamp, config.LastCloudPublish, config.CloudPublishIntervalInt) { + if toCloud { mqtt.PublishCloud(topic, 0, false, buf) } } @@ -62,16 +82,34 @@ func shouldPublishOn( return true } -// ListenVirtualCAN binds a UDP port and dispatches synthetic CAN frames -// from on-tcm software services (e.g. shelter heartbeats) through -// PublishData under the "tcm" bus label. Wire format is TCM-26's 72-byte -// struct: +// virtualCANFrameSize is TCM-26's UDP wire format for synthetic frames: // // [0:4] CAN ID u32 LE // [4] bus u8 (ignored — virtual frames are always bus "tcm") // [5] length u8 (actual byte count, 0-64) // [6:70] data // [70:72] alignment padding +const virtualCANFrameSize = 72 + +func parseVirtualCANFrame(packet []byte) (canID uint32, data []byte, err error) { + if len(packet) < virtualCANFrameSize { + return 0, nil, fmt.Errorf("invalid packet size: expected at least %d bytes, got %d", virtualCANFrameSize, len(packet)) + } + + canID = binary.LittleEndian.Uint32(packet[0:4]) + length := int(packet[5]) + if length > 64 || 6+length > len(packet) { + return 0, nil, fmt.Errorf("payload length %d exceeds packet size %d", length, len(packet)) + } + + data = make([]byte, length) + copy(data, packet[6:6+length]) + return canID, data, nil +} + +// ListenVirtualCAN binds a UDP port and dispatches synthetic CAN frames +// from on-tcm software services (e.g. shelter heartbeats) through +// PublishData under the "tcm" bus label. func ListenVirtualCAN(port string) { shouldLog := config.Env == "DEV" @@ -90,10 +128,13 @@ func ListenVirtualCAN(port string) { defer conn.Close() utils.SugarLogger.Infof("[VCAN:%s] listening", port) + buffer := make([]byte, 1024) for { - buffer := make([]byte, 1024) n, remoteAddr, err := conn.ReadFromUDP(buffer) if err != nil { + if errors.Is(err, net.ErrClosed) { + return + } utils.SugarLogger.Errorf("[VCAN:%s] Error reading from UDP: %v", port, err) continue } @@ -101,25 +142,16 @@ func ListenVirtualCAN(port string) { utils.SugarLogger.Infof("[VCAN:%s] Received %d bytes from %s", port, n, remoteAddr.String()) } - if n < 72 { - utils.SugarLogger.Infof("[VCAN:%s] Invalid packet size: expected at least 72 bytes, got %d", port, n) - continue - } - - canID := binary.LittleEndian.Uint32(buffer[0:4]) - length := int(buffer[5]) - if length > 64 || length+6 > n { - utils.SugarLogger.Infof("[VCAN:%s] Payload length %d exceeds packet size %d, skipping", port, length, n) + canID, data, err := parseVirtualCANFrame(buffer[:n]) + if err != nil { + utils.SugarLogger.Infof("[VCAN:%s] %v, skipping", port, err) continue } - payload := make([]byte, length) - copy(payload, buffer[6:6+length]) - if shouldLog { - utils.SugarLogger.Infof("[VCAN:%s] CAN ID: 0x%03x, Length: %d", port, canID, length) + utils.SugarLogger.Infof("[VCAN:%s] CAN ID: 0x%03x, Length: %d", port, canID, len(data)) } - go PublishData(config.VirtualBusLabel, canID, payload) + PublishData(config.VirtualBusLabel, canID, data) } } diff --git a/relay/service/ping.go b/relay/service/ping.go index a0fc624..19088ef 100644 --- a/relay/service/ping.go +++ b/relay/service/ping.go @@ -87,10 +87,7 @@ func PublishPing() { topic := fmt.Sprintf("%s/%s/tcm/ping", config.TopicRoot, config.VehicleID) micros := time.Now().UnixMicro() go CreatePing(int(micros)) - payload := make([]byte, 10) - binary.BigEndian.PutUint64(payload[0:8], uint64(micros)) - binary.BigEndian.PutUint16(payload[8:10], config.VehicleUploadKey) - mqtt.Publish(topic, 0, false, payload) + mqtt.Publish(topic, 0, false, encodePayload(uint64(micros), nil)) } func CreatePing(ping int) { diff --git a/relay/service/socketcan_linux.go b/relay/service/socketcan_linux.go index 3807851..46083b9 100644 --- a/relay/service/socketcan_linux.go +++ b/relay/service/socketcan_linux.go @@ -19,13 +19,14 @@ const canFrameSize = 16 const reopenBackoff = 5 * time.Second -// RunSocketCAN starts one reader per configured interface and blocks -// forever so main has something to park on. -func RunSocketCAN() { +// StartSocketCAN starts one reader per configured interface and returns. +// The readers run until the process exits — there is no clean way to +// interrupt a blocking recv on a raw CAN socket, and nothing downstream +// needs them stopped before the database queue drains. +func StartSocketCAN() { for _, iface := range config.CANInterfaces { go runReader(iface) } - select {} } // runReader reads frames until an error, then reopens after a backoff — @@ -71,22 +72,38 @@ func readFrames(iface config.CANInterface) error { continue } - rawID := binary.LittleEndian.Uint32(frame[0:4]) - if rawID&(unix.CAN_RTR_FLAG|unix.CAN_ERR_FLAG) != 0 { + canID, data, ok := parseCANFrame(frame) + if !ok { continue } - canID := rawID & unix.CAN_SFF_MASK - if rawID&unix.CAN_EFF_FLAG != 0 { - canID = rawID & unix.CAN_EFF_MASK - } - length := int(frame[4]) - if length > 8 { - length = 8 - } - data := make([]byte, length) - copy(data, frame[8:8+length]) + // Inline, not `go`: PublishData only touches bounded queues, and a + // goroutine per frame would put the whole bus through the scheduler. + PublishData(iface.Label, canID, data) + } +} + +// parseCANFrame decodes a classic can_frame, reporting ok=false for RTR +// and error frames, which carry no telemetry. +func parseCANFrame(frame []byte) (canID uint32, data []byte, ok bool) { + if len(frame) < canFrameSize { + return 0, nil, false + } + + rawID := binary.LittleEndian.Uint32(frame[0:4]) + if rawID&(unix.CAN_RTR_FLAG|unix.CAN_ERR_FLAG) != 0 { + return 0, nil, false + } + canID = rawID & unix.CAN_SFF_MASK + if rawID&unix.CAN_EFF_FLAG != 0 { + canID = rawID & unix.CAN_EFF_MASK + } - go PublishData(iface.Label, canID, data) + length := int(frame[4]) + if length > 8 { + length = 8 } + data = make([]byte, length) + copy(data, frame[8:8+length]) + return canID, data, true } diff --git a/relay/service/socketcan_stub.go b/relay/service/socketcan_stub.go index 02a200a..de4089f 100644 --- a/relay/service/socketcan_stub.go +++ b/relay/service/socketcan_stub.go @@ -7,9 +7,8 @@ import ( "relay/utils" ) -func RunSocketCAN() { +func StartSocketCAN() { if len(config.CANInterfaces) > 0 { utils.SugarLogger.Warnf("socketcan requires linux; ignoring CAN_INTERFACES (%d configured)", len(config.CANInterfaces)) } - select {} } diff --git a/relay/service/tcm_status.go b/relay/service/tcm_status.go index 27edfd2..e2a24d1 100644 --- a/relay/service/tcm_status.go +++ b/relay/service/tcm_status.go @@ -37,37 +37,40 @@ func InitializeTCMStatus() { func publishTCMStatus() { inet, mqttOK, clock, lastPongAt, lastPongRTT := state.snapshot() - mapacheOK := !lastPongAt.IsZero() && time.Since(lastPongAt) < mapachePongFreshness() - var statusBits byte + bits := statusBits(inet, mqttOK, mapacheOK, clock) + + topic := fmt.Sprintf("%s/%s/tcm/0x200", config.TopicRoot, config.VehicleID) + mqtt.Publish(topic, 0, false, encodePayload(uint64(time.Now().UnixMicro()), encodeTCMStatus(bits, lastPongRTT))) + utils.SugarLogger.Debugf("[TCM] published status: bits=%08b latency=%dms", bits, lastPongRTT) +} + +func statusBits(inet, mqttOK, mapacheOK, clock bool) byte { + var bits byte if inet { - statusBits |= tcmStatusConnectionOK + bits |= tcmStatusConnectionOK } if mqttOK { - statusBits |= tcmStatusMQTTOK + bits |= tcmStatusMQTTOK } if mapacheOK { - statusBits |= tcmStatusMapacheOK + bits |= tcmStatusMapacheOK } if clock { - statusBits |= tcmStatusClockOK + bits |= tcmStatusClockOK } + return bits +} - // TCM Status payload layout (8 bytes): - // [0] status_bits - // [1:3] mapache_ping (u16, ms, little-endian) - // [3:8] reserved - dataPayload := make([]byte, 8) - dataPayload[0] = statusBits - binary.LittleEndian.PutUint16(dataPayload[1:3], lastPongRTT) - - payload := make([]byte, 10, 18) - binary.BigEndian.PutUint64(payload[0:8], uint64(time.Now().UnixMicro())) - binary.BigEndian.PutUint16(payload[8:10], config.VehicleUploadKey) - payload = append(payload, dataPayload...) - - topic := fmt.Sprintf("%s/%s/tcm/0x200", config.TopicRoot, config.VehicleID) - mqtt.Publish(topic, 0, false, payload) - utils.SugarLogger.Debugf("[TCM] published status: bits=%08b latency=%dms", statusBits, lastPongRTT) +// TCM Status data layout (8 bytes): +// +// [0] status_bits +// [1:3] mapache_ping (u16, ms, little-endian) +// [3:8] reserved +func encodeTCMStatus(bits byte, pingMs uint16) []byte { + data := make([]byte, 8) + data[0] = bits + binary.LittleEndian.PutUint16(data[1:3], pingMs) + return data } From 7e164cbd6c49c29642a1827711c7d93669d44f35 Mon Sep 17 00:00:00 2001 From: Bharat Kathi Date: Sat, 29 Aug 2026 09:03:05 -0700 Subject: [PATCH 06/11] feat!: rework resource metrics for the Pi Zero 2 W MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 0x201 layout was inherited from TCM-26's Jetson and carried fields the Pi cannot produce: two extra CPU cores, GPU utilization, frequency and temperature, and three power-rail readings. Sharing the Mapache decoder was the stated reason to keep them, but no p987 decoder exists yet, so the compatibility is with nothing. Cut the layout to what the board actually reports — 4 cores, no GPU, no power rails — and spend one of the freed bytes on the failure mode this hardware really has. Under-voltage on a Pi corrupts SD cards and is invisible in every other metric. The firmware get_throttled word is the only place the live throttle bits are exposed, since the Pi throttles in firmware rather than through the kernel thermal governor. That attribute is deprecated upstream, so fall back to the rpi_volt hwmon alarm, which carries the sticky under-voltage bit only (BIT(16)) and nothing thermal. Saturate the encoded fields rather than truncating: a bogus reading should pin a field, not alias to a plausible small number on a dashboard. BREAKING CHANGE: the 0x201 payload is now 29 bytes with a new field order. Any decoder written against the 44-byte TCM-26 layout must be updated. --- relay/model/resources.go | 59 ++++++++-------- relay/service/resources.go | 113 +++++++++++++++++++++---------- relay/service/resources_linux.go | 92 ++++++++++++++++++++++--- 3 files changed, 189 insertions(+), 75 deletions(-) diff --git a/relay/model/resources.go b/relay/model/resources.go index 7c9a4f7..5d09310 100644 --- a/relay/model/resources.go +++ b/relay/model/resources.go @@ -1,33 +1,34 @@ package model -// ResourceMetrics keeps TCM-26's 44-byte 0x201 wire layout so the -// Mapache-side decoder can be shared. Fields with no Pi equivalent -// (GPU, power rails, CPUs 4-5 on a quad-core) stay zero. +// ReportedCPUs is the core count carried in the 0x201 layout. The Pi Zero +// 2 W's BCM2710A1 is quad-core. +const ReportedCPUs = 4 + +// Throttle flags, published in the 0x201 throttle byte. Sourced from the +// Pi firmware's get_throttled word — thermal management on the Pi happens +// in firmware, not in the kernel thermal governor, so this is the only +// place the state is observable. +const ( + ThrottleUndervoltage = 1 << 0 // under-voltage right now + ThrottleUndervoltageSince = 1 << 1 // under-voltage has occurred since boot + ThrottleThermal = 1 << 2 // frequency throttled right now + ThrottleThermalSince = 1 << 3 // throttling has occurred since boot +) + +// ResourceMetrics is what a Pi Zero 2 W can actually report. The board has +// no discrete GPU counters and no power-rail sensors, so unlike TCM-26's +// Jetson layout there are no GPU or voltage/current/power fields — the +// undervoltage flags are the useful signal in their place. type ResourceMetrics struct { - CPU0Freq int `json:"cpu_0_freq"` // 2 bytes, MHz - CPU0Util int `json:"cpu_0_util"` // 1 byte, % - CPU1Freq int `json:"cpu_1_freq"` // 2 bytes - CPU1Util int `json:"cpu_1_util"` // 1 byte - CPU2Freq int `json:"cpu_2_freq"` // 2 bytes - CPU2Util int `json:"cpu_2_util"` // 1 byte - CPU3Freq int `json:"cpu_3_freq"` // 2 bytes - CPU3Util int `json:"cpu_3_util"` // 1 byte - CPU4Freq int `json:"cpu_4_freq"` // 2 bytes - CPU4Util int `json:"cpu_4_util"` // 1 byte - CPU5Freq int `json:"cpu_5_freq"` // 2 bytes - CPU5Util int `json:"cpu_5_util"` // 1 byte - CPUTotalUtil int `json:"cpu_total_util"` // 1 byte, % - RAMTotal int `json:"ram_total"` // 2 bytes, MB - RAMUsed int `json:"ram_used"` // 2 bytes, MB - RAMUtil int `json:"ram_util"` // 1 byte, % - GPUUtil int `json:"gpu_util"` // 1 byte - GPUFreq int `json:"gpu_freq"` // 2 bytes - DiskTotal int `json:"disk_total"` // 4 bytes, MB - DiskUsed int `json:"disk_used"` // 4 bytes, MB - DiskUtil int `json:"disk_util"` // 1 byte, % - CPUTemp int `json:"cpu_temp"` // 1 byte, °C - GPUTemp int `json:"gpu_temp"` // 1 byte - VoltageDraw int `json:"voltage_draw"` // 2 bytes - CurrentDraw int `json:"current_draw"` // 2 bytes - PowerDraw int `json:"power_draw"` // 2 bytes + CPUFreq [ReportedCPUs]int `json:"cpu_freq"` // 2 bytes each, MHz + CPUUtil [ReportedCPUs]int `json:"cpu_util"` // 1 byte each, % + CPUTotalUtil int `json:"cpu_total_util"` // 1 byte, % + RAMTotal int `json:"ram_total"` // 2 bytes, MB + RAMUsed int `json:"ram_used"` // 2 bytes, MB + RAMUtil int `json:"ram_util"` // 1 byte, % + DiskTotal int `json:"disk_total"` // 4 bytes, MB + DiskUsed int `json:"disk_used"` // 4 bytes, MB + DiskUtil int `json:"disk_util"` // 1 byte, % + CPUTemp int `json:"cpu_temp"` // 1 byte, °C + ThrottleFlags int `json:"throttle_flags"` // 1 byte } diff --git a/relay/service/resources.go b/relay/service/resources.go index 285d5f5..3722bd5 100644 --- a/relay/service/resources.go +++ b/relay/service/resources.go @@ -32,43 +32,84 @@ func InitializeResourceQuery() { }() } -// PublishResources keeps TCM-26's 44-byte 0x201 layout so the -// Mapache-side decoder can be shared across both TCMs. -func PublishResources(metrics model.ResourceMetrics) { - topic := fmt.Sprintf("%s/%s/tcm/0x201", config.TopicRoot, config.VehicleID) +// resourcePayloadSize is the 0x201 data length: +// +// [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 resourcePayloadSize = 29 + +func encodeResourcePayload(m model.ResourceMetrics) []byte { + data := make([]byte, resourcePayloadSize) + + off := 0 + for i := 0; i < model.ReportedCPUs; i++ { + binary.LittleEndian.PutUint16(data[off:off+2], clampU16(m.CPUFreq[i])) + data[off+2] = clampU8(m.CPUUtil[i]) + off += 3 + } - dataPayload := make([]byte, 44) - binary.LittleEndian.PutUint16(dataPayload[:2], uint16(metrics.CPU0Freq)) - dataPayload[2] = byte(metrics.CPU0Util) - binary.LittleEndian.PutUint16(dataPayload[3:5], uint16(metrics.CPU1Freq)) - dataPayload[5] = byte(metrics.CPU1Util) - binary.LittleEndian.PutUint16(dataPayload[6:8], uint16(metrics.CPU2Freq)) - dataPayload[8] = byte(metrics.CPU2Util) - binary.LittleEndian.PutUint16(dataPayload[9:11], uint16(metrics.CPU3Freq)) - dataPayload[11] = byte(metrics.CPU3Util) - binary.LittleEndian.PutUint16(dataPayload[12:14], uint16(metrics.CPU4Freq)) - dataPayload[14] = byte(metrics.CPU4Util) - binary.LittleEndian.PutUint16(dataPayload[15:17], uint16(metrics.CPU5Freq)) - dataPayload[17] = byte(metrics.CPU5Util) - dataPayload[18] = byte(metrics.CPUTotalUtil) - binary.LittleEndian.PutUint16(dataPayload[19:21], uint16(metrics.RAMTotal)) - binary.LittleEndian.PutUint16(dataPayload[21:23], uint16(metrics.RAMUsed)) - dataPayload[23] = byte(metrics.RAMUtil) - dataPayload[24] = byte(metrics.GPUUtil) - binary.LittleEndian.PutUint16(dataPayload[25:27], uint16(metrics.GPUFreq)) - binary.LittleEndian.PutUint32(dataPayload[27:31], uint32(metrics.DiskTotal)) - binary.LittleEndian.PutUint32(dataPayload[31:35], uint32(metrics.DiskUsed)) - dataPayload[35] = byte(metrics.DiskUtil) - dataPayload[36] = byte(metrics.CPUTemp) - dataPayload[37] = byte(metrics.GPUTemp) - binary.LittleEndian.PutUint16(dataPayload[38:40], uint16(metrics.VoltageDraw)) - binary.LittleEndian.PutUint16(dataPayload[40:42], uint16(metrics.CurrentDraw)) - binary.LittleEndian.PutUint16(dataPayload[42:44], uint16(metrics.PowerDraw)) + data[off] = clampU8(m.CPUTotalUtil) + off++ + binary.LittleEndian.PutUint16(data[off:off+2], clampU16(m.RAMTotal)) + off += 2 + binary.LittleEndian.PutUint16(data[off:off+2], clampU16(m.RAMUsed)) + off += 2 + data[off] = clampU8(m.RAMUtil) + off++ + binary.LittleEndian.PutUint32(data[off:off+4], clampU32(m.DiskTotal)) + off += 4 + binary.LittleEndian.PutUint32(data[off:off+4], clampU32(m.DiskUsed)) + off += 4 + data[off] = clampU8(m.DiskUtil) + off++ + data[off] = clampU8(m.CPUTemp) + off++ + data[off] = clampU8(m.ThrottleFlags) - payload := make([]byte, 10, 54) - binary.BigEndian.PutUint64(payload[0:8], uint64(time.Now().UnixMicro())) - binary.BigEndian.PutUint16(payload[8:10], config.VehicleUploadKey) - payload = append(payload, dataPayload...) + return data +} + +// Saturate rather than wrap: a bogus reading should pin the field, not +// alias to a plausible-looking small number on the dashboard. +func clampU8(v int) byte { + if v < 0 { + return 0 + } + if v > 255 { + return 255 + } + return byte(v) +} - mqtt.Publish(topic, 0, false, payload) +func clampU16(v int) uint16 { + if v < 0 { + return 0 + } + if v > 65535 { + return 65535 + } + return uint16(v) +} + +func clampU32(v int) uint32 { + if v < 0 { + return 0 + } + if v > 4294967295 { + return 4294967295 + } + return uint32(v) +} + +func PublishResources(metrics model.ResourceMetrics) { + topic := fmt.Sprintf("%s/%s/tcm/0x201", config.TopicRoot, config.VehicleID) + mqtt.Publish(topic, 0, false, encodePayload(uint64(time.Now().UnixMicro()), encodeResourcePayload(metrics))) } diff --git a/relay/service/resources_linux.go b/relay/service/resources_linux.go index 4da5a88..df6e9fa 100644 --- a/relay/service/resources_linux.go +++ b/relay/service/resources_linux.go @@ -15,8 +15,6 @@ import ( "golang.org/x/sys/unix" ) -const maxReportedCPUs = 6 - type cpuSample struct { total uint64 idle uint64 @@ -26,9 +24,8 @@ var cpuSampleMu sync.Mutex var prevCPUSamples map[string]cpuSample // QueryResourceMetrics reads Pi resource stats straight from /proc and -// /sys — no jtop equivalent needed. CPU utilization is the delta since -// the previous call (the 10s poll cadence is the smoothing window), so -// the first call reports 0%. +// /sys. CPU utilization is the delta since the previous call (the 10s poll +// cadence is the smoothing window), so the first call reports 0%. func QueryResourceMetrics() (model.ResourceMetrics, error) { var m model.ResourceMetrics @@ -37,13 +34,11 @@ func QueryResourceMetrics() (model.ResourceMetrics, error) { return m, fmt.Errorf("cpu utilization: %w", err) } m.CPUTotalUtil = total - perCore := [maxReportedCPUs]*int{&m.CPU0Util, &m.CPU1Util, &m.CPU2Util, &m.CPU3Util, &m.CPU4Util, &m.CPU5Util} - perFreq := [maxReportedCPUs]*int{&m.CPU0Freq, &m.CPU1Freq, &m.CPU2Freq, &m.CPU3Freq, &m.CPU4Freq, &m.CPU5Freq} - for i := 0; i < maxReportedCPUs; i++ { + for i := 0; i < model.ReportedCPUs; i++ { if i < len(utils) { - *perCore[i] = utils[i] + m.CPUUtil[i] = utils[i] } - *perFreq[i] = readCPUFreqMHz(i) + m.CPUFreq[i] = readCPUFreqMHz(i) } ramTotal, ramUsed, err := readMemInfo() @@ -64,6 +59,7 @@ func QueryResourceMetrics() (model.ResourceMetrics, error) { } m.CPUTemp = readCPUTemp() + m.ThrottleFlags = readThrottleFlags() return m, nil } @@ -189,3 +185,79 @@ func readCPUTemp() int { } return milli / 1000 } + +// Firmware get_throttled bit positions (raspberrypi.com/documentation +// "vcgencmd get_throttled"). The low nibble is live state, bits 16+ are +// sticky since boot. +const ( + fwUndervoltage = 1 << 0 + fwThrottled = 1 << 2 + fwUndervoltageSince = 1 << 16 + fwThrottledSince = 1 << 18 +) + +const ( + throttledPath = "/sys/devices/platform/soc/soc:firmware/get_throttled" + hwmonGlob = "/sys/class/hwmon/hwmon*/in0_lcrit_alarm" +) + +// readThrottleFlags reports under-voltage and thermal throttling. On the +// Pi both are handled in firmware rather than by the kernel thermal +// governor, so the firmware's get_throttled word is the only place the +// live bits are visible. That attribute is deprecated upstream, so fall +// back to the rpi_volt hwmon alarm — which exposes only the sticky +// under-voltage bit, nothing thermal and nothing live. +func readThrottleFlags() int { + raw, err := readThrottledWord() + if err != nil { + return readUndervoltageAlarm() + } + + var flags int + if raw&fwUndervoltage != 0 { + flags |= model.ThrottleUndervoltage + } + if raw&fwUndervoltageSince != 0 { + flags |= model.ThrottleUndervoltageSince + } + if raw&fwThrottled != 0 { + flags |= model.ThrottleThermal + } + if raw&fwThrottledSince != 0 { + flags |= model.ThrottleThermalSince + } + return flags +} + +func readThrottledWord() (uint64, error) { + data, err := os.ReadFile(throttledPath) + if err != nil { + return 0, err + } + // The attribute is printed as bare hex; vcgencmd renders the same word + // with an 0x prefix, so tolerate both. + text := strings.TrimPrefix(strings.TrimSpace(string(data)), "0x") + return strconv.ParseUint(text, 16, 64) +} + +func readUndervoltageAlarm() int { + matches, err := filepath.Glob(hwmonGlob) + if err != nil { + return 0 + } + for _, path := range matches { + name, err := os.ReadFile(filepath.Join(filepath.Dir(path), "name")) + if err != nil || strings.TrimSpace(string(name)) != "rpi_volt" { + continue + } + data, err := os.ReadFile(path) + if err != nil { + continue + } + if strings.TrimSpace(string(data)) == "1" { + return model.ThrottleUndervoltageSince + } + return 0 + } + return 0 +} From b9799b46f99bfadacb4a5970be6c7314b3454926 Mon Sep 17 00:00:00 2001 From: Bharat Kathi Date: Sat, 29 Aug 2026 09:03:19 -0700 Subject: [PATCH 07/11] fix: surface broker and database failures instead of dropping them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit paho's loggers default to NOOPLogger, and the cloud client sets ConnectRetry, so a wrong host, port, or credential retried forever in complete silence — the only symptom was pongs that never arrived. Route paho's errors into zap, dropping the stacktrace (always the same paho internals) and sampling to one line per message per minute, since an unreachable broker retries every 5s indefinitely. Fail fast when a broker host is set without a port, which previously produced a "tcp://host:" URL, and log both endpoints at startup so the configured target is visible rather than inferred. Log the database path resolved rather than raw. The default is relative and lands wherever the workdir points, which is what made the volume bug above hard to see. Silence GORM's "record not found", which is a normal result here: the ping watchdog polls for a successful pong every couple of seconds and finds none until the car first reaches Mapache. At the default level that wrote a colorized SQL dump to the SD card on every poll, forever, while the car was offline. Drop the ANSI color outside DEV too. --- relay/database/db.go | 18 ++++++++++++++++-- relay/utils/config.go | 30 +++++++++++++++++++++++++++++- 2 files changed, 45 insertions(+), 3 deletions(-) diff --git a/relay/database/db.go b/relay/database/db.go index 23d2ca9..408f4b8 100644 --- a/relay/database/db.go +++ b/relay/database/db.go @@ -2,9 +2,12 @@ package database import ( "fmt" + "log" + "os" "relay/config" "relay/model" "relay/utils" + "time" "github.com/glebarez/sqlite" cmap "github.com/orcaman/concurrent-map/v2" @@ -58,11 +61,22 @@ func Close() { utils.SugarLogger.Infoln("[DB] Closed") } +// gormLogger silences "record not found", which is a normal result here — +// the ping watchdog polls for a successful pong every few seconds and gets +// none until the car first reaches Mapache. Left at the default it writes +// a colorized SQL dump to the SD card on every poll, forever, while the +// car is offline. func gormLogger() logger.Interface { + level := logger.Error if config.Env == "DEV" { - return logger.Default.LogMode(logger.Warn) + level = logger.Warn } - return logger.Default.LogMode(logger.Error) + return logger.New(log.New(os.Stdout, "", log.LstdFlags), logger.Config{ + SlowThreshold: 200 * time.Millisecond, + LogLevel: level, + IgnoreRecordNotFoundError: true, + Colorful: config.Env == "DEV", + }) } func InitializeMap() { diff --git a/relay/utils/config.go b/relay/utils/config.go index 42c5fb8..e3596f6 100644 --- a/relay/utils/config.go +++ b/relay/utils/config.go @@ -1,6 +1,7 @@ package utils import ( + "path/filepath" "relay/config" "strconv" "time" @@ -32,21 +33,48 @@ func VerifyConfig() { config.DBBatchSize = parseIntWithFallback(config.DBBatchSizeRaw, 5000, "DB_BATCH_SIZE") config.RetentionHours = parseIntWithFallback(config.RetentionHoursRaw, 72, "RETENTION_HOURS") + if config.LocalMQTTHost != "" && config.LocalMQTTPort == "" { + SugarLogger.Fatalln("LOCAL_MQTT_HOST is set but LOCAL_MQTT_PORT is not") + } + if config.CloudMQTTHost != "" && config.CloudMQTTPort == "" { + SugarLogger.Fatalln("CLOUD_MQTT_HOST is set but CLOUD_MQTT_PORT is not") + } + if len(config.CANInterfaces) == 0 && len(config.VirtualCANPorts) == 0 { SugarLogger.Warnln("No CAN_INTERFACES or VIRTUAL_CAN_PORTS configured — relay has no frame sources") } SugarLogger.Infof("Vehicle ID: %s", config.VehicleID) SugarLogger.Infof("Vehicle Upload Key: %d", config.VehicleUploadKey) - SugarLogger.Infof("Database Path: %s", config.DatabasePath) + // Resolved, not raw: the default is relative and lands wherever the + // workdir points (inside /data in the image), which is not obvious + // from the configured value alone. + SugarLogger.Infof("Database Path: %s", resolvedPath(config.DatabasePath)) for _, iface := range config.CANInterfaces { SugarLogger.Infof("CAN Interface: %s (bus %s)", iface.Name, iface.Label) } + SugarLogger.Infof("Local Broker: %s", brokerEndpoint(config.LocalMQTTHost, config.LocalMQTTPort)) + SugarLogger.Infof("Cloud Broker: %s", brokerEndpoint(config.CloudMQTTHost, config.CloudMQTTPort)) SugarLogger.Infof("Local Publish Interval: %dms", config.LocalPublishIntervalInt) SugarLogger.Infof("Cloud Publish Interval: %dms", config.CloudPublishIntervalInt) SugarLogger.Infof("Ping Interval: %s", config.PingInterval) } +func resolvedPath(path string) string { + abs, err := filepath.Abs(path) + if err != nil { + return path + } + return abs +} + +func brokerEndpoint(host, port string) string { + if host == "" { + return "disabled" + } + return host + ":" + port +} + func parseIntWithFallback(raw string, fallback int, name string) int { if raw == "" { return fallback From 677e9d464abfeaf1d2afaf71134c24002e2f5d0e Mon Sep 17 00:00:00 2001 From: Bharat Kathi Date: Sat, 29 Aug 2026 09:04:06 -0700 Subject: [PATCH 08/11] fix: reject vehicle ids and bus labels that break topic shape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vehicle identity travels in the topic, not the payload, and Mapache's ingest splits on "/" and requires exactly four segments, reading the vehicle from segment 1. A VEHICLE_ID or bus label containing a slash shifts every field and the message is dropped on arrival — silently, because we publish at QoS 0 and never learn it was rejected. The car would buffer to SQLite and upload nothing. MQTT also forbids wildcards in a topic being published to. Refuse to start rather than sanitizing: a silently renamed vehicle scatters data under the wrong id, which is worse than not booting. --- relay/utils/config.go | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/relay/utils/config.go b/relay/utils/config.go index e3596f6..c4b7b8b 100644 --- a/relay/utils/config.go +++ b/relay/utils/config.go @@ -1,9 +1,12 @@ package utils import ( + "errors" + "fmt" "path/filepath" "relay/config" "strconv" + "strings" "time" ) @@ -11,6 +14,9 @@ func VerifyConfig() { if config.VehicleID == "" { SugarLogger.Fatalln("VEHICLE_ID is not set") } + if err := validTopicSegment(config.VehicleID); err != nil { + SugarLogger.Fatalf("VEHICLE_ID is not usable in a topic: %v", err) + } key, err := strconv.Atoi(config.VehicleUploadKeyString) if err != nil { SugarLogger.Fatalln("VEHICLE_UPLOAD_KEY is not a number") @@ -33,6 +39,12 @@ func VerifyConfig() { config.DBBatchSize = parseIntWithFallback(config.DBBatchSizeRaw, 5000, "DB_BATCH_SIZE") config.RetentionHours = parseIntWithFallback(config.RetentionHoursRaw, 72, "RETENTION_HOURS") + for _, iface := range config.CANInterfaces { + if err := validTopicSegment(iface.Label); err != nil { + SugarLogger.Fatalf("CAN_INTERFACES bus label %q is not usable in a topic: %v", iface.Label, err) + } + } + if config.LocalMQTTHost != "" && config.LocalMQTTPort == "" { SugarLogger.Fatalln("LOCAL_MQTT_HOST is set but LOCAL_MQTT_PORT is not") } @@ -60,6 +72,27 @@ func VerifyConfig() { SugarLogger.Infof("Ping Interval: %s", config.PingInterval) } +// validTopicSegment rejects values that would change the shape of a +// published topic. Mapache's ingest requires exactly four segments and +// reads the vehicle from segment 1, so a "/" here shifts every field and +// the ingest drops the message — silently, since we publish at QoS 0 and +// never learn it was rejected. MQTT also forbids wildcards in a topic +// being published to. +func validTopicSegment(s string) error { + if s == "" { + return errors.New("must not be empty") + } + for _, bad := range []string{"/", "+", "#"} { + if strings.Contains(s, bad) { + return fmt.Errorf("must not contain %q", bad) + } + } + if strings.TrimSpace(s) != s || strings.ContainsAny(s, " \t\r\n") { + return errors.New("must not contain whitespace") + } + return nil +} + func resolvedPath(path string) string { abs, err := filepath.Abs(path) if err != nil { From a0d47b3cfc35c59fedfb09b734ba4cce70f6b38c Mon Sep 17 00:00:00 2001 From: Bharat Kathi Date: Sat, 29 Aug 2026 09:04:06 -0700 Subject: [PATCH 09/11] test: cover wire formats, frame parsing, and config validation The encoders and parsers are pure functions over bytes and are decoded downstream by fixed offset, so they are the parts worth pinning. Covers the shared message header, the 0x200 and 0x201 payloads, the virtual CAN and socketcan frame parsers, the publish throttle, interface and port parsing, and the topic-shape guard. Includes cases for the two bugs that motivated the parsers being extracted: that parsed frame data is copied out of a reused read buffer, and that a CAN FD DLC on a classic socket is clamped to 8 bytes. --- relay/config/config_test.go | 60 ++++++++++++ relay/service/can_test.go | 135 ++++++++++++++++++++++++++ relay/service/clock_test.go | 17 ++++ relay/service/resources_test.go | 95 ++++++++++++++++++ relay/service/socketcan_linux_test.go | 96 ++++++++++++++++++ relay/service/tcm_status_test.go | 49 ++++++++++ relay/utils/config_test.go | 63 ++++++++++++ 7 files changed, 515 insertions(+) create mode 100644 relay/config/config_test.go create mode 100644 relay/service/can_test.go create mode 100644 relay/service/clock_test.go create mode 100644 relay/service/resources_test.go create mode 100644 relay/service/socketcan_linux_test.go create mode 100644 relay/service/tcm_status_test.go create mode 100644 relay/utils/config_test.go diff --git a/relay/config/config_test.go b/relay/config/config_test.go new file mode 100644 index 0000000..4b12df8 --- /dev/null +++ b/relay/config/config_test.go @@ -0,0 +1,60 @@ +package config + +import "testing" + +func TestParseInterfaceList(t *testing.T) { + tests := []struct { + name string + in string + want []CANInterface + }{ + {"empty", "", nil}, + {"single pair", "can0:pcan", []CANInterface{{"can0", "pcan"}}}, + {"multiple pairs", "can0:pcan,can1:kcan", []CANInterface{{"can0", "pcan"}, {"can1", "kcan"}}}, + {"bare name labels itself", "can0", []CANInterface{{"can0", "can0"}}}, + {"empty label falls back to name", "can0:", []CANInterface{{"can0", "can0"}}}, + {"surrounding whitespace", " can0:pcan , can1:kcan ", []CANInterface{{"can0", "pcan"}, {"can1", "kcan"}}}, + {"empty entries skipped", "can0:pcan,,can1:kcan", []CANInterface{{"can0", "pcan"}, {"can1", "kcan"}}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := parseInterfaceList(tt.in) + if len(got) != len(tt.want) { + t.Fatalf("parseInterfaceList(%q) = %v, want %v", tt.in, got, tt.want) + } + for i := range got { + if got[i] != tt.want[i] { + t.Errorf("interface %d = %v, want %v", i, got[i], tt.want[i]) + } + } + }) + } +} + +func TestParsePortList(t *testing.T) { + tests := []struct { + name string + in string + want []string + }{ + {"empty", "", nil}, + {"single", "8100", []string{"8100"}}, + {"multiple with whitespace", "8100, 8101", []string{"8100", "8101"}}, + {"empty entries skipped", "8100,,8101", []string{"8100", "8101"}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := parsePortList(tt.in) + if len(got) != len(tt.want) { + t.Fatalf("parsePortList(%q) = %v, want %v", tt.in, got, tt.want) + } + for i := range got { + if got[i] != tt.want[i] { + t.Errorf("port %d = %q, want %q", i, got[i], tt.want[i]) + } + } + }) + } +} diff --git a/relay/service/can_test.go b/relay/service/can_test.go new file mode 100644 index 0000000..5ad580e --- /dev/null +++ b/relay/service/can_test.go @@ -0,0 +1,135 @@ +package service + +import ( + "bytes" + "encoding/binary" + "relay/config" + "testing" + + cmap "github.com/orcaman/concurrent-map/v2" +) + +func TestEncodePayload(t *testing.T) { + config.VehicleUploadKey = 0xBEEF + data := []byte{0x01, 0x02, 0x03} + + got := encodePayload(0x0011223344556677, data) + + if len(got) != 10+len(data) { + t.Fatalf("payload length = %d, want %d", len(got), 10+len(data)) + } + if ts := binary.BigEndian.Uint64(got[0:8]); ts != 0x0011223344556677 { + t.Errorf("timestamp = %#x, want %#x", ts, uint64(0x0011223344556677)) + } + if key := binary.BigEndian.Uint16(got[8:10]); key != 0xBEEF { + t.Errorf("upload key = %#x, want %#x", key, 0xBEEF) + } + if !bytes.Equal(got[10:], data) { + t.Errorf("data = %v, want %v", got[10:], data) + } +} + +func TestEncodePayloadNoData(t *testing.T) { + config.VehicleUploadKey = 1 + if got := encodePayload(42, nil); len(got) != 10 { + t.Errorf("header-only payload length = %d, want 10", len(got)) + } +} + +func TestParseVirtualCANFrame(t *testing.T) { + frame := func(canID uint32, length byte, data []byte) []byte { + p := make([]byte, virtualCANFrameSize) + binary.LittleEndian.PutUint32(p[0:4], canID) + p[5] = length + copy(p[6:], data) + return p + } + + t.Run("valid frame", func(t *testing.T) { + canID, data, err := parseVirtualCANFrame(frame(0x210, 4, []byte{0xDE, 0xAD, 0xBE, 0xEF})) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if canID != 0x210 { + t.Errorf("canID = %#x, want %#x", canID, 0x210) + } + if !bytes.Equal(data, []byte{0xDE, 0xAD, 0xBE, 0xEF}) { + t.Errorf("data = %v, want DEADBEEF", data) + } + }) + + t.Run("zero length", func(t *testing.T) { + _, data, err := parseVirtualCANFrame(frame(0x211, 0, nil)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(data) != 0 { + t.Errorf("data length = %d, want 0", len(data)) + } + }) + + t.Run("data is copied out of the buffer", func(t *testing.T) { + packet := frame(0x210, 2, []byte{0xAA, 0xBB}) + _, data, err := parseVirtualCANFrame(packet) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + // The listener reuses its read buffer, so a retained slice would + // see the next frame's bytes. + packet[6], packet[7] = 0x00, 0x00 + if !bytes.Equal(data, []byte{0xAA, 0xBB}) { + t.Errorf("data = %v after buffer reuse, want AABB", data) + } + }) + + t.Run("short packet", func(t *testing.T) { + if _, _, err := parseVirtualCANFrame(make([]byte, virtualCANFrameSize-1)); err == nil { + t.Error("expected error for undersized packet") + } + }) + + t.Run("length beyond CAN FD max", func(t *testing.T) { + if _, _, err := parseVirtualCANFrame(frame(0x210, 65, nil)); err == nil { + t.Error("expected error for length 65") + } + }) + + t.Run("max length payload fits", func(t *testing.T) { + if _, data, err := parseVirtualCANFrame(frame(0x210, 64, nil)); err != nil { + t.Errorf("64-byte payload in a 72-byte packet should be valid: %v", err) + } else if len(data) != 64 { + t.Errorf("data length = %d, want 64", len(data)) + } + }) +} + +func TestShouldPublishOn(t *testing.T) { + last := cmap.New[uint64]() + const intervalMs = 20 + const key = "pcan/512" + + if !shouldPublishOn(key, 1_000_000, last, intervalMs) { + t.Fatal("first publish for a key should always pass") + } + if shouldPublishOn(key, 1_010_000, last, intervalMs) { + t.Error("publish 10ms later should be throttled") + } + if shouldPublishOn(key, 1_020_000, last, intervalMs) { + t.Error("publish exactly at the interval should be throttled") + } + if !shouldPublishOn(key, 1_020_001, last, intervalMs) { + t.Error("publish past the interval should pass") + } +} + +func TestShouldPublishOnKeysAreIndependent(t *testing.T) { + last := cmap.New[uint64]() + + if !shouldPublishOn("pcan/512", 1_000_000, last, 20) { + t.Fatal("first publish on pcan should pass") + } + // Same CAN ID on another bus is a different signal entirely. + if !shouldPublishOn("kcan/512", 1_000_000, last, 20) { + t.Error("first publish on kcan should not be throttled by pcan") + } +} diff --git a/relay/service/clock_test.go b/relay/service/clock_test.go new file mode 100644 index 0000000..07ba83e --- /dev/null +++ b/relay/service/clock_test.go @@ -0,0 +1,17 @@ +package service + +import ( + "testing" + "time" +) + +func TestClockPlausible(t *testing.T) { + // A Pi with no RTC and no network boots to 1970; anything at or after + // the cutoff means the clock has been set. + if !ClockPlausible() { + t.Errorf("clock should be plausible: now=%s cutoff=%s", time.Now(), minValidTime) + } + if minValidTime.After(time.Now()) { + t.Error("minValidTime is in the future — the cutoff would reject every real reading") + } +} diff --git a/relay/service/resources_test.go b/relay/service/resources_test.go new file mode 100644 index 0000000..e9d8443 --- /dev/null +++ b/relay/service/resources_test.go @@ -0,0 +1,95 @@ +package service + +import ( + "encoding/binary" + "relay/model" + "testing" +) + +func TestEncodeResourcePayload(t *testing.T) { + m := model.ResourceMetrics{ + CPUFreq: [model.ReportedCPUs]int{1000, 1001, 1002, 1003}, + CPUUtil: [model.ReportedCPUs]int{10, 20, 30, 40}, + CPUTotalUtil: 25, + RAMTotal: 512, + RAMUsed: 128, + RAMUtil: 25, + DiskTotal: 30000, + DiskUsed: 12000, + DiskUtil: 40, + CPUTemp: 55, + ThrottleFlags: model.ThrottleUndervoltageSince | model.ThrottleThermal, + } + + got := encodeResourcePayload(m) + + if len(got) != resourcePayloadSize { + t.Fatalf("payload length = %d, want %d", len(got), resourcePayloadSize) + } + + for i := 0; i < model.ReportedCPUs; i++ { + off := i * 3 + if freq := binary.LittleEndian.Uint16(got[off : off+2]); int(freq) != m.CPUFreq[i] { + t.Errorf("cpu%d freq = %d, want %d", i, freq, m.CPUFreq[i]) + } + if util := got[off+2]; int(util) != m.CPUUtil[i] { + t.Errorf("cpu%d util = %d, want %d", i, util, m.CPUUtil[i]) + } + } + + if got[12] != 25 { + t.Errorf("cpu_total_util = %d, want 25", got[12]) + } + if v := binary.LittleEndian.Uint16(got[13:15]); v != 512 { + t.Errorf("ram_total = %d, want 512", v) + } + if v := binary.LittleEndian.Uint16(got[15:17]); v != 128 { + t.Errorf("ram_used = %d, want 128", v) + } + if got[17] != 25 { + t.Errorf("ram_util = %d, want 25", got[17]) + } + if v := binary.LittleEndian.Uint32(got[18:22]); v != 30000 { + t.Errorf("disk_total = %d, want 30000", v) + } + if v := binary.LittleEndian.Uint32(got[22:26]); v != 12000 { + t.Errorf("disk_used = %d, want 12000", v) + } + if got[26] != 40 { + t.Errorf("disk_util = %d, want 40", got[26]) + } + if got[27] != 55 { + t.Errorf("cpu_temp = %d, want 55", got[27]) + } + if got[28] != model.ThrottleUndervoltageSince|model.ThrottleThermal { + t.Errorf("throttle_flags = %#04b, want %#04b", got[28], model.ThrottleUndervoltageSince|model.ThrottleThermal) + } +} + +func TestEncodeResourcePayloadSaturates(t *testing.T) { + // A Pi with no readable sensor reports 0; a bogus reading must pin the + // field rather than wrap into a plausible small number. + m := model.ResourceMetrics{ + CPUFreq: [model.ReportedCPUs]int{70000, -1, 0, 0}, + CPUUtil: [model.ReportedCPUs]int{300, -5, 0, 0}, + DiskTotal: 5_000_000_000, + } + + got := encodeResourcePayload(m) + + if v := binary.LittleEndian.Uint16(got[0:2]); v != 65535 { + t.Errorf("cpu0 freq = %d, want 65535", v) + } + if got[2] != 255 { + t.Errorf("cpu0 util = %d, want 255", got[2]) + } + if v := binary.LittleEndian.Uint16(got[3:5]); v != 0 { + t.Errorf("cpu1 freq = %d, want 0", v) + } + if got[5] != 0 { + t.Errorf("cpu1 util = %d, want 0", got[5]) + } + if v := binary.LittleEndian.Uint32(got[18:22]); v != 4294967295 { + t.Errorf("disk_total = %d, want 4294967295", v) + } +} diff --git a/relay/service/socketcan_linux_test.go b/relay/service/socketcan_linux_test.go new file mode 100644 index 0000000..2af2b1a --- /dev/null +++ b/relay/service/socketcan_linux_test.go @@ -0,0 +1,96 @@ +//go:build linux + +package service + +import ( + "bytes" + "encoding/binary" + "testing" + + "golang.org/x/sys/unix" +) + +func canFrame(rawID uint32, dlc byte, data []byte) []byte { + frame := make([]byte, canFrameSize) + binary.LittleEndian.PutUint32(frame[0:4], rawID) + frame[4] = dlc + copy(frame[8:], data) + return frame +} + +func TestParseCANFrame(t *testing.T) { + t.Run("standard id", func(t *testing.T) { + canID, data, ok := parseCANFrame(canFrame(0x123, 8, []byte{1, 2, 3, 4, 5, 6, 7, 8})) + if !ok { + t.Fatal("expected frame to parse") + } + if canID != 0x123 { + t.Errorf("canID = %#x, want 0x123", canID) + } + if !bytes.Equal(data, []byte{1, 2, 3, 4, 5, 6, 7, 8}) { + t.Errorf("data = %v", data) + } + }) + + t.Run("extended id", func(t *testing.T) { + canID, _, ok := parseCANFrame(canFrame(0x18DAF110|unix.CAN_EFF_FLAG, 0, nil)) + if !ok { + t.Fatal("expected frame to parse") + } + if canID != 0x18DAF110 { + t.Errorf("canID = %#x, want 0x18DAF110", canID) + } + }) + + t.Run("standard id masks off stray high bits", func(t *testing.T) { + canID, _, ok := parseCANFrame(canFrame(0xFFFFF123&^uint32(unix.CAN_EFF_FLAG|unix.CAN_RTR_FLAG|unix.CAN_ERR_FLAG), 0, nil)) + if !ok { + t.Fatal("expected frame to parse") + } + if canID != 0x123 { + t.Errorf("canID = %#x, want 0x123", canID) + } + }) + + t.Run("rtr frames are skipped", func(t *testing.T) { + if _, _, ok := parseCANFrame(canFrame(0x123|unix.CAN_RTR_FLAG, 0, nil)); ok { + t.Error("RTR frame should not parse — it carries no telemetry") + } + }) + + t.Run("error frames are skipped", func(t *testing.T) { + if _, _, ok := parseCANFrame(canFrame(0x123|unix.CAN_ERR_FLAG, 0, nil)); ok { + t.Error("error frame should not parse") + } + }) + + t.Run("short read is skipped", func(t *testing.T) { + if _, _, ok := parseCANFrame(make([]byte, canFrameSize-1)); ok { + t.Error("undersized frame should not parse") + } + }) + + t.Run("dlc is clamped to the classic payload size", func(t *testing.T) { + // A CAN FD DLC on a classic socket would otherwise read past the + // 8 data bytes the frame actually carries. + _, data, ok := parseCANFrame(canFrame(0x123, 15, nil)) + if !ok { + t.Fatal("expected frame to parse") + } + if len(data) != 8 { + t.Errorf("data length = %d, want 8", len(data)) + } + }) + + t.Run("data is copied out of the read buffer", func(t *testing.T) { + frame := canFrame(0x123, 2, []byte{0xAA, 0xBB}) + _, data, ok := parseCANFrame(frame) + if !ok { + t.Fatal("expected frame to parse") + } + frame[8], frame[9] = 0, 0 + if !bytes.Equal(data, []byte{0xAA, 0xBB}) { + t.Errorf("data = %v after buffer reuse, want AABB", data) + } + }) +} diff --git a/relay/service/tcm_status_test.go b/relay/service/tcm_status_test.go new file mode 100644 index 0000000..25555c3 --- /dev/null +++ b/relay/service/tcm_status_test.go @@ -0,0 +1,49 @@ +package service + +import ( + "encoding/binary" + "testing" +) + +func TestStatusBits(t *testing.T) { + tests := []struct { + name string + inet, mqttOK, mapacheOK, clock bool + want byte + }{ + {"all down", false, false, false, false, 0}, + {"all up", true, true, true, true, 0b1111}, + {"internet only", true, false, false, false, tcmStatusConnectionOK}, + {"mqtt only", false, true, false, false, tcmStatusMQTTOK}, + {"mapache only", false, false, true, false, tcmStatusMapacheOK}, + {"clock only", false, false, false, true, tcmStatusClockOK}, + {"link up, mapache silent", true, true, false, true, tcmStatusConnectionOK | tcmStatusMQTTOK | tcmStatusClockOK}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := statusBits(tt.inet, tt.mqttOK, tt.mapacheOK, tt.clock); got != tt.want { + t.Errorf("statusBits = %08b, want %08b", got, tt.want) + } + }) + } +} + +func TestEncodeTCMStatus(t *testing.T) { + got := encodeTCMStatus(0b1011, 1234) + + if len(got) != 8 { + t.Fatalf("payload length = %d, want 8", len(got)) + } + if got[0] != 0b1011 { + t.Errorf("status bits = %08b, want %08b", got[0], 0b1011) + } + if ping := binary.LittleEndian.Uint16(got[1:3]); ping != 1234 { + t.Errorf("mapache ping = %d, want 1234", ping) + } + for i, b := range got[3:] { + if b != 0 { + t.Errorf("reserved byte %d = %d, want 0", i+3, b) + } + } +} diff --git a/relay/utils/config_test.go b/relay/utils/config_test.go new file mode 100644 index 0000000..04528bc --- /dev/null +++ b/relay/utils/config_test.go @@ -0,0 +1,63 @@ +package utils + +import ( + "fmt" + "relay/config" + "strings" + "testing" +) + +func TestValidTopicSegment(t *testing.T) { + valid := []string{"cayman", "boxster-987", "gr_987", "987.2", "pcan", "kcan"} + for _, s := range valid { + t.Run("valid/"+s, func(t *testing.T) { + if err := validTopicSegment(s); err != nil { + t.Errorf("validTopicSegment(%q) = %v, want nil", s, err) + } + }) + } + + invalid := map[string]string{ + "empty": "", + "slash": "cayman/987", + "leading slash": "/cayman", + "plus wildcard": "cay+man", + "hash wildcard": "cayman#", + "inner space": "cayman 987", + "trailing space": "cayman ", + "tab": "cayman\t987", + "newline": "cayman\n", + } + for name, s := range invalid { + t.Run("invalid/"+name, func(t *testing.T) { + if err := validTopicSegment(s); err == nil { + t.Errorf("validTopicSegment(%q) = nil, want error", s) + } + }) + } +} + +// Mapache's ingest splits on "/" and requires exactly 4 segments, reading +// the vehicle from segment 1. Anything that changes the segment count is +// dropped there with no signal back to the relay. +func TestValidVehicleIDKeepsTopicShape(t *testing.T) { + for _, vehicleID := range []string{"cayman", "boxster-987"} { + if err := validTopicSegment(vehicleID); err != nil { + t.Fatalf("validTopicSegment(%q) = %v", vehicleID, err) + } + topic := fmt.Sprintf("%s/%s/%s/0x%03x", config.TopicRoot, vehicleID, "pcan", 0x123) + parts := strings.Split(topic, "/") + if len(parts) != 4 { + t.Errorf("topic %q has %d segments, want 4", topic, len(parts)) + } + if parts[1] != vehicleID { + t.Errorf("segment 1 = %q, want %q", parts[1], vehicleID) + } + } + + // The failure this guards against. + bad := fmt.Sprintf("%s/%s/%s/0x%03x", config.TopicRoot, "cayman/987", "pcan", 0x123) + if len(strings.Split(bad, "/")) == 4 { + t.Error("a slashed vehicle id should break the 4-segment shape — the guard would be pointless otherwise") + } +} From a88d9bcf3972ef7354d458a5f9fee2d1b8a91dfb Mon Sep 17 00:00:00 2001 From: Bharat Kathi Date: Sat, 29 Aug 2026 09:04:06 -0700 Subject: [PATCH 10/11] ci: run vet and the test suite before building images Gate the image build on the tests so a tree that fails its own suite cannot be pushed to GHCR. --- .github/workflows/relay.yml | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/.github/workflows/relay.yml b/.github/workflows/relay.yml index a7d93be..5120da6 100644 --- a/.github/workflows/relay.yml +++ b/.github/workflows/relay.yml @@ -9,9 +9,33 @@ on: - "**" jobs: + test: + runs-on: ubuntu-24.04 + name: Test + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: relay/go.mod + cache-dependency-path: relay/go.sum + + - name: Vet + working-directory: relay + run: go vet ./... + + - name: Test + working-directory: relay + run: go test -race ./... + build: runs-on: ${{ matrix.runner }} name: Build ${{ matrix.platform }} + # Don't push an image for a tree that doesn't pass its own tests. + needs: test strategy: fail-fast: false matrix: From a8cc20098a423a9cf3f377ecc9924aa723a38368 Mon Sep 17 00:00:00 2001 From: Bharat Kathi Date: Sat, 29 Aug 2026 09:35:38 -0700 Subject: [PATCH 11/11] chore: drop the test suite and its CI job Matches how the rest of the services are set up. --- .github/workflows/relay.yml | 24 ----- relay/config/config_test.go | 60 ------------ relay/service/can_test.go | 135 -------------------------- relay/service/clock_test.go | 17 ---- relay/service/resources_test.go | 95 ------------------ relay/service/socketcan_linux_test.go | 96 ------------------ relay/service/tcm_status_test.go | 49 ---------- relay/utils/config_test.go | 63 ------------ 8 files changed, 539 deletions(-) delete mode 100644 relay/config/config_test.go delete mode 100644 relay/service/can_test.go delete mode 100644 relay/service/clock_test.go delete mode 100644 relay/service/resources_test.go delete mode 100644 relay/service/socketcan_linux_test.go delete mode 100644 relay/service/tcm_status_test.go delete mode 100644 relay/utils/config_test.go diff --git a/.github/workflows/relay.yml b/.github/workflows/relay.yml index 5120da6..a7d93be 100644 --- a/.github/workflows/relay.yml +++ b/.github/workflows/relay.yml @@ -9,33 +9,9 @@ on: - "**" jobs: - test: - runs-on: ubuntu-24.04 - name: Test - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Set up Go - uses: actions/setup-go@v5 - with: - go-version-file: relay/go.mod - cache-dependency-path: relay/go.sum - - - name: Vet - working-directory: relay - run: go vet ./... - - - name: Test - working-directory: relay - run: go test -race ./... - build: runs-on: ${{ matrix.runner }} name: Build ${{ matrix.platform }} - # Don't push an image for a tree that doesn't pass its own tests. - needs: test strategy: fail-fast: false matrix: diff --git a/relay/config/config_test.go b/relay/config/config_test.go deleted file mode 100644 index 4b12df8..0000000 --- a/relay/config/config_test.go +++ /dev/null @@ -1,60 +0,0 @@ -package config - -import "testing" - -func TestParseInterfaceList(t *testing.T) { - tests := []struct { - name string - in string - want []CANInterface - }{ - {"empty", "", nil}, - {"single pair", "can0:pcan", []CANInterface{{"can0", "pcan"}}}, - {"multiple pairs", "can0:pcan,can1:kcan", []CANInterface{{"can0", "pcan"}, {"can1", "kcan"}}}, - {"bare name labels itself", "can0", []CANInterface{{"can0", "can0"}}}, - {"empty label falls back to name", "can0:", []CANInterface{{"can0", "can0"}}}, - {"surrounding whitespace", " can0:pcan , can1:kcan ", []CANInterface{{"can0", "pcan"}, {"can1", "kcan"}}}, - {"empty entries skipped", "can0:pcan,,can1:kcan", []CANInterface{{"can0", "pcan"}, {"can1", "kcan"}}}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := parseInterfaceList(tt.in) - if len(got) != len(tt.want) { - t.Fatalf("parseInterfaceList(%q) = %v, want %v", tt.in, got, tt.want) - } - for i := range got { - if got[i] != tt.want[i] { - t.Errorf("interface %d = %v, want %v", i, got[i], tt.want[i]) - } - } - }) - } -} - -func TestParsePortList(t *testing.T) { - tests := []struct { - name string - in string - want []string - }{ - {"empty", "", nil}, - {"single", "8100", []string{"8100"}}, - {"multiple with whitespace", "8100, 8101", []string{"8100", "8101"}}, - {"empty entries skipped", "8100,,8101", []string{"8100", "8101"}}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := parsePortList(tt.in) - if len(got) != len(tt.want) { - t.Fatalf("parsePortList(%q) = %v, want %v", tt.in, got, tt.want) - } - for i := range got { - if got[i] != tt.want[i] { - t.Errorf("port %d = %q, want %q", i, got[i], tt.want[i]) - } - } - }) - } -} diff --git a/relay/service/can_test.go b/relay/service/can_test.go deleted file mode 100644 index 5ad580e..0000000 --- a/relay/service/can_test.go +++ /dev/null @@ -1,135 +0,0 @@ -package service - -import ( - "bytes" - "encoding/binary" - "relay/config" - "testing" - - cmap "github.com/orcaman/concurrent-map/v2" -) - -func TestEncodePayload(t *testing.T) { - config.VehicleUploadKey = 0xBEEF - data := []byte{0x01, 0x02, 0x03} - - got := encodePayload(0x0011223344556677, data) - - if len(got) != 10+len(data) { - t.Fatalf("payload length = %d, want %d", len(got), 10+len(data)) - } - if ts := binary.BigEndian.Uint64(got[0:8]); ts != 0x0011223344556677 { - t.Errorf("timestamp = %#x, want %#x", ts, uint64(0x0011223344556677)) - } - if key := binary.BigEndian.Uint16(got[8:10]); key != 0xBEEF { - t.Errorf("upload key = %#x, want %#x", key, 0xBEEF) - } - if !bytes.Equal(got[10:], data) { - t.Errorf("data = %v, want %v", got[10:], data) - } -} - -func TestEncodePayloadNoData(t *testing.T) { - config.VehicleUploadKey = 1 - if got := encodePayload(42, nil); len(got) != 10 { - t.Errorf("header-only payload length = %d, want 10", len(got)) - } -} - -func TestParseVirtualCANFrame(t *testing.T) { - frame := func(canID uint32, length byte, data []byte) []byte { - p := make([]byte, virtualCANFrameSize) - binary.LittleEndian.PutUint32(p[0:4], canID) - p[5] = length - copy(p[6:], data) - return p - } - - t.Run("valid frame", func(t *testing.T) { - canID, data, err := parseVirtualCANFrame(frame(0x210, 4, []byte{0xDE, 0xAD, 0xBE, 0xEF})) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if canID != 0x210 { - t.Errorf("canID = %#x, want %#x", canID, 0x210) - } - if !bytes.Equal(data, []byte{0xDE, 0xAD, 0xBE, 0xEF}) { - t.Errorf("data = %v, want DEADBEEF", data) - } - }) - - t.Run("zero length", func(t *testing.T) { - _, data, err := parseVirtualCANFrame(frame(0x211, 0, nil)) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if len(data) != 0 { - t.Errorf("data length = %d, want 0", len(data)) - } - }) - - t.Run("data is copied out of the buffer", func(t *testing.T) { - packet := frame(0x210, 2, []byte{0xAA, 0xBB}) - _, data, err := parseVirtualCANFrame(packet) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - // The listener reuses its read buffer, so a retained slice would - // see the next frame's bytes. - packet[6], packet[7] = 0x00, 0x00 - if !bytes.Equal(data, []byte{0xAA, 0xBB}) { - t.Errorf("data = %v after buffer reuse, want AABB", data) - } - }) - - t.Run("short packet", func(t *testing.T) { - if _, _, err := parseVirtualCANFrame(make([]byte, virtualCANFrameSize-1)); err == nil { - t.Error("expected error for undersized packet") - } - }) - - t.Run("length beyond CAN FD max", func(t *testing.T) { - if _, _, err := parseVirtualCANFrame(frame(0x210, 65, nil)); err == nil { - t.Error("expected error for length 65") - } - }) - - t.Run("max length payload fits", func(t *testing.T) { - if _, data, err := parseVirtualCANFrame(frame(0x210, 64, nil)); err != nil { - t.Errorf("64-byte payload in a 72-byte packet should be valid: %v", err) - } else if len(data) != 64 { - t.Errorf("data length = %d, want 64", len(data)) - } - }) -} - -func TestShouldPublishOn(t *testing.T) { - last := cmap.New[uint64]() - const intervalMs = 20 - const key = "pcan/512" - - if !shouldPublishOn(key, 1_000_000, last, intervalMs) { - t.Fatal("first publish for a key should always pass") - } - if shouldPublishOn(key, 1_010_000, last, intervalMs) { - t.Error("publish 10ms later should be throttled") - } - if shouldPublishOn(key, 1_020_000, last, intervalMs) { - t.Error("publish exactly at the interval should be throttled") - } - if !shouldPublishOn(key, 1_020_001, last, intervalMs) { - t.Error("publish past the interval should pass") - } -} - -func TestShouldPublishOnKeysAreIndependent(t *testing.T) { - last := cmap.New[uint64]() - - if !shouldPublishOn("pcan/512", 1_000_000, last, 20) { - t.Fatal("first publish on pcan should pass") - } - // Same CAN ID on another bus is a different signal entirely. - if !shouldPublishOn("kcan/512", 1_000_000, last, 20) { - t.Error("first publish on kcan should not be throttled by pcan") - } -} diff --git a/relay/service/clock_test.go b/relay/service/clock_test.go deleted file mode 100644 index 07ba83e..0000000 --- a/relay/service/clock_test.go +++ /dev/null @@ -1,17 +0,0 @@ -package service - -import ( - "testing" - "time" -) - -func TestClockPlausible(t *testing.T) { - // A Pi with no RTC and no network boots to 1970; anything at or after - // the cutoff means the clock has been set. - if !ClockPlausible() { - t.Errorf("clock should be plausible: now=%s cutoff=%s", time.Now(), minValidTime) - } - if minValidTime.After(time.Now()) { - t.Error("minValidTime is in the future — the cutoff would reject every real reading") - } -} diff --git a/relay/service/resources_test.go b/relay/service/resources_test.go deleted file mode 100644 index e9d8443..0000000 --- a/relay/service/resources_test.go +++ /dev/null @@ -1,95 +0,0 @@ -package service - -import ( - "encoding/binary" - "relay/model" - "testing" -) - -func TestEncodeResourcePayload(t *testing.T) { - m := model.ResourceMetrics{ - CPUFreq: [model.ReportedCPUs]int{1000, 1001, 1002, 1003}, - CPUUtil: [model.ReportedCPUs]int{10, 20, 30, 40}, - CPUTotalUtil: 25, - RAMTotal: 512, - RAMUsed: 128, - RAMUtil: 25, - DiskTotal: 30000, - DiskUsed: 12000, - DiskUtil: 40, - CPUTemp: 55, - ThrottleFlags: model.ThrottleUndervoltageSince | model.ThrottleThermal, - } - - got := encodeResourcePayload(m) - - if len(got) != resourcePayloadSize { - t.Fatalf("payload length = %d, want %d", len(got), resourcePayloadSize) - } - - for i := 0; i < model.ReportedCPUs; i++ { - off := i * 3 - if freq := binary.LittleEndian.Uint16(got[off : off+2]); int(freq) != m.CPUFreq[i] { - t.Errorf("cpu%d freq = %d, want %d", i, freq, m.CPUFreq[i]) - } - if util := got[off+2]; int(util) != m.CPUUtil[i] { - t.Errorf("cpu%d util = %d, want %d", i, util, m.CPUUtil[i]) - } - } - - if got[12] != 25 { - t.Errorf("cpu_total_util = %d, want 25", got[12]) - } - if v := binary.LittleEndian.Uint16(got[13:15]); v != 512 { - t.Errorf("ram_total = %d, want 512", v) - } - if v := binary.LittleEndian.Uint16(got[15:17]); v != 128 { - t.Errorf("ram_used = %d, want 128", v) - } - if got[17] != 25 { - t.Errorf("ram_util = %d, want 25", got[17]) - } - if v := binary.LittleEndian.Uint32(got[18:22]); v != 30000 { - t.Errorf("disk_total = %d, want 30000", v) - } - if v := binary.LittleEndian.Uint32(got[22:26]); v != 12000 { - t.Errorf("disk_used = %d, want 12000", v) - } - if got[26] != 40 { - t.Errorf("disk_util = %d, want 40", got[26]) - } - if got[27] != 55 { - t.Errorf("cpu_temp = %d, want 55", got[27]) - } - if got[28] != model.ThrottleUndervoltageSince|model.ThrottleThermal { - t.Errorf("throttle_flags = %#04b, want %#04b", got[28], model.ThrottleUndervoltageSince|model.ThrottleThermal) - } -} - -func TestEncodeResourcePayloadSaturates(t *testing.T) { - // A Pi with no readable sensor reports 0; a bogus reading must pin the - // field rather than wrap into a plausible small number. - m := model.ResourceMetrics{ - CPUFreq: [model.ReportedCPUs]int{70000, -1, 0, 0}, - CPUUtil: [model.ReportedCPUs]int{300, -5, 0, 0}, - DiskTotal: 5_000_000_000, - } - - got := encodeResourcePayload(m) - - if v := binary.LittleEndian.Uint16(got[0:2]); v != 65535 { - t.Errorf("cpu0 freq = %d, want 65535", v) - } - if got[2] != 255 { - t.Errorf("cpu0 util = %d, want 255", got[2]) - } - if v := binary.LittleEndian.Uint16(got[3:5]); v != 0 { - t.Errorf("cpu1 freq = %d, want 0", v) - } - if got[5] != 0 { - t.Errorf("cpu1 util = %d, want 0", got[5]) - } - if v := binary.LittleEndian.Uint32(got[18:22]); v != 4294967295 { - t.Errorf("disk_total = %d, want 4294967295", v) - } -} diff --git a/relay/service/socketcan_linux_test.go b/relay/service/socketcan_linux_test.go deleted file mode 100644 index 2af2b1a..0000000 --- a/relay/service/socketcan_linux_test.go +++ /dev/null @@ -1,96 +0,0 @@ -//go:build linux - -package service - -import ( - "bytes" - "encoding/binary" - "testing" - - "golang.org/x/sys/unix" -) - -func canFrame(rawID uint32, dlc byte, data []byte) []byte { - frame := make([]byte, canFrameSize) - binary.LittleEndian.PutUint32(frame[0:4], rawID) - frame[4] = dlc - copy(frame[8:], data) - return frame -} - -func TestParseCANFrame(t *testing.T) { - t.Run("standard id", func(t *testing.T) { - canID, data, ok := parseCANFrame(canFrame(0x123, 8, []byte{1, 2, 3, 4, 5, 6, 7, 8})) - if !ok { - t.Fatal("expected frame to parse") - } - if canID != 0x123 { - t.Errorf("canID = %#x, want 0x123", canID) - } - if !bytes.Equal(data, []byte{1, 2, 3, 4, 5, 6, 7, 8}) { - t.Errorf("data = %v", data) - } - }) - - t.Run("extended id", func(t *testing.T) { - canID, _, ok := parseCANFrame(canFrame(0x18DAF110|unix.CAN_EFF_FLAG, 0, nil)) - if !ok { - t.Fatal("expected frame to parse") - } - if canID != 0x18DAF110 { - t.Errorf("canID = %#x, want 0x18DAF110", canID) - } - }) - - t.Run("standard id masks off stray high bits", func(t *testing.T) { - canID, _, ok := parseCANFrame(canFrame(0xFFFFF123&^uint32(unix.CAN_EFF_FLAG|unix.CAN_RTR_FLAG|unix.CAN_ERR_FLAG), 0, nil)) - if !ok { - t.Fatal("expected frame to parse") - } - if canID != 0x123 { - t.Errorf("canID = %#x, want 0x123", canID) - } - }) - - t.Run("rtr frames are skipped", func(t *testing.T) { - if _, _, ok := parseCANFrame(canFrame(0x123|unix.CAN_RTR_FLAG, 0, nil)); ok { - t.Error("RTR frame should not parse — it carries no telemetry") - } - }) - - t.Run("error frames are skipped", func(t *testing.T) { - if _, _, ok := parseCANFrame(canFrame(0x123|unix.CAN_ERR_FLAG, 0, nil)); ok { - t.Error("error frame should not parse") - } - }) - - t.Run("short read is skipped", func(t *testing.T) { - if _, _, ok := parseCANFrame(make([]byte, canFrameSize-1)); ok { - t.Error("undersized frame should not parse") - } - }) - - t.Run("dlc is clamped to the classic payload size", func(t *testing.T) { - // A CAN FD DLC on a classic socket would otherwise read past the - // 8 data bytes the frame actually carries. - _, data, ok := parseCANFrame(canFrame(0x123, 15, nil)) - if !ok { - t.Fatal("expected frame to parse") - } - if len(data) != 8 { - t.Errorf("data length = %d, want 8", len(data)) - } - }) - - t.Run("data is copied out of the read buffer", func(t *testing.T) { - frame := canFrame(0x123, 2, []byte{0xAA, 0xBB}) - _, data, ok := parseCANFrame(frame) - if !ok { - t.Fatal("expected frame to parse") - } - frame[8], frame[9] = 0, 0 - if !bytes.Equal(data, []byte{0xAA, 0xBB}) { - t.Errorf("data = %v after buffer reuse, want AABB", data) - } - }) -} diff --git a/relay/service/tcm_status_test.go b/relay/service/tcm_status_test.go deleted file mode 100644 index 25555c3..0000000 --- a/relay/service/tcm_status_test.go +++ /dev/null @@ -1,49 +0,0 @@ -package service - -import ( - "encoding/binary" - "testing" -) - -func TestStatusBits(t *testing.T) { - tests := []struct { - name string - inet, mqttOK, mapacheOK, clock bool - want byte - }{ - {"all down", false, false, false, false, 0}, - {"all up", true, true, true, true, 0b1111}, - {"internet only", true, false, false, false, tcmStatusConnectionOK}, - {"mqtt only", false, true, false, false, tcmStatusMQTTOK}, - {"mapache only", false, false, true, false, tcmStatusMapacheOK}, - {"clock only", false, false, false, true, tcmStatusClockOK}, - {"link up, mapache silent", true, true, false, true, tcmStatusConnectionOK | tcmStatusMQTTOK | tcmStatusClockOK}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := statusBits(tt.inet, tt.mqttOK, tt.mapacheOK, tt.clock); got != tt.want { - t.Errorf("statusBits = %08b, want %08b", got, tt.want) - } - }) - } -} - -func TestEncodeTCMStatus(t *testing.T) { - got := encodeTCMStatus(0b1011, 1234) - - if len(got) != 8 { - t.Fatalf("payload length = %d, want 8", len(got)) - } - if got[0] != 0b1011 { - t.Errorf("status bits = %08b, want %08b", got[0], 0b1011) - } - if ping := binary.LittleEndian.Uint16(got[1:3]); ping != 1234 { - t.Errorf("mapache ping = %d, want 1234", ping) - } - for i, b := range got[3:] { - if b != 0 { - t.Errorf("reserved byte %d = %d, want 0", i+3, b) - } - } -} diff --git a/relay/utils/config_test.go b/relay/utils/config_test.go deleted file mode 100644 index 04528bc..0000000 --- a/relay/utils/config_test.go +++ /dev/null @@ -1,63 +0,0 @@ -package utils - -import ( - "fmt" - "relay/config" - "strings" - "testing" -) - -func TestValidTopicSegment(t *testing.T) { - valid := []string{"cayman", "boxster-987", "gr_987", "987.2", "pcan", "kcan"} - for _, s := range valid { - t.Run("valid/"+s, func(t *testing.T) { - if err := validTopicSegment(s); err != nil { - t.Errorf("validTopicSegment(%q) = %v, want nil", s, err) - } - }) - } - - invalid := map[string]string{ - "empty": "", - "slash": "cayman/987", - "leading slash": "/cayman", - "plus wildcard": "cay+man", - "hash wildcard": "cayman#", - "inner space": "cayman 987", - "trailing space": "cayman ", - "tab": "cayman\t987", - "newline": "cayman\n", - } - for name, s := range invalid { - t.Run("invalid/"+name, func(t *testing.T) { - if err := validTopicSegment(s); err == nil { - t.Errorf("validTopicSegment(%q) = nil, want error", s) - } - }) - } -} - -// Mapache's ingest splits on "/" and requires exactly 4 segments, reading -// the vehicle from segment 1. Anything that changes the segment count is -// dropped there with no signal back to the relay. -func TestValidVehicleIDKeepsTopicShape(t *testing.T) { - for _, vehicleID := range []string{"cayman", "boxster-987"} { - if err := validTopicSegment(vehicleID); err != nil { - t.Fatalf("validTopicSegment(%q) = %v", vehicleID, err) - } - topic := fmt.Sprintf("%s/%s/%s/0x%03x", config.TopicRoot, vehicleID, "pcan", 0x123) - parts := strings.Split(topic, "/") - if len(parts) != 4 { - t.Errorf("topic %q has %d segments, want 4", topic, len(parts)) - } - if parts[1] != vehicleID { - t.Errorf("segment 1 = %q, want %q", parts[1], vehicleID) - } - } - - // The failure this guards against. - bad := fmt.Sprintf("%s/%s/%s/0x%03x", config.TopicRoot, "cayman/987", "pcan", 0x123) - if len(strings.Split(bad, "/")) == 4 { - t.Error("a slashed vehicle id should break the 4-segment shape — the guard would be pointless otherwise") - } -}