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..3f8da4e --- /dev/null +++ b/relay/Dockerfile @@ -0,0 +1,30 @@ +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 + +# 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"] 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..408f4b8 --- /dev/null +++ b/relay/database/db.go @@ -0,0 +1,85 @@ +package database + +import ( + "fmt" + "log" + "os" + "relay/config" + "relay/model" + "relay/utils" + "time" + + "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 +} + +// 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") +} + +// 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" { + level = logger.Warn + } + 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() { + 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..4fe9c4e --- /dev/null +++ b/relay/main.go @@ -0,0 +1,49 @@ +package main + +import ( + "os" + "os/signal" + "relay/config" + "relay/database" + "relay/mqtt" + "relay/service" + "relay/utils" + "syscall" +) + +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.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/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..5d09310 --- /dev/null +++ b/relay/model/resources.go @@ -0,0 +1,34 @@ +package model + +// 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 { + 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/mqtt/mqtt.go b/relay/mqtt/mqtt.go new file mode 100644 index 0000000..b867b35 --- /dev/null +++ b/relay/mqtt/mqtt.go @@ -0,0 +1,304 @@ +package mqtt + +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 — +// 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) +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. + 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) + } + localQueue = startQueue("local", Client) + } 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, + ) + cloudQueue = startQueue("cloud", CloudClient) + 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) +} + +// 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). +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 localQueue == nil { + return + } + localQueue.enqueue(topic, qos, retained, payload) +} + +func PublishCloud(topic string, qos byte, retained bool, payload []byte) { + if cloudQueue == nil { + return + } + cloudQueue.enqueue(topic, qos, retained, payload) +} + +// 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) + } + localQueue, cloudQueue = nil, nil +} + +// 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 + } + 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()) + 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 + } + // 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 + } + 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..4280538 --- /dev/null +++ b/relay/service/can.go @@ -0,0 +1,157 @@ +package service + +import ( + "encoding/binary" + "errors" + "fmt" + "net" + "relay/config" + "relay/mqtt" + "relay/utils" + "strconv" + "time" + + 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 +// 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. +// +// 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, "") + + // 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) + 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 toCloud { + 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 +} + +// 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" + + 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) + + buffer := make([]byte, 1024) + for { + 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 + } + if shouldLog { + utils.SugarLogger.Infof("[VCAN:%s] Received %d bytes from %s", port, n, remoteAddr.String()) + } + + canID, data, err := parseVirtualCANFrame(buffer[:n]) + if err != nil { + utils.SugarLogger.Infof("[VCAN:%s] %v, skipping", port, err) + continue + } + + if shouldLog { + utils.SugarLogger.Infof("[VCAN:%s] CAN ID: 0x%03x, Length: %d", port, canID, len(data)) + } + + PublishData(config.VirtualBusLabel, canID, data) + } +} 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..714f5ea --- /dev/null +++ b/relay/service/dbqueue.go @@ -0,0 +1,148 @@ +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 + +// 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), + 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) { + msg := model.P987Message{ + Timestamp: timestamp, + VehicleID: vehicleID, + Topic: topic, + Data: data, + Synced: 0, + SourceNode: sourceNode, + 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: + 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] + } + } + } +} + +// 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() + 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() + + 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..19088ef --- /dev/null +++ b/relay/service/ping.go @@ -0,0 +1,114 @@ +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 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) + } + lastWarn = time.Now() + } + + 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)) + mqtt.Publish(topic, 0, false, encodePayload(uint64(micros), nil)) +} + +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..3722bd5 --- /dev/null +++ b/relay/service/resources.go @@ -0,0 +1,115 @@ +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) + } + }() +} + +// 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 + } + + 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) + + 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) +} + +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 new file mode 100644 index 0000000..df6e9fa --- /dev/null +++ b/relay/service/resources_linux.go @@ -0,0 +1,263 @@ +//go:build linux + +package service + +import ( + "fmt" + "os" + "path/filepath" + "relay/config" + "relay/model" + "strconv" + "strings" + "sync" + + "golang.org/x/sys/unix" +) + +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. 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 + for i := 0; i < model.ReportedCPUs; i++ { + if i < len(utils) { + m.CPUUtil[i] = utils[i] + } + m.CPUFreq[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() + m.ThrottleFlags = readThrottleFlags() + + 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 +} + +// 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 +} 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..46083b9 --- /dev/null +++ b/relay/service/socketcan_linux.go @@ -0,0 +1,109 @@ +//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 + +// 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) + } +} + +// 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 + } + + canID, data, ok := parseCANFrame(frame) + if !ok { + continue + } + + // 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 + } + + 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 new file mode 100644 index 0000000..de4089f --- /dev/null +++ b/relay/service/socketcan_stub.go @@ -0,0 +1,14 @@ +//go:build !linux + +package service + +import ( + "relay/config" + "relay/utils" +) + +func StartSocketCAN() { + if len(config.CANInterfaces) > 0 { + utils.SugarLogger.Warnf("socketcan requires linux; ignoring CAN_INTERFACES (%d configured)", len(config.CANInterfaces)) + } +} 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..e2a24d1 --- /dev/null +++ b/relay/service/tcm_status.go @@ -0,0 +1,76 @@ +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() + + 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 { + bits |= tcmStatusConnectionOK + } + if mqttOK { + bits |= tcmStatusMQTTOK + } + if mapacheOK { + bits |= tcmStatusMapacheOK + } + if clock { + bits |= tcmStatusClockOK + } + return bits +} + +// 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 +} diff --git a/relay/utils/config.go b/relay/utils/config.go new file mode 100644 index 0000000..c4b7b8b --- /dev/null +++ b/relay/utils/config.go @@ -0,0 +1,121 @@ +package utils + +import ( + "errors" + "fmt" + "path/filepath" + "relay/config" + "strconv" + "strings" + "time" +) + +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") + } + 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") + + 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") + } + 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) + // 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) +} + +// 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 { + 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 + } + 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)"