diff --git a/.github/workflows/p987.yml b/.github/workflows/p987.yml new file mode 100644 index 00000000..7a24d287 --- /dev/null +++ b/.github/workflows/p987.yml @@ -0,0 +1,155 @@ +name: p987 +run-name: Triggered by ${{ github.event_name }} to ${{ github.ref }} by @${{ github.actor }} + +on: + push: + branches: + - "**" + tags: + - "v*" + +jobs: + build: + runs-on: ${{ matrix.runner }} + name: Build ${{ matrix.platform }} + strategy: + fail-fast: false + matrix: + include: + - platform: linux/amd64 + runner: ubuntu-24.04 + - platform: linux/arm64 + runner: ubuntu-24.04-arm + + permissions: + contents: read + packages: write + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Generate platform pair + id: platform + run: | + platform=${{ matrix.platform }} + echo "pair=${platform//\//-}" >> $GITHUB_OUTPUT + + - name: Build and push by digest + id: build + uses: docker/build-push-action@v5 + with: + context: p987 + platforms: ${{ matrix.platform }} + outputs: type=image,name=ghcr.io/gaucho-racing/mapache/p987,push-by-digest=true,name-canonical=true,push=true + cache-from: | + type=gha,scope=build-${{ github.workflow }}-${{ steps.platform.outputs.pair }}-${{ github.ref_name }} + type=gha,scope=build-${{ github.workflow }}-${{ steps.platform.outputs.pair }}-main + cache-to: type=gha,scope=build-${{ github.workflow }}-${{ steps.platform.outputs.pair }}-${{ github.ref_name }},mode=max + + - name: Export digest + run: | + mkdir -p /tmp/digests + digest="${{ steps.build.outputs.digest }}" + touch "/tmp/digests/${digest#sha256:}" + + - name: Upload digest + uses: actions/upload-artifact@v4 + with: + name: digests-${{ steps.platform.outputs.pair }} + path: /tmp/digests/* + if-no-files-found: error + retention-days: 1 + + merge: + runs-on: ubuntu-latest + name: Merge manifests + needs: build + + permissions: + contents: read + packages: write + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + fetch-tags: true + + - name: Download digests + uses: actions/download-artifact@v4 + with: + path: /tmp/digests + pattern: digests-* + merge-multiple: true + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Check if this commit has a release tag + id: release + run: | + tag=$(git tag --points-at HEAD | grep '^v' | head -n1) + if [ -n "$tag" ]; then + echo "Found tag: $tag" + if gh release view "$tag" --json tagName > /dev/null 2>&1; then + echo "release_tag=$tag" >> $GITHUB_OUTPUT + echo "is_release=true" >> $GITHUB_OUTPUT + exit 0 + fi + fi + echo "is_release=false" >> $GITHUB_OUTPUT + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Generate tag list + id: tags + shell: bash + run: | + TAGS="type=sha" + + if [ "${GITHUB_REF_TYPE}" = "branch" ] && [ "${GITHUB_REF_NAME}" = "main" ]; then + TAGS="${TAGS}\ntype=raw,value=latest" + fi + + if [ "${{ steps.release.outputs.is_release }}" = "true" ]; then + CLEAN_TAG=$(echo "${{ steps.release.outputs.release_tag }}" | sed 's/^v//') + TAGS="${TAGS}\ntype=raw,value=${CLEAN_TAG}" + fi + + echo -e "tags<> $GITHUB_OUTPUT + + - name: Extract image metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ghcr.io/gaucho-racing/mapache/p987 + tags: ${{ steps.tags.outputs.tags }} + + - name: Create manifest list and push + working-directory: /tmp/digests + run: | + docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ + $(printf 'ghcr.io/gaucho-racing/mapache/p987@sha256:%s ' *) + + - name: Inspect image + run: | + docker buildx imagetools inspect ghcr.io/gaucho-racing/mapache/p987:${{ steps.meta.outputs.version }} diff --git a/docker-compose.yaml b/docker-compose.yaml index 33d30af6..a8aa7da7 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -116,6 +116,39 @@ services: AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID} AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY} + p987: + container_name: mapache-p987 + build: + context: . + dockerfile: p987/Dockerfile.dev + restart: unless-stopped + depends_on: + - nanomq + - clickhouse + ports: + - "7020:7020" + volumes: + - ./p987:/app/p987 + - ./mapache-go:/app/mapache-go + - p987_go_cache:/go + environment: + ENV: "DEV" + PORT: "7020" + MQTT_HOST: "nanomq" + MQTT_PORT: "1883" + MQTT_USER: "p987" + MQTT_PASSWORD: "p987" + CLICKHOUSE_HOST: "clickhouse" + CLICKHOUSE_PORT: "9000" + CLICKHOUSE_USER: "default" + CLICKHOUSE_PASSWORD: "" + CLICKHOUSE_DATABASE: "mapache" + KERBECS_ENDPOINT: "http://kerbecs:10300" + KERBECS_USER: "admin" + KERBECS_PASSWORD: "admin" + SKIP_AUTH_CHECK: "true" + VEHICLE_UPLOAD_KEY_CACHE_TTL: "600" + live: container_name: mapache-live build: @@ -254,5 +287,6 @@ volumes: auth_go_cache: vehicle_go_cache: gr26_go_cache: + p987_go_cache: live_go_cache: dashboard_node_modules: diff --git a/kerbecs.yaml b/kerbecs.yaml index a2d41ebb..cfa86dcb 100644 --- a/kerbecs.yaml +++ b/kerbecs.yaml @@ -50,6 +50,12 @@ upstreams: instances: - http://gr26:7005 + p987: + name: mapache-p987 + version: 3.3.0 + instances: + - http://p987:7020 + live: name: mapache-live version: 3.3.0 @@ -163,6 +169,14 @@ routes: strip_prefix: /api envelope: default + - name: p987 + match: + path: /api/p987/* + upstream: p987 + rewrite: + strip_prefix: /api + envelope: default + - name: live-ws match: path: /api/live/ws diff --git a/p987/.air.toml b/p987/.air.toml new file mode 100644 index 00000000..3d5c83f0 --- /dev/null +++ b/p987/.air.toml @@ -0,0 +1,21 @@ +root = "." +tmp_dir = "tmp" + +[build] + bin = "./tmp/main" + cmd = "go mod tidy && go build -o ./tmp/main ." + delay = 1000 + exclude_dir = ["tmp", "vendor"] + exclude_regex = ["_test.go"] + include_ext = ["go", "toml"] + kill_delay = "0s" + send_interrupt = false + poll = true + poll_interval = 500 + stop_on_error = true + +[log] + time = false + +[misc] + clean_on_exit = true diff --git a/p987/.gitignore b/p987/.gitignore new file mode 100644 index 00000000..f29bf026 --- /dev/null +++ b/p987/.gitignore @@ -0,0 +1,168 @@ +.env + +### VisualStudioCode template +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +!.vscode/*.code-snippets + +# Local History for Visual Studio Code +.history/ + +# Built Visual Studio Code Extensions +*.vsix + +### Go template +# If you prefer the allow list template instead of the deny list, see community template: +# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore +# +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out +coverage.html + +# Dependency directories (remove the comment below to include it) +# vendor/ + +# Go workspace file +go.work + +### JetBrains template +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff +.idea/ + +# AWS User-specific +.idea/**/aws.xml + +# Generated files +.idea/**/contentModel.xml + +# Sensitive or high-churn files +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/dbnavigator.xml + +# Gradle +.idea/**/gradle.xml +.idea/**/libraries + +# Gradle and Maven with auto-import +# When using Gradle or Maven with auto-import, you should exclude module files, +# since they will be recreated, and may cause churn. Uncomment if using +# auto-import. +# .idea/artifacts +# .idea/compiler.xml +# .idea/jarRepositories.xml +# .idea/modules.xml +# .idea/*.iml +# .idea/modules +# *.iml +# *.ipr + +# CMake +cmake-build-*/ + +# Mongo Explorer plugin +.idea/**/mongoSettings.xml + +# File-based project format +*.iws + +# IntelliJ +out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Cursive Clojure plugin +.idea/replstate.xml + +# SonarLint plugin +.idea/sonarlint/ + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +# Editor-based Rest Client +.idea/httpRequests + +# Android studio 3.1+ serialized cache file +.idea/caches/build_file_checksums.ser + +### Windows template +# Windows thumbnail cache files +Thumbs.db +Thumbs.db:encryptable +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk + +### macOS template +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk diff --git a/p987/Dockerfile b/p987/Dockerfile new file mode 100644 index 00000000..278d5e7a --- /dev/null +++ b/p987/Dockerfile @@ -0,0 +1,29 @@ +FROM --platform=$BUILDPLATFORM golang:1.26-alpine AS builder + +RUN apk --no-cache add ca-certificates +RUN apk add --no-cache tzdata + +WORKDIR /app + +COPY go.mod ./ +COPY go.sum ./ +RUN go mod download + +COPY . ./ +ARG TARGETOS +ARG TARGETARCH +RUN GOOS=$TARGETOS GOARCH=$TARGETARCH go build -o /p987 + +## +## Deploy +## +FROM alpine:3.21 + +WORKDIR / + +COPY --from=builder /p987 /p987 + +COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo +ENV TZ=UTC + +ENTRYPOINT ["/p987"] diff --git a/p987/Dockerfile.dev b/p987/Dockerfile.dev new file mode 100644 index 00000000..fdd0b133 --- /dev/null +++ b/p987/Dockerfile.dev @@ -0,0 +1,7 @@ +FROM golang:1.26-alpine + +RUN go install github.com/air-verse/air@latest + +WORKDIR /app/p987 + +CMD ["air", "-c", ".air.toml"] diff --git a/p987/api/api.go b/p987/api/api.go new file mode 100644 index 00000000..a394367e --- /dev/null +++ b/p987/api/api.go @@ -0,0 +1,39 @@ +package api + +import ( + "time" + + "github.com/gaucho-racing/mapache/p987/config" + "github.com/gaucho-racing/mapache/p987/pkg/logger" + "github.com/gin-contrib/cors" + "github.com/gin-gonic/gin" +) + +func Run() { + api := InitializeRouter() + InitializeRoutes(api) + if err := api.Run(":" + config.Port); err != nil { + logger.SugarLogger.Fatalf("Failed to start server: %v", err) + } +} + +func InitializeRouter() *gin.Engine { + if config.IsProduction() { + gin.SetMode(gin.ReleaseMode) + } + r := gin.Default() + r.Use(cors.New(cors.Config{ + AllowAllOrigins: true, + AllowMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"}, + AllowHeaders: []string{"Origin", "Content-Length", "Content-Type", "Authorization"}, + MaxAge: 12 * time.Hour, + AllowCredentials: true, + })) + return r +} + +func InitializeRoutes(router *gin.Engine) { + router.GET("/p987/ping", Ping) + router.GET("/p987/messages/:id", GetCANMessage) + router.GET("/p987/signals/:id", GetCANBySignalID) +} diff --git a/p987/api/can.go b/p987/api/can.go new file mode 100644 index 00000000..573d307b --- /dev/null +++ b/p987/api/can.go @@ -0,0 +1,100 @@ +package api + +import ( + "encoding/hex" + "encoding/json" + "errors" + "net/http" + + "github.com/gaucho-racing/mapache/p987/config" + "github.com/gaucho-racing/mapache/p987/model" + "github.com/gaucho-racing/mapache/p987/service" + + mapache "github.com/gaucho-racing/mapache/mapache-go/v3" + + "github.com/gin-gonic/gin" +) + +// Bytes is hex-encoded (not the default base64) so the dashboard's hex +// grid can render without re-encoding. +type canMessageResponse struct { + ID string `json:"id"` + VehicleID string `json:"vehicle_id"` + NodeID string `json:"node_id"` + Timestamp int `json:"timestamp"` + CANID int `json:"can_id"` + Bytes string `json:"bytes"` + UploadKey int `json:"upload_key"` + Metadata map[string]any `json:"metadata,omitempty"` + ProducedAt string `json:"produced_at"` + CreatedAt string `json:"created_at"` + Fields []canFieldTrace `json:"fields"` + Signals []mapache.Signal `json:"signals"` +} + +func GetCANMessage(c *gin.Context) { + id := c.Param("id") + if id == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "id is required"}) + return + } + respondWithCAN(c, service.GetCAN, id, "can message not found") +} + +// GetCANBySignalID returns the same trace shape as GetCANMessage but looks +// up the source CAN frame by signal id. +func GetCANBySignalID(c *gin.Context) { + id := c.Param("id") + if id == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "id is required"}) + return + } + respondWithCAN(c, service.GetCANForSignal, id, "no can frame linked to this signal") +} + +func respondWithCAN( + c *gin.Context, + lookup func(string) (model.CAN, error), + id string, + notFoundMsg string, +) { + if !config.ClickhouseEnabled() { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "clickhouse disabled"}) + return + } + can, err := lookup(id) + if err != nil { + if errors.Is(err, service.ErrNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": notFoundMsg}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + signals, err := service.GetSignalsForCAN(can.ID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + var meta map[string]any + if len(can.Metadata) > 0 { + _ = json.Unmarshal(can.Metadata, &meta) + } + + c.JSON(http.StatusOK, canMessageResponse{ + ID: can.ID, + VehicleID: can.VehicleID, + NodeID: can.NodeID, + Timestamp: can.Timestamp, + CANID: can.CANID, + Bytes: hex.EncodeToString(can.Bytes), + UploadKey: can.UploadKey, + Metadata: meta, + ProducedAt: can.ProducedAt.UTC().Format("2006-01-02T15:04:05.000000Z"), + CreatedAt: can.CreatedAt.UTC().Format("2006-01-02T15:04:05.000000Z"), + Fields: decodeFieldTrace(can), + Signals: signals, + }) +} diff --git a/p987/api/can_trace.go b/p987/api/can_trace.go new file mode 100644 index 00000000..07ffaa62 --- /dev/null +++ b/p987/api/can_trace.go @@ -0,0 +1,69 @@ +package api + +import ( + "encoding/hex" + "fmt" + + "github.com/gaucho-racing/mapache/p987/model" + + mapache "github.com/gaucho-racing/mapache/mapache-go/v3" +) + +type canFieldTrace struct { + Name string `json:"name"` + Offset int `json:"offset"` + Size int `json:"size"` + Sign string `json:"sign"` + Endian string `json:"endian"` + Bytes string `json:"bytes"` + RawValue int `json:"raw_value"` + SignalNames []string `json:"signal_names"` +} + +// decodeFieldTrace re-runs the decoder to expose per-field metadata. +// Returns nil for unknown ids and decode failures — the reason is already +// recorded in can.Metadata. +func decodeFieldTrace(can model.CAN) []canFieldTrace { + messageStruct := model.GetMessage(can.NodeID, can.CANID) + if messageStruct == nil { + return nil + } + if err := messageStruct.FillFromBytes(can.Bytes); err != nil { + return nil + } + + out := make([]canFieldTrace, 0, len(messageStruct)) + offset := 0 + for _, f := range messageStruct { + signalNames := make([]string, 0) + for _, s := range f.ExportSignals() { + signalNames = append(signalNames, fmt.Sprintf("%s_%s", can.NodeID, s.Name)) + } + out = append(out, canFieldTrace{ + Name: f.Name, + Offset: offset, + Size: f.Size, + Sign: signMode(f.Sign), + Endian: endian(f.Endian), + Bytes: hex.EncodeToString(f.Bytes), + RawValue: f.Value, + SignalNames: signalNames, + }) + offset += f.Size + } + return out +} + +func signMode(s mapache.SignMode) string { + if s == mapache.Signed { + return "signed" + } + return "unsigned" +} + +func endian(e mapache.Endian) string { + if e == mapache.BigEndian { + return "big" + } + return "little" +} diff --git a/p987/api/ping.go b/p987/api/ping.go new file mode 100644 index 00000000..8ad07822 --- /dev/null +++ b/p987/api/ping.go @@ -0,0 +1,12 @@ +package api + +import ( + "net/http" + + "github.com/gaucho-racing/mapache/p987/config" + "github.com/gin-gonic/gin" +) + +func Ping(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"message": config.Service.FormattedNameWithVersion() + " is online!"}) +} diff --git a/p987/config/banner.go b/p987/config/banner.go new file mode 100644 index 00000000..ab559089 --- /dev/null +++ b/p987/config/banner.go @@ -0,0 +1,20 @@ +package config + +import "github.com/fatih/color" + +var Banner = ` +███╗ ███╗ █████╗ ██████╗ █████╗ ██████╗██╗ ██╗███████╗ +████╗ ████║██╔══██╗██╔══██╗██╔══██╗██╔════╝██║ ██║██╔════╝ +██╔████╔██║███████║██████╔╝███████║██║ ███████║█████╗ +██║╚██╔╝██║██╔══██║██╔═══╝ ██╔══██║██║ ██╔══██║██╔══╝ +██║ ╚═╝ ██║██║ ██║██║ ██║ ██║╚██████╗██║ ██║███████╗ +╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═╝ ╚═╝ ╚═════╝╚═╝ ╚═╝╚══════╝ +` + +func PrintStartupBanner() { + banner := color.New(color.Bold, color.FgHiMagenta).PrintlnFunc() + banner(Banner) + version := color.New(color.Bold, color.FgMagenta).PrintlnFunc() + version("Running " + Service.FormattedNameWithVersion() + " [ENV: " + Env + "]") + println() +} diff --git a/p987/config/config.go b/p987/config/config.go new file mode 100644 index 00000000..6e66cb7f --- /dev/null +++ b/p987/config/config.go @@ -0,0 +1,59 @@ +package config + +import ( + "fmt" + "os" + "strings" +) + +type ServiceInfo struct { + Name string + Version string +} + +func (s ServiceInfo) FormattedNameWithVersion() string { + return fmt.Sprintf("%s v%s", s.Name, s.Version) +} + +func (s ServiceInfo) PathPrefix() string { + return strings.ToLower(s.Name) +} + +var Service = ServiceInfo{ + Name: "P987", + Version: "0.1.0", +} + +// TopicRoot is the first topic segment the relay publishes under. It is +// also the generation namespace, so it doubles as the table prefix. +const TopicRoot = "p987" + +var SkipAuthCheck = os.Getenv("SKIP_AUTH_CHECK") == "true" +var VehicleUploadKeyCacheTTL = os.Getenv("VEHICLE_UPLOAD_KEY_CACHE_TTL") + +var Env = os.Getenv("ENV") +var Port = os.Getenv("PORT") + +var ClickhouseHost = os.Getenv("CLICKHOUSE_HOST") +var ClickhousePort = os.Getenv("CLICKHOUSE_PORT") +var ClickhouseUser = os.Getenv("CLICKHOUSE_USER") +var ClickhousePassword = os.Getenv("CLICKHOUSE_PASSWORD") +var ClickhouseDatabase = os.Getenv("CLICKHOUSE_DATABASE") + +// ClickhouseEnabled is the master switch for all CH access. Unset +// CLICKHOUSE_HOST means "no ClickHouse", which is how the service is run +// when testing the live path against a relay without standing up storage. +func ClickhouseEnabled() bool { return ClickhouseHost != "" } + +var KerbecsEndpoint = os.Getenv("KERBECS_ENDPOINT") +var KerbecsUser = os.Getenv("KERBECS_USER") +var KerbecsPassword = os.Getenv("KERBECS_PASSWORD") + +var MQTTHost = os.Getenv("MQTT_HOST") +var MQTTPort = os.Getenv("MQTT_PORT") +var MQTTUser = os.Getenv("MQTT_USER") +var MQTTPassword = os.Getenv("MQTT_PASSWORD") + +func IsProduction() bool { + return Env == "PROD" +} diff --git a/p987/config/verify.go b/p987/config/verify.go new file mode 100644 index 00000000..699b37c2 --- /dev/null +++ b/p987/config/verify.go @@ -0,0 +1,42 @@ +package config + +import "github.com/gaucho-racing/mapache/p987/pkg/logger" + +func Verify() { + if Env == "" { + Env = "PROD" + logger.SugarLogger.Infof("ENV is not set, defaulting to %s", Env) + } + if Port == "" { + Port = "7020" + logger.SugarLogger.Infof("PORT is not set, defaulting to %s", Port) + } + if !ClickhouseEnabled() { + logger.SugarLogger.Infoln("CLICKHOUSE_HOST is not set, ClickHouse disabled") + } else { + if ClickhousePort == "" { + ClickhousePort = "9000" + logger.SugarLogger.Infof("CLICKHOUSE_PORT is not set, defaulting to %s", ClickhousePort) + } + if ClickhouseUser == "" { + ClickhouseUser = "default" + logger.SugarLogger.Infof("CLICKHOUSE_USER is not set, defaulting to %s", ClickhouseUser) + } + if ClickhouseDatabase == "" { + ClickhouseDatabase = "mapache" + logger.SugarLogger.Infof("CLICKHOUSE_DATABASE is not set, defaulting to %s", ClickhouseDatabase) + } + } + if MQTTHost == "" { + MQTTHost = "localhost" + logger.SugarLogger.Infof("MQTT_HOST is not set, defaulting to %s", MQTTHost) + } + if MQTTPort == "" { + MQTTPort = "1883" + logger.SugarLogger.Infof("MQTT_PORT is not set, defaulting to %s", MQTTPort) + } + if VehicleUploadKeyCacheTTL == "" { + VehicleUploadKeyCacheTTL = "600" + logger.SugarLogger.Infof("VEHICLE_UPLOAD_KEY_CACHE_TTL is not set, defaulting to %s", VehicleUploadKeyCacheTTL) + } +} diff --git a/p987/database/db.go b/p987/database/db.go new file mode 100644 index 00000000..bed2cfca --- /dev/null +++ b/p987/database/db.go @@ -0,0 +1,129 @@ +package database + +import ( + "context" + "fmt" + "time" + + "github.com/gaucho-racing/mapache/p987/config" + "github.com/gaucho-racing/mapache/p987/pkg/logger" + + "github.com/ClickHouse/clickhouse-go/v2" + "github.com/ClickHouse/clickhouse-go/v2/lib/driver" + mapache "github.com/gaucho-racing/mapache/mapache-go/v3" +) + +var Conn driver.Conn + +var dbRetries = 0 + +func options(database string) *clickhouse.Options { + return &clickhouse.Options{ + Addr: []string{fmt.Sprintf("%s:%s", config.ClickhouseHost, config.ClickhousePort)}, + Auth: clickhouse.Auth{ + Database: database, + Username: config.ClickhouseUser, + Password: config.ClickhousePassword, + }, + DialTimeout: 10 * time.Second, + } +} + +// InsertCtx flags inserts as server-side async — CH coalesces small INSERTs +// into larger parts and we don't block on the flush. +func InsertCtx(parent context.Context) context.Context { + return clickhouse.Context(parent, clickhouse.WithSettings(clickhouse.Settings{ + "async_insert": 1, + "wait_for_async_insert": 0, + })) +} + +func Init() { + if err := connect(); err != nil { + if dbRetries < 5 { + dbRetries++ + logger.SugarLogger.Errorln("failed to connect clickhouse, retrying in 5s... ", err) + time.Sleep(time.Second * 5) + Init() + return + } + logger.SugarLogger.Fatalf("failed to connect clickhouse after 5 attempts: %v", err) + } +} + +func connect() error { + ctx := context.Background() + + // Bootstrap through "default" so we can CREATE DATABASE before opening the app conn. + bootstrap, err := clickhouse.Open(options("default")) + if err != nil { + return err + } + if err := bootstrap.Exec(ctx, fmt.Sprintf("CREATE DATABASE IF NOT EXISTS %s", config.ClickhouseDatabase)); err != nil { + bootstrap.Close() + return err + } + bootstrap.Close() + + conn, err := clickhouse.Open(options(config.ClickhouseDatabase)) + if err != nil { + return err + } + if err := conn.Ping(ctx); err != nil { + conn.Close() + return err + } + if err := migrate(ctx, conn); err != nil { + conn.Close() + return err + } + logger.SugarLogger.Infoln("Connected to ClickHouse") + Conn = conn + return nil +} + +func migrate(ctx context.Context, conn driver.Conn) error { + for _, stmt := range []string{mapache.SignalClickHouseDDL, canDDL, mapache.PingClickHouseDDL} { + if err := conn.Exec(ctx, stmt); err != nil { + return err + } + } + logger.SugarLogger.Infoln("ClickHouse migration complete") + return nil +} + +// p987_can DDL lives here (service-specific); signal/ping DDL live on +// mapache-go. node_id holds the bus label, so it belongs in the sort key: +// two physical buses have independent 11-bit id spaces, and the same +// arbitration id on each is a different signal. +const canDDL = ` +CREATE TABLE IF NOT EXISTS p987_can ( + id String CODEC(ZSTD(1)), + + vehicle_id LowCardinality(String), + + node_id LowCardinality(String), + + timestamp Int64 CODEC(Delta, ZSTD(1)), + + can_id Int32 CODEC(T64, ZSTD(1)), + + bytes String CODEC(ZSTD(1)), + + upload_key Int32 CODEC(T64, ZSTD(1)), + + metadata String CODEC(ZSTD(1)), + + produced_at DateTime64(6, 'UTC') + MATERIALIZED fromUnixTimestamp64Micro(timestamp) + CODEC(Delta, ZSTD(1)), + + created_at DateTime64(6, 'UTC') + DEFAULT now64(6) + CODEC(Delta, ZSTD(1)), + + INDEX idx_id id TYPE bloom_filter GRANULARITY 4 +) +ENGINE = ReplacingMergeTree(created_at) +PARTITION BY toYYYYMM(produced_at) +ORDER BY (vehicle_id, timestamp, node_id, can_id)` diff --git a/p987/go.mod b/p987/go.mod new file mode 100644 index 00000000..faf98fbe --- /dev/null +++ b/p987/go.mod @@ -0,0 +1,63 @@ +module github.com/gaucho-racing/mapache/p987 + +go 1.26 + +require ( + github.com/ClickHouse/clickhouse-go/v2 v2.46.0 + github.com/eclipse/paho.golang v0.23.0 + github.com/fatih/color v1.18.0 + github.com/gaucho-racing/mapache/mapache-go/v3 v3.5.0 + github.com/gaucho-racing/ulid-go v1.1.0 + github.com/gin-contrib/cors v1.7.6 + github.com/gin-gonic/gin v1.12.0 + go.uber.org/zap v1.27.1 +) + +require ( + github.com/ClickHouse/ch-go v0.71.0 // indirect + github.com/andybalholm/brotli v1.2.0 // indirect + github.com/bytedance/gopkg v0.1.3 // indirect + github.com/bytedance/sonic v1.15.0 // indirect + github.com/bytedance/sonic/loader v0.5.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/cloudwego/base64x v0.1.6 // indirect + github.com/gabriel-vasile/mimetype v1.4.12 // indirect + github.com/gin-contrib/sse v1.1.0 // indirect + github.com/go-faster/city v1.0.1 // indirect + github.com/go-faster/errors v0.7.1 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.30.1 // indirect + github.com/goccy/go-json v0.10.5 // indirect + github.com/goccy/go-yaml v1.19.2 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/gorilla/websocket v1.5.3 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/compress v1.18.3 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/paulmach/orb v0.12.0 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/pierrec/lz4/v4 v4.1.25 // indirect + github.com/quic-go/qpack v0.6.0 // indirect + github.com/quic-go/quic-go v0.59.0 // indirect + github.com/segmentio/asm v1.2.1 // indirect + github.com/shopspring/decimal v1.4.0 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.3.1 // indirect + go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect + go.opentelemetry.io/otel v1.41.0 // indirect + go.opentelemetry.io/otel/trace v1.41.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/arch v0.22.0 // indirect + golang.org/x/crypto v0.48.0 // indirect + golang.org/x/net v0.51.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/text v0.34.0 // indirect + google.golang.org/protobuf v1.36.10 // indirect +) diff --git a/p987/go.sum b/p987/go.sum new file mode 100644 index 00000000..1fd307c3 --- /dev/null +++ b/p987/go.sum @@ -0,0 +1,211 @@ +github.com/ClickHouse/ch-go v0.71.0 h1:bUdZ/EZj/LcVHsMqaRUP2holqygrPWQKeMjc6nZoyRM= +github.com/ClickHouse/ch-go v0.71.0/go.mod h1:NwbNc+7jaqfY58dmdDUbG4Jl22vThgx1cYjBw0vtgXw= +github.com/ClickHouse/clickhouse-go/v2 v2.46.0 h1:s3eRy+hYmu5uzotB6ZhDofgHu8kDgGN/fpmjxRkqSpk= +github.com/ClickHouse/clickhouse-go/v2 v2.46.0/go.mod h1:giJfUVlMkcfUEPVfRpt51zZaGEx9i17gCos8gBl392c= +github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= +github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= +github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= +github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= +github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= +github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= +github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/eclipse/paho.golang v0.23.0 h1:KHgl2wz6EJo7cMBmkuhpt7C576vP+kpPv7jjvSyR6Mk= +github.com/eclipse/paho.golang v0.23.0/go.mod h1:nQRhTkoZv8EAiNs5UU0/WdQIx2NrnWUpL9nsGJTQN04= +github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= +github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= +github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= +github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/gaucho-racing/mapache/mapache-go/v3 v3.5.0 h1:sihPvHGJ9CgkoqBySJnjZ1IKoHi9kqh0l1w8nAyNDRE= +github.com/gaucho-racing/mapache/mapache-go/v3 v3.5.0/go.mod h1:2Zb3ztikLtk3UMS0/bg2nhwroSoyFWvQa/tqVGvYFlc= +github.com/gaucho-racing/ulid-go v1.1.0 h1:x00XM8EjlegfhlLYIob+U8ba5iX0gDRUr8mgBsjCunk= +github.com/gaucho-racing/ulid-go v1.1.0/go.mod h1:HwqoC27UtvXHrmhTO7K2GnXZ1VAeR6tg6EjrSEP5JUU= +github.com/gin-contrib/cors v1.7.6 h1:3gQ8GMzs1Ylpf70y8bMw4fVpycXIeX1ZemuSQIsnQQY= +github.com/gin-contrib/cors v1.7.6/go.mod h1:Ulcl+xN4jel9t1Ry8vqph23a60FwH9xVLd+3ykmTjOk= +github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= +github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= +github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= +github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc= +github.com/go-faster/city v1.0.1 h1:4WAxSZ3V2Ws4QRDrscLEDcibJY8uf41H6AhXDrNDcGw= +github.com/go-faster/city v1.0.1/go.mod h1:jKcUJId49qdW3L1qKHH/3wPeUstCVpVSXTM6vO3VcTw= +github.com/go-faster/errors v0.7.1 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AYg= +github.com/go-faster/errors v0.7.1/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w= +github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= +github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk= +github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw= +github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc= +github.com/paulmach/orb v0.12.0 h1:z+zOwjmG3MyEEqzv92UN49Lg1JFYx0L9GpGKNVDKk1s= +github.com/paulmach/orb v0.12.0/go.mod h1:5mULz1xQfs3bmQm63QEJA6lNGujuRafwA5S/EnuLaLU= +github.com/paulmach/protoscan v0.2.1/go.mod h1:SpcSwydNLrxUGSDvXvO0P7g7AuhJ7lcKfDlhJCDw2gY= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pierrec/lz4/v4 v4.1.25 h1:kocOqRffaIbU5djlIBr7Wh+cx82C0vtFb0fOurZHqD0= +github.com/pierrec/lz4/v4 v4.1.25/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= +github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= +github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw= +github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0= +github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= +github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= +github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= +github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= +github.com/xdg-go/scram v1.1.1/go.mod h1:RaEWvsqvNKKvBPvcKeFjrG2cJqOkHTiyTpzz23ni57g= +github.com/xdg-go/stringprep v1.0.3/go.mod h1:W3f5j4i+9rC0kuIEJL0ky1VpHXQU3ocBgklLGvcBnW8= +github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= +github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +go.mongodb.org/mongo-driver v1.11.4/go.mod h1:PTSz5yu21bkT/wXpkS7WR5f0ddqw5quethTUn9WM+2g= +go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE= +go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= +go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c= +go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE= +go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0= +go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= +go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI= +golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= +golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/p987/main.go b/p987/main.go new file mode 100644 index 00000000..765998d6 --- /dev/null +++ b/p987/main.go @@ -0,0 +1,32 @@ +package main + +import ( + "context" + + "github.com/gaucho-racing/mapache/p987/api" + "github.com/gaucho-racing/mapache/p987/config" + "github.com/gaucho-racing/mapache/p987/database" + "github.com/gaucho-racing/mapache/p987/mqtt" + "github.com/gaucho-racing/mapache/p987/pkg/kerbecs" + "github.com/gaucho-racing/mapache/p987/pkg/logger" + "github.com/gaucho-racing/mapache/p987/service" +) + +func main() { + logger.Init(config.IsProduction()) + defer logger.Logger.Sync() + + config.Verify() + config.PrintStartupBanner() + kerbecs.Init(config.KerbecsEndpoint, config.KerbecsUser, config.KerbecsPassword) + if config.ClickhouseEnabled() { + database.Init() + } + + mqtt.SetMessageHandler(service.HandleInboundMessage) + if err := mqtt.Init(context.Background()); err != nil { + logger.SugarLogger.Fatalf("Failed to initialize MQTT: %v", err) + } + + api.Run() +} diff --git a/p987/model/can.go b/p987/model/can.go new file mode 100644 index 00000000..4371aa0d --- /dev/null +++ b/p987/model/can.go @@ -0,0 +1,30 @@ +package model + +import "time" + +// CAN is a stored record of a decoded CAN frame. +// +// NodeID carries the bus label (pcan, kcan, tcm) rather than a sender node +// id. On stock Porsche CAN the sending ECU is a function of the +// arbitration id, so the only routing fact the id cannot carry is which +// physical bus the frame arrived on — and two buses have independent +// 11-bit id spaces, so the bus is part of the natural key. +// +// Natural key: (vehicle_id, node_id, timestamp). +// Metadata carries {"status": ok|unknown_can_id|decode_error|..., "note": ...}. +type CAN struct { + ID string `json:"id"` + VehicleID string `json:"vehicle_id"` + NodeID string `json:"node_id"` + Timestamp int `json:"timestamp"` + CANID int `json:"can_id"` + Bytes []byte `json:"bytes"` + UploadKey int `json:"upload_key"` + Metadata []byte `json:"metadata,omitempty"` + ProducedAt time.Time `json:"produced_at"` + CreatedAt time.Time `json:"created_at"` +} + +func (CAN) TableName() string { + return "p987_can" +} diff --git a/p987/model/dme.go b/p987/model/dme.go new file mode 100644 index 00000000..2a7d025c --- /dev/null +++ b/p987/model/dme.go @@ -0,0 +1,493 @@ +package model + +import mp "github.com/gaucho-racing/mapache/mapache-go/v3" + +// Heartbeat is 0x140 from DME. +var Heartbeat = mp.Message{ + mp.NewField("hb_page", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("HB_Page", f.Value, 0, 8, false, 1, 0), + } + }), + mp.NewField("hb_sourceid", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("HB_SourceID", f.Value, 0, 8, false, 1, 0), + } + }), + mp.NewField("hb_counter", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("HB_Counter", f.Value, 0, 4, false, 1, 0), + } + }), + mp.NewField("hb_checksum", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("HB_Checksum", f.Value, 0, 8, false, 1, 0), + } + }), +} + +// DME1 is 0x242 from DME. +var DME1 = mp.Message{ + mp.NewField("dme1_counter", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("DME1_Counter", f.Value, 0, 4, false, 1, 0), + } + }), + mp.NewField("dme_enginetorque", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("DME_EngineTorque", f.Value, 0, 8, false, 0.39, 0), // % + } + }), + mp.NewField("dme_rpm", 2, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("DME_RPM", f.Value, 0, 16, false, 0.25, 0), // rpm + } + }), + mp.NewField("dme_interventions", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("DME_Interventions", f.Value, 0, 8, false, 0.39, 0), // % + } + }), + mp.NewField("dme_app", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("DME_APP", f.Value, 0, 8, false, 0.39215, 0), // % + } + }), + mp.NewField("dme_torqueloss", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("DME_TorqueLoss", f.Value, 0, 8, false, 0.39, 0), // % + } + }), + mp.NewField("dme_drivertrq", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("DME_DriverTrq", f.Value, 0, 8, false, 0.781, 0), // % + } + }), +} + +// DME2 is 0x245 from DME. +// +// The DBC declares multiplexed signals here without ever declaring the +// multiplexer switch, so there is no way to tell which variant a frame +// carries. Not decoded: DME2_B2_B3, DME2_MUL_Code. +var DME2 = mp.Message{ + mp.NewField("dme2_mux", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("DME2_MUX", f.Value, 6, 2, false, 1, 0), + } + }), + mp.NewField("dme_coolanttemp", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("DME_CoolantTemp", f.Value, 0, 8, false, 0.75, -48), // °C + } + }), + mp.NewField("_reserved", 2, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return nil + }), + mp.NewField("dme_idlespeedtarget", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("DME_IdleSpeedTarget", f.Value, 0, 8, false, 10, 0), // rpm + } + }), + mp.NewField("dme_momentbase", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("DME_MomentBase", f.Value, 0, 8, false, 0.39, 0), // % + } + }), + mp.NewField("dme2_counter", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("DME2_Counter", f.Value, 0, 4, false, 1, 0), + } + }), + mp.NewField("dme2_torqueindexed", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("DME2_TorqueIndexed", f.Value, 0, 8, false, 0.39215, 0), // % + } + }), +} + +// DME3 is 0x246 from DME. +// +// The DBC declares multiplexed signals here without ever declaring the +// multiplexer switch, so there is no way to tell which variant a frame +// carries. Not decoded: DME3_Muxed. +var DME3 = mp.Message{ + mp.NewField("bytes_0_0", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("DME_EngagedGear", f.Value, 0, 3, false, 1, 0), + flag("DME_KickdownActive", f.Value, 3), + flag("DME_CompressorRunning", f.Value, 4), + flag("DME_CompressorFault", f.Value, 5), + flag("Sport_Mode_Error", f.Value, 6), + flag("Ambient_Pressure_Error", f.Value, 7), + } + }), + mp.NewField("bytes_1_1", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("DME_GearRequirement", f.Value, 0, 3, false, 1, 0), + sig("DME_GearRequest", f.Value, 3, 3, false, 1, 0), + flag("DME_TRQ_Target_Error", f.Value, 6), + flag("DME_Overboost", f.Value, 7), + } + }), + mp.NewField("dme_gb_targettrq", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("DME_GB_TargetTrq", f.Value, 0, 8, false, 0.39, 0), // % + } + }), + mp.NewField("dme_accelpedalangle", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("DME_AccelPedalAngle", f.Value, 0, 8, false, 0.4, 0), // % + } + }), + mp.NewField("dme_gb_trqactual", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("DME_GB_TrqActual", f.Value, 0, 8, false, 0.39, 0), // % + } + }), + mp.NewField("dme_ambientpressure", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("DME_AmbientPressure", f.Value, 0, 8, false, 5, 0), // mbar + } + }), + mp.NewField("dme3_counter", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("DME3_Counter", f.Value, 0, 4, false, 1, 0), + } + }), + mp.NewField("_reserved", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return nil + }), +} + +// DME_Lambda is 0x303 from DME. +// Lambda / AFR / intake data. 7-byte DLC unique to 987. Signal layout is approximate — verify with wideband O2. +var DME_Lambda = mp.Message{ + mp.NewField("dme_lambda_counter", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("DME_Lambda_Counter", f.Value, 0, 4, false, 1, 0), + } + }), + mp.NewField("_reserved", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return nil + }), + mp.NewField("dme_lambda_value", 2, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("DME_Lambda_Value", f.Value, 0, 16, false, 0.0001, 0), + } + }), + mp.NewField("dme_lambda_status", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("DME_Lambda_Status", f.Value, 0, 8, false, 1, 0), + } + }), + mp.NewField("dme_lambda_checksum", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("DME_Lambda_Checksum", f.Value, 0, 8, false, 1, 0), + } + }), + mp.NewField("dme_lambda_flags", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("DME_Lambda_Flags", f.Value, 0, 8, false, 1, 0), + } + }), +} + +// DRIVEMODE is 0x308 from DME. +var DRIVEMODE = mp.Message{ + mp.NewField("bytes_0_1", 2, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("LT_Rad_Fan_PWM", f.Value, 0, 2, false, 1, 0), + sig("RT_Rad_Fan_PWM", f.Value, 2, 2, false, 1, 0), + flag("Trunk_Lid_Open", f.Value, 4), + flag("Sport_Mode", f.Value, 5), + flag("Wiper_Status", f.Value, 6), + sig("Radio_Key", f.Value, 7, 4, false, 1, 0), + flag("Low_Beam", f.Value, 11), + flag("Reverse_Light", f.Value, 12), + flag("High_Beam", f.Value, 14), + } + }), + mp.NewField("_reserved", 2, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return nil + }), + mp.NewField("outside_temp", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("Outside_Temp", f.Value, 0, 8, false, 0.5, -50), // °C + } + }), + mp.NewField("_reserved", 3, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return nil + }), +} + +// DME_Status is 0x31F from DME. +// Engine running status broadcast. Constant data during idle — likely ECU health + counters. +var DME_Status = mp.Message{ + mp.NewField("dme_status_id", 2, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("DME_Status_ID", f.Value, 0, 16, false, 1, 0), + } + }), + mp.NewField("dme_status_flags1", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("DME_Status_Flags1", f.Value, 0, 8, false, 1, 0), + } + }), + mp.NewField("dme_status_flags2", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("DME_Status_Flags2", f.Value, 0, 8, false, 1, 0), + } + }), + mp.NewField("dme_status_reserved", 2, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("DME_Status_Reserved", f.Value, 0, 16, false, 1, 0), + } + }), + mp.NewField("dme_status_counter", 2, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("DME_Status_Counter", f.Value, 0, 16, false, 1, 0), + } + }), +} + +// DME4 is 0x441 from DME. +var DME4 = mp.Message{ + mp.NewField("bytes_0_0", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + flag("DME_CEL_Flashing", f.Value, 0), + flag("DME_CEL_Steady", f.Value, 1), + flag("DME_FuelReserve", f.Value, 2), + flag("DME_ReducedPower", f.Value, 3), + flag("DME_EngCompFanAlert", f.Value, 4), + flag("DME_OilTempSensFault", f.Value, 5), + flag("DME_OilPressureAlert", f.Value, 6), + flag("DME_ChargingAlert", f.Value, 7), + } + }), + mp.NewField("bytes_1_1", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("DME_RadFanSpeedReq", f.Value, 0, 7, false, 1, 0), // % + flag("DME_EngineRunning", f.Value, 7), + } + }), + mp.NewField("dme_fuelconsumption1", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("DME_FuelConsumption1", f.Value, 0, 8, false, 1, 0), // µl + } + }), + mp.NewField("dme_fuelconsumption2", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("DME_FuelConsumption2", f.Value, 0, 8, false, 1, 0), // µl + } + }), + mp.NewField("dme_boostpressure", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("DME_BoostPressure", f.Value, 0, 8, false, 0.01, 0), // bar + } + }), + mp.NewField("dme_oiltemp", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("DME_OilTemp", f.Value, 0, 8, false, 0.75, -48), // °C + } + }), + mp.NewField("dme_oilpressure", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("DME_OilPressure", f.Value, 0, 8, false, 0.04, 0), // bar + } + }), + mp.NewField("bytes_7_7", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + flag("DME_CoolantLevelSW", f.Value, 0), + sig("DME_EngineCompTemp", f.Value, 1, 6, true, 1, -48), // °C + flag("DME_EngCompTempFail", f.Value, 7), + } + }), +} + +// DME_Torque is 0x470 from DME. +var DME_Torque = mp.Message{ + mp.NewField("dme_torque_counter", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("DME_Torque_Counter", f.Value, 0, 4, false, 1, 0), + } + }), + mp.NewField("dme_torque_req1", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("DME_Torque_Req1", f.Value, 0, 8, false, 0.39, 0), // % + } + }), + mp.NewField("dme_torque_req2", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("DME_Torque_Req2", f.Value, 0, 8, false, 0.39, 0), // % + } + }), + mp.NewField("dme_torque_req3", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("DME_Torque_Req3", f.Value, 0, 8, false, 0.39, 0), // % + } + }), + mp.NewField("dme_torque_max", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("DME_Torque_Max", f.Value, 0, 8, false, 0.39, 0), // % + } + }), + mp.NewField("dme_torque_min", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("DME_Torque_Min", f.Value, 0, 8, false, 0.39, 0), // % + } + }), + mp.NewField("_reserved", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return nil + }), + mp.NewField("dme_torque_checksum", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("DME_Torque_Checksum", f.Value, 0, 8, false, 1, 0), + } + }), +} + +// CLUSTER1 is 0x502 from DME. +var CLUSTER1 = mp.Message{ + mp.NewField("cluster1_b0", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("CLUSTER1_B0", f.Value, 0, 8, false, 1, 0), + } + }), + mp.NewField("cluster1_b1", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("CLUSTER1_B1", f.Value, 0, 8, false, 1, 0), + } + }), + mp.NewField("bytes_2_2", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("CLUSTER1_Flags", f.Value, 0, 5, false, 1, 0), + flag("CLUSTER_ClutchSW", f.Value, 5), + sig("CLUSTER1_B2u", f.Value, 6, 2, false, 1, 0), + } + }), + mp.NewField("cluster_ambbrightness", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("CLUSTER_AmbBrightness", f.Value, 0, 8, false, 0.3922, 0), // % + } + }), + mp.NewField("cluster1_b4", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("CLUSTER1_B4", f.Value, 0, 8, false, 1, 0), + } + }), + mp.NewField("cluster1_b5", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("CLUSTER1_B5", f.Value, 0, 8, false, 1, 0), + } + }), + mp.NewField("cluster1_b6", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("CLUSTER1_B6", f.Value, 0, 8, false, 1, 0), + } + }), + mp.NewField("cluster1_b7", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("CLUSTER1_B7", f.Value, 0, 8, false, 1, 0), + } + }), +} + +// Immobilizer is 0x513 from DME. +var Immobilizer = mp.Message{ + mp.NewField("immo_challenge", 5, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("Immo_Challenge", f.Value, 0, 40, false, 1, 0), + } + }), +} + +// ECU_ID1 is 0x62A from DME. +// ECU identification. Constant: 41 41 08 42 55 67 10 93. Appears at startup only. +var ECU_ID1 = mp.Message{ + mp.NewField("ecu_id1_bytes", 8, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { // ECU_ID1_Bytes: opaque 64-bit blob, kept in the raw frame + return nil + }), +} + +// ECU_Coding is 0x62F from DME. +// ECU calibration/coding data. Constant: 59 8F 9E 02 23 00 80 C7. +var ECU_Coding = mp.Message{ + mp.NewField("ecu_coding_bytes", 8, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { // ECU_Coding_Bytes: opaque 64-bit blob, kept in the raw frame + return nil + }), +} + +// DME6 is 0x669 from DME. +var DME6 = mp.Message{ + mp.NewField("dme_odometer", 3, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("DME_Odometer", f.Value, 0, 20, false, 1, 0), // km + } + }), + mp.NewField("dme_countrycode", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("DME_CountryCode", f.Value, 0, 7, false, 1, 0), + } + }), + mp.NewField("dme6_statusbit", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + flag("DME6_StatusBit", f.Value, 0), + } + }), + mp.NewField("dme6_counter", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("DME6_Counter", f.Value, 0, 8, false, 1, 0), + } + }), + mp.NewField("dme6_data", 2, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("DME6_Data", f.Value, 0, 16, false, 1, 0), + } + }), +} + +// DME8_Version is 0x716 from DME. +// Motronic software version. ASCII '5e' in bytes 0-1. +var DME8_Version = mp.Message{ + mp.NewField("dme_sw_byte0", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("DME_SW_Byte0", f.Value, 0, 8, false, 1, 0), + } + }), + mp.NewField("dme_sw_byte1", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("DME_SW_Byte1", f.Value, 0, 8, false, 1, 0), + } + }), + mp.NewField("dme_sw_byte2", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("DME_SW_Byte2", f.Value, 0, 8, false, 1, 0), + } + }), + mp.NewField("dme_sw_byte3", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("DME_SW_Byte3", f.Value, 0, 8, false, 1, 0), + } + }), + mp.NewField("dme_sw_byte4", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("DME_SW_Byte4", f.Value, 0, 8, false, 1, 0), + } + }), + mp.NewField("dme_sw_byte5", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("DME_SW_Byte5", f.Value, 0, 8, false, 1, 0), + } + }), + mp.NewField("dme_sw_byte6", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("DME_SW_Byte6", f.Value, 0, 8, false, 1, 0), + } + }), + mp.NewField("dme_sw_byte7", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("DME_SW_Byte7", f.Value, 0, 8, false, 1, 0), + } + }), +} diff --git a/p987/model/gateway.go b/p987/model/gateway.go new file mode 100644 index 00000000..8bf00284 --- /dev/null +++ b/p987/model/gateway.go @@ -0,0 +1,49 @@ +package model + +import mp "github.com/gaucho-racing/mapache/mapache-go/v3" + +// Gateway_Cfg is 0x70B from Gateway. +// Gateway module configuration. Constant during capture. +var Gateway_Cfg = mp.Message{ + mp.NewField("gw_config_data", 8, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { // GW_Config_Data: opaque 64-bit blob, kept in the raw frame + return nil + }), +} + +// Gateway_Net is 0x70D from Gateway. +// Gateway network configuration. Transitions 0x00→active during startup. +var Gateway_Net = mp.Message{ + mp.NewField("gw_network_data", 8, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { // GW_Network_Data: opaque 64-bit blob, kept in the raw frame + return nil + }), +} + +// Gateway_State is 0x719 from Gateway. +// Gateway wake/sleep state. D4 changes 0x0B→0x5B at startup transition. +var Gateway_State = mp.Message{ + mp.NewField("gw_state_b0", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("GW_State_B0", f.Value, 0, 8, false, 1, 0), + } + }), + mp.NewField("gw_state_b1", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("GW_State_B1", f.Value, 0, 8, false, 1, 0), + } + }), + mp.NewField("gw_state_b2", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("GW_State_B2", f.Value, 0, 8, false, 1, 0), + } + }), + mp.NewField("gw_state_change", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("GW_State_Change", f.Value, 0, 8, false, 1, 0), + } + }), + mp.NewField("gw_state_b4_b7", 4, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("GW_State_B4_B7", f.Value, 0, 32, false, 1, 0), + } + }), +} diff --git a/p987/model/klima.go b/p987/model/klima.go new file mode 100644 index 00000000..6429ebdc --- /dev/null +++ b/p987/model/klima.go @@ -0,0 +1,51 @@ +package model + +import mp "github.com/gaucho-racing/mapache/mapache-go/v3" + +// KLIMA is 0x600 from KLIMO. +var KLIMA = mp.Message{ + mp.NewField("bytes_0_0", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + flag("HVAC_Fan_Increase", f.Value, 0), + flag("HVAC_Display_On", f.Value, 2), + flag("KLIMA_CompressorReq", f.Value, 3), + } + }), + mp.NewField("bytes_1_1", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("KLIMA_B1_Bits", f.Value, 0, 4, false, 1, 0), + flag("KLIMA_RearDefrost", f.Value, 4), + sig("KLIMA_BlowerStage", f.Value, 5, 3, false, 1, 0), + } + }), + mp.NewField("klima_refrigpressure", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("KLIMA_RefrigPressure", f.Value, 0, 8, false, 0.2, 0), // Bar + } + }), + mp.NewField("klima_b3_temp", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("KLIMA_B3_Temp", f.Value, 0, 8, false, 0.5, -50), // °C + } + }), + mp.NewField("klima_blowerspeed", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("KLIMA_BlowerSpeed", f.Value, 0, 8, false, 1, 0), + } + }), + mp.NewField("klima_insidetemp", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("KLIMA_InsideTemp", f.Value, 0, 8, false, 0.5, -50), // °C + } + }), + mp.NewField("klima_b6_temp", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("KLIMA_B6_Temp", f.Value, 0, 8, false, 0.5, -50), // °C + } + }), + mp.NewField("klima_b7", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("KLIMA_B7", f.Value, 0, 8, false, 1, 0), + } + }), +} diff --git a/p987/model/message.go b/p987/model/message.go new file mode 100644 index 00000000..7c949caa --- /dev/null +++ b/p987/model/message.go @@ -0,0 +1,75 @@ +package model + +import mp "github.com/gaucho-racing/mapache/mapache-go/v3" + +// messageMap is the vehicle CAN decoder registry, transcribed from +// cayman_987.dbc in the TCM-987 repo. Ids not listed here are still +// persisted as raw frames. +var messageMap = map[int]mp.Message{ + // DME + 0x140: Heartbeat, + 0x242: DME1, + 0x245: DME2, + 0x246: DME3, + 0x303: DME_Lambda, + 0x308: DRIVEMODE, + 0x31F: DME_Status, + 0x441: DME4, + 0x470: DME_Torque, + 0x502: CLUSTER1, + 0x513: Immobilizer, + 0x62A: ECU_ID1, + 0x62F: ECU_Coding, + 0x669: DME6, + 0x716: DME8_Version, + // Gateway + 0x70B: Gateway_Cfg, + 0x70D: Gateway_Net, + 0x719: Gateway_State, + // KLIMO + 0x600: KLIMA, + // PAS + 0x165: PAS, + // PDK + 0x44C: PDK1, + 0x44F: PDK_Flags, + // PSM + 0x14A: PSM1, + 0x24A: PSM2, + 0x44A: PSM3, + 0x44B: PSM4, + 0x718: PSM5, + // SCCM + 0x0C2: SCCM1, + 0x210: SCCM2, + 0x71A: SCCM3, +} + +// tcmMessageMap holds the TCM's synthetic frames, which are only valid on +// the "tcm" bus. The split matters: shelter injects its frames at 0x210 +// and 0x211 through the relay's virtual CAN port, and 0x210 is also SCCM2 +// on the car's own bus. Same id, different message, told apart only by +// which bus it arrived on. +var tcmMessageMap = map[int]mp.Message{ + MsgIDTCMStatus: TCMStatus, + MsgIDTCMResources: TCMResourceUtil, +} + +// BusTCM is the label the relay publishes under for frames that never +// touched a physical CAN bus. +const BusTCM = "tcm" + +// GetMessage returns the decoder for an id on a given bus, or nil when +// nothing is registered for it. +func GetMessage(bus string, id int) mp.Message { + if bus == BusTCM { + if msg, ok := tcmMessageMap[id]; ok { + return msg + } + return nil + } + if msg, ok := messageMap[id]; ok { + return msg + } + return nil +} diff --git a/p987/model/pas.go b/p987/model/pas.go new file mode 100644 index 00000000..8d0dfdbb --- /dev/null +++ b/p987/model/pas.go @@ -0,0 +1,13 @@ +package model + +import mp "github.com/gaucho-racing/mapache/mapache-go/v3" + +// PAS is 0x165 from PAS. +var PAS = mp.Message{ + mp.NewField("bytes_0_0", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + flag("PAS_KeyPresent", f.Value, 0), + sig("PAS_KeyPosition", f.Value, 2, 2, false, 1, 0), + } + }), +} diff --git a/p987/model/pdk.go b/p987/model/pdk.go new file mode 100644 index 00000000..05c098e5 --- /dev/null +++ b/p987/model/pdk.go @@ -0,0 +1,55 @@ +package model + +import mp "github.com/gaucho-racing/mapache/mapache-go/v3" + +// PDK1 is 0x44C from PDK. +var PDK1 = mp.Message{ + mp.NewField("pdk_selectedgear", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("PDK_SelectedGear", f.Value, 0, 3, false, 1, 0), + } + }), + mp.NewField("pdk_shiftfork1", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("PDK_ShiftFork1", f.Value, 0, 8, false, 1, 0), + } + }), + mp.NewField("pdk_shiftfork2", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("PDK_ShiftFork2", f.Value, 0, 8, false, 1, 0), + } + }), + mp.NewField("pdk_clutchstatus", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("PDK_ClutchStatus", f.Value, 0, 8, false, 1, 0), + } + }), + mp.NewField("pdk_oiltemp", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("PDK_OilTemp", f.Value, 0, 8, false, 0.75, -48), // °C + } + }), + mp.NewField("_reserved", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return nil + }), + mp.NewField("pdk_counter", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("PDK_Counter", f.Value, 0, 4, false, 1, 0), + } + }), + mp.NewField("pdk_checksum", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("PDK_Checksum", f.Value, 0, 8, false, 1, 0), + } + }), +} + +// PDK_Flags is 0x44F from PDK. +// PDK fault flags. All zero in this capture — gearbox healthy. +var PDK_Flags = mp.Message{ + mp.NewField("pdk_errorflags", 2, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("PDK_ErrorFlags", f.Value, 0, 16, false, 1, 0), + } + }), +} diff --git a/p987/model/psm.go b/p987/model/psm.go new file mode 100644 index 00000000..80716437 --- /dev/null +++ b/p987/model/psm.go @@ -0,0 +1,194 @@ +package model + +import mp "github.com/gaucho-racing/mapache/mapache-go/v3" + +// PSM1 is 0x14A from PSM. +var PSM1 = mp.Message{ + mp.NewField("bytes_0_0", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + flag("ASR_Requirement", f.Value, 0), + flag("MSR_Requirement", f.Value, 1), + flag("ABS_Status", f.Value, 2), + flag("Brake_Intervention", f.Value, 3), + flag("ESP_Intervention", f.Value, 4), + sig("ASR_Switching", f.Value, 5, 2, false, 1, 0), + flag("ESP_Control", f.Value, 7), + } + }), + mp.NewField("bytes_1_1", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + flag("ABS_Error", f.Value, 0), + flag("ESP_Error", f.Value, 1), + flag("EBV_Error", f.Value, 2), + flag("PSM_FootBrake", f.Value, 3), + flag("PSM_FootBrake2", f.Value, 4), + flag("PSM_Disabled", f.Value, 5), + flag("Brake_Fluid_Switch", f.Value, 6), + flag("PSM_HandBrake", f.Value, 7), + } + }), + mp.NewField("bytes_2_3", 2, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + flag("ESP_Diag_Mode", f.Value, 0), + sig("Vref", f.Value, 0, 16, false, 0.01, 0), // km/h + } + }), + mp.NewField("psm_torquereqslow", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("PSM_TorqueReqSlow", f.Value, 0, 8, false, 1, 0), + } + }), + mp.NewField("psm_torquereqfast", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("PSM_TorqueReqFast", f.Value, 0, 8, false, 1, 0), + } + }), + mp.NewField("engagement_torque", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("Engagement_Torque", f.Value, 0, 8, false, 0.39, 0), // % + } + }), + mp.NewField("psm_lateralaccel", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("PSM_LateralAccel", f.Value, 0, 8, false, 1, 0), + } + }), +} + +// PSM2 is 0x24A from PSM. +var PSM2 = mp.Message{ + mp.NewField("psm_wheelspeedfl", 2, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("PSM_WheelSpeedFL", f.Value, 0, 16, false, 0.01, 0), // km/h + } + }), + mp.NewField("psm_wheelspeedfr", 2, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("PSM_WheelSpeedFR", f.Value, 0, 16, false, 0.01, 0), // km/h + } + }), + mp.NewField("psm_wheelspeedrl", 2, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("PSM_WheelSpeedRL", f.Value, 0, 16, false, 0.01, 0), // km/h + } + }), + mp.NewField("psm_wheelspeedrr", 2, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("PSM_WheelSpeedRR", f.Value, 0, 16, false, 0.01, 0), // km/h + } + }), +} + +// PSM3 is 0x44A from PSM. +var PSM3 = mp.Message{ + mp.NewField("psm3_b0", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("PSM3_B0", f.Value, 0, 8, false, 1, 0), + } + }), + mp.NewField("psm3_b1", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("PSM3_B1", f.Value, 0, 8, false, 1, 0), + } + }), + mp.NewField("psm3_b2", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("PSM3_B2", f.Value, 0, 8, false, 1, 0), + } + }), + mp.NewField("psm3_b3", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("PSM3_B3", f.Value, 0, 8, false, 1, 0), + } + }), + mp.NewField("psm3_b4", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("PSM3_B4", f.Value, 0, 8, false, 1, 0), + } + }), + mp.NewField("psm3_b5", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("PSM3_B5", f.Value, 0, 8, false, 1, 0), + } + }), + mp.NewField("psm3_b6", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("PSM3_B6", f.Value, 0, 8, false, 1, 0), + } + }), + mp.NewField("psm3_b7", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("PSM3_B7", f.Value, 0, 8, false, 1, 0), + } + }), +} + +// PSM4 is 0x44B from PSM. +var PSM4 = mp.Message{ + mp.NewField("psm_brakepressure", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("PSM_BrakePressure", f.Value, 0, 8, false, 1, 0), // Bar + } + }), + mp.NewField("_reserved", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return nil + }), + mp.NewField("bytes_2_3", 2, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("Yaw_Rate", f.Value, 0, 9, false, 0.0021326, 0), // rad/s + flag("Yaw_Rate_Sign", f.Value, 9), + } + }), + mp.NewField("_reserved", 3, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return nil + }), + mp.NewField("longitudinal_accel", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("Longitudinal_Accel", f.Value, 0, 8, false, 0.015, -1.8), // g + } + }), +} + +// PSM5 is 0x718 from PSM. +var PSM5 = mp.Message{ + mp.NewField("psm5_b0", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("PSM5_B0", f.Value, 0, 8, false, 1, 0), + } + }), + mp.NewField("psm5_b1", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("PSM5_B1", f.Value, 0, 8, false, 1, 0), + } + }), + mp.NewField("psm5_b2", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("PSM5_B2", f.Value, 0, 8, false, 1, 0), + } + }), + mp.NewField("psm5_b3", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("PSM5_B3", f.Value, 0, 8, false, 1, 0), + } + }), + mp.NewField("psm5_b4", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("PSM5_B4", f.Value, 0, 8, false, 1, 0), + } + }), + mp.NewField("psm5_b5", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("PSM5_B5", f.Value, 0, 8, false, 1, 0), + } + }), + mp.NewField("psm5_b6", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("PSM5_B6", f.Value, 0, 8, false, 1, 0), + } + }), + mp.NewField("psm5_b7", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("PSM5_B7", f.Value, 0, 8, false, 1, 0), + } + }), +} diff --git a/p987/model/sccm.go b/p987/model/sccm.go new file mode 100644 index 00000000..7a757f9c --- /dev/null +++ b/p987/model/sccm.go @@ -0,0 +1,110 @@ +package model + +import mp "github.com/gaucho-racing/mapache/mapache-go/v3" + +// SCCM1 is 0xC2 from SCCM. +var SCCM1 = mp.Message{ + mp.NewField("bytes_0_1", 2, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("SCCM_SteeringAngle", f.Value, 2, 13, false, 0.175, 0), // deg + flag("SCCM_SteeringAngleSign", f.Value, 15), + } + }), + mp.NewField("bytes_2_3", 2, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("SCCM_SteeringAngleRate", f.Value, 2, 13, false, 0.175, 0), // deg/sec + flag("SCCM_SteeringAngleRateSign", f.Value, 15), + } + }), + mp.NewField("sccm_sensorid", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("SCCM_SensorID", f.Value, 0, 8, false, 1, 0), + } + }), + mp.NewField("sccm_counter", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("SCCM_Counter", f.Value, 4, 4, false, 1, 0), + } + }), + mp.NewField("_reserved", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return nil + }), + mp.NewField("sccm_checksum", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("SCCM_Checksum", f.Value, 0, 8, false, 1, 0), + } + }), +} + +// SCCM2 is 0x210 from SCCM. +var SCCM2 = mp.Message{ + mp.NewField("sccm_cruisecount1", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("SCCM_CruiseCount1", f.Value, 4, 4, false, 1, 0), + } + }), + mp.NewField("bytes_1_1", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + flag("SCCM_CruiseEnable", f.Value, 0), + flag("SCCM_CruiseDown", f.Value, 1), + flag("SCCM_CruiseTowards", f.Value, 2), + flag("SCCM_CruiseAway", f.Value, 3), + flag("SCCM_CruiseTowardsHold", f.Value, 4), + flag("SCCM_CruiseAwayHold", f.Value, 5), + flag("SCCM_CruiseAvailable", f.Value, 7), + } + }), + mp.NewField("bytes_2_2", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + flag("SCCM_CruiseUp", f.Value, 1), + sig("SCCM_CruiseCount2", f.Value, 4, 4, false, 1, 0), + } + }), + mp.NewField("_reserved", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return nil + }), +} + +// SCCM3 is 0x71A from SCCM. +var SCCM3 = mp.Message{ + mp.NewField("sccm3_b0", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("SCCM3_B0", f.Value, 0, 8, false, 1, 0), + } + }), + mp.NewField("sccm3_b1", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("SCCM3_B1", f.Value, 0, 8, false, 1, 0), + } + }), + mp.NewField("sccm3_b2", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("SCCM3_B2", f.Value, 0, 8, false, 1, 0), + } + }), + mp.NewField("sccm3_b3", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("SCCM3_B3", f.Value, 0, 8, false, 1, 0), + } + }), + mp.NewField("sccm3_b4", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("SCCM3_B4", f.Value, 0, 8, false, 1, 0), + } + }), + mp.NewField("sccm3_b5", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("SCCM3_B5", f.Value, 0, 8, false, 1, 0), + } + }), + mp.NewField("sccm3_b6", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("SCCM3_B6", f.Value, 0, 8, false, 1, 0), + } + }), + mp.NewField("sccm3_b7", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig("SCCM3_B7", f.Value, 0, 8, false, 1, 0), + } + }), +} diff --git a/p987/model/signal.go b/p987/model/signal.go new file mode 100644 index 00000000..e044e4cf --- /dev/null +++ b/p987/model/signal.go @@ -0,0 +1,34 @@ +package model + +import mp "github.com/gaucho-racing/mapache/mapache-go/v3" + +// Stock Porsche signals do not sit on byte boundaries — SCCM_SteeringAngle +// is 13 bits starting at bit 2 — so a field here is the contiguous run of +// bytes its signals occupy, and each signal is shifted and masked out of +// the field value. Shifts are relative to the start of the field, not the +// start of the frame. + +// raw pulls length bits starting shift bits into the field value, +// sign-extending when the DBC marks the signal signed. +func raw(v, shift, length int, signed bool) int { + if length >= 64 { + return v + } + x := (v >> shift) & (1<> shift & 1 + return mp.Signal{Name: name, Value: float64(r), RawValue: r} +} diff --git a/p987/model/tcm.go b/p987/model/tcm.go new file mode 100644 index 00000000..7756b253 --- /dev/null +++ b/p987/model/tcm.go @@ -0,0 +1,98 @@ +package model + +import ( + "fmt" + + mp "github.com/gaucho-racing/mapache/mapache-go/v3" +) + +// The TCM publishes these under the "tcm" bus label. They never touched a +// physical CAN bus, so they are not in the 987 DBC. +const ( + MsgIDTCMStatus = 0x200 + MsgIDTCMResources = 0x201 +) + +// TCMStatus is a synthetic 8-byte message the relay publishes every 5s +// summarizing on-vehicle connectivity. status_bits is a flat bitfield; +// each bit is exposed as its own boolean signal so consumers can query +// "is X reachable?" without bit-twiddling. +// +// connection_ok — TCM has general internet (DNS reachable) +// mqtt_ok — cloud MQTT broker is connected +// mapache_ok — cloud Mapache is responding (recent pong) +// clock_ok — local clock past the 2003-10-31 cutoff (RTC/NTP synced) +// mapache_ping — RTT to Mapache in ms, from the most recent pong +var TCMStatus = mp.Message{ + mp.NewField("status_bits", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + flag("connection_ok", f.Value, 0), + flag("mqtt_ok", f.Value, 1), + flag("mapache_ok", f.Value, 2), + flag("clock_ok", f.Value, 3), + } + }), + mp.NewField("mapache_ping", 2, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + {Name: "mapache_ping", Value: float64(f.Value), RawValue: f.Value}, + } + }), + mp.NewField("_reserved", 5, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return nil + }), +} + +// TCMResourceUtil is the relay's 29-byte resource frame, published every +// 10s. Deliberately not TCM-26's 44-byte Jetson layout: a Pi Zero 2 W is +// quad-core with no discrete GPU counters and no power-rail sensors, so +// those fields would be permanently zero. The freed space carries throttle +// flags instead, which is the failure mode this board actually has — +// under-voltage corrupts SD cards and shows up in no other metric. +var TCMResourceUtil = mp.Message{ + cpuField(0), cpuField(1), cpuField(2), cpuField(3), + mp.NewField("cpu_total_util", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{{Name: "cpu_total_util", Value: float64(f.Value), RawValue: f.Value}} + }), + mp.NewField("ram_total", 2, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{{Name: "ram_total", Value: float64(f.Value), RawValue: f.Value}} + }), + mp.NewField("ram_used", 2, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{{Name: "ram_used", Value: float64(f.Value), RawValue: f.Value}} + }), + mp.NewField("ram_util", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{{Name: "ram_util", Value: float64(f.Value), RawValue: f.Value}} + }), + mp.NewField("disk_total", 4, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{{Name: "disk_total", Value: float64(f.Value), RawValue: f.Value}} + }), + mp.NewField("disk_used", 4, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{{Name: "disk_used", Value: float64(f.Value), RawValue: f.Value}} + }), + mp.NewField("disk_util", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{{Name: "disk_util", Value: float64(f.Value), RawValue: f.Value}} + }), + mp.NewField("cpu_temp", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{{Name: "cpu_temp", Value: float64(f.Value), RawValue: f.Value}} + }), + mp.NewField("throttle_flags", 1, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + flag("undervoltage", f.Value, 0), + flag("undervoltage_since_boot", f.Value, 1), + flag("thermal_throttled", f.Value, 2), + flag("thermal_throttled_since_boot", f.Value, 3), + } + }), +} + +// cpuField builds the repeated per-core (freq u16, util u8) triple. The Pi +// Zero 2 W's BCM2710A1 is quad-core, so the relay sends exactly four. +func cpuField(n int) mp.Field { + freq := fmt.Sprintf("cpu_%d_freq", n) + util := fmt.Sprintf("cpu_%d_util", n) + return mp.NewField(fmt.Sprintf("cpu_%d", n), 3, mp.Unsigned, mp.LittleEndian, func(f mp.Field) []mp.Signal { + return []mp.Signal{ + sig(freq, f.Value, 0, 16, false, 1, 0), + sig(util, f.Value, 16, 8, false, 1, 0), + } + }) +} diff --git a/p987/mqtt/mqtt.go b/p987/mqtt/mqtt.go new file mode 100644 index 00000000..7358a0a8 --- /dev/null +++ b/p987/mqtt/mqtt.go @@ -0,0 +1,127 @@ +package mqtt + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "os" + "sync" + "time" + + "github.com/gaucho-racing/mapache/p987/config" + "github.com/gaucho-racing/mapache/p987/pkg/logger" + + "github.com/eclipse/paho.golang/autopaho" + "github.com/eclipse/paho.golang/paho" +) + +const ( + sharedSubGroup = "p987-cluster" + canTopicFilter = "$share/" + sharedSubGroup + "/" + config.TopicRoot + "/#" +) + +const ( + keepAliveSeconds = 30 + connectTimeout = 15 * time.Second +) + +var Manager *autopaho.ConnectionManager + +type MessageHandler func(topic string, payload []byte) + +var ( + handlerMu sync.RWMutex + handler MessageHandler +) + +func SetMessageHandler(h MessageHandler) { + handlerMu.Lock() + handler = h + handlerMu.Unlock() +} + +func Init(ctx context.Context) error { + serverURL, err := url.Parse(fmt.Sprintf("mqtt://%s:%s", config.MQTTHost, config.MQTTPort)) + if err != nil { + return fmt.Errorf("invalid MQTT URL: %w", err) + } + + host, err := os.Hostname() + if err != nil || host == "" { + host = fmt.Sprintf("pid%d", os.Getpid()) + } + clientID := fmt.Sprintf("%s-%s", config.Service.Name, host) + + cfg := autopaho.ClientConfig{ + ServerUrls: []*url.URL{serverURL}, + KeepAlive: keepAliveSeconds, + ConnectTimeout: connectTimeout, + ConnectUsername: config.MQTTUser, + ConnectPassword: []byte(config.MQTTPassword), + CleanStartOnInitialConnection: true, + SessionExpiryInterval: 0, + OnConnectionUp: func(cm *autopaho.ConnectionManager, _ *paho.Connack) { + logger.SugarLogger.Infoln("[MQ] Connected to MQTT broker") + if _, err := cm.Subscribe(context.Background(), &paho.Subscribe{ + Subscriptions: []paho.SubscribeOptions{ + {Topic: canTopicFilter, QoS: 0}, + }, + }); err != nil { + logger.SugarLogger.Warnf("[MQ] Subscribe to %s failed: %v", canTopicFilter, err) + } + }, + OnConnectError: func(err error) { + logger.SugarLogger.Warnf("[MQ] Connection error: %v", err) + }, + ClientConfig: paho.ClientConfig{ + ClientID: clientID, + OnPublishReceived: []func(paho.PublishReceived) (bool, error){ + func(pr paho.PublishReceived) (bool, error) { + handlerMu.RLock() + h := handler + handlerMu.RUnlock() + if h != nil { + h(pr.Packet.Topic, pr.Packet.Payload) + } + return true, nil + }, + }, + OnClientError: func(err error) { + logger.SugarLogger.Warnf("[MQ] Client error: %v", err) + }, + OnServerDisconnect: func(d *paho.Disconnect) { + logger.SugarLogger.Warnf("[MQ] Server disconnect: reason=%d", d.ReasonCode) + }, + }, + } + + cm, err := autopaho.NewConnection(ctx, cfg) + if err != nil { + return fmt.Errorf("autopaho.NewConnection: %w", err) + } + Manager = cm + return nil +} + +func Publish(ctx context.Context, topic string, payload []byte) { + if Manager == nil { + return + } + if _, err := Manager.Publish(ctx, &paho.Publish{ + QoS: 0, + Topic: topic, + Payload: payload, + }); err != nil { + logger.SugarLogger.Warnf("[MQ] Publish to %s failed: %v", topic, err) + } +} + +func PublishJSON(ctx context.Context, topic string, v any) { + payload, err := json.Marshal(v) + if err != nil { + logger.SugarLogger.Warnf("[MQ] JSON marshal for %s failed: %v", topic, err) + return + } + Publish(ctx, topic, payload) +} diff --git a/p987/pkg/kerbecs/kerbecs.go b/p987/pkg/kerbecs/kerbecs.go new file mode 100644 index 00000000..b7c6bd22 --- /dev/null +++ b/p987/pkg/kerbecs/kerbecs.go @@ -0,0 +1,114 @@ +// Package kerbecs resolves gateway-form paths (e.g. /vehicles/{id}) to the +// concrete upstream URL by asking the kerbecs gateway's admin /admin-gw/resolve +// endpoint. Mirrors the pattern from Sentinel — kerbecs is the routing source +// of truth, so we ask it where a request should go and cache the answer. +package kerbecs + +import ( + "encoding/json" + "fmt" + "net/http" + "net/url" + "strings" + "sync" + "time" +) + +const cacheTTL = 5 * time.Minute + +var ( + endpoint string + user string + password string + client = &http.Client{Timeout: 5 * time.Second} +) + +type entry struct { + url string + exp time.Time +} + +var ( + mu sync.RWMutex + cache = map[string]entry{} +) + +// Init configures the resolver against the kerbecs admin API. No connection is +// made here — lookups happen lazily on first Resolve. +func Init(adminEndpoint, adminUser, adminPassword string) { + endpoint = strings.TrimRight(adminEndpoint, "/") + user = adminUser + password = adminPassword + go sweep() +} + +type resolveResponse struct { + Matched bool `json:"matched"` + URL string `json:"url"` + RewrittenPath string `json:"rewritten_path"` +} + +// Resolve maps a gateway-form path and HTTP method to the full upstream URL. +// Answers are cached for cacheTTL. +func Resolve(method, path string) (string, error) { + if endpoint == "" { + return "", fmt.Errorf("kerbecs resolver not initialized") + } + key := method + " " + path + + mu.RLock() + if e, ok := cache[key]; ok && time.Now().Before(e.exp) { + mu.RUnlock() + return e.url, nil + } + mu.RUnlock() + + q := url.Values{} + q.Set("path", path) + q.Set("method", method) + req, err := http.NewRequest(http.MethodGet, endpoint+"/admin-gw/resolve?"+q.Encode(), nil) + if err != nil { + return "", fmt.Errorf("resolve %s: %w", path, err) + } + req.SetBasicAuth(user, password) + + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("resolve %s: %w", path, err) + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + return "", fmt.Errorf("no upstream registered for %s", path) + } + if resp.StatusCode >= 400 { + return "", fmt.Errorf("resolve %s: kerbecs returned %d", path, resp.StatusCode) + } + + var rr resolveResponse + if err := json.NewDecoder(resp.Body).Decode(&rr); err != nil { + return "", fmt.Errorf("resolve %s: decode: %w", path, err) + } + if !rr.Matched { + return "", fmt.Errorf("no upstream registered for %s", path) + } + + full := strings.TrimRight(rr.URL, "/") + rr.RewrittenPath + mu.Lock() + cache[key] = entry{url: full, exp: time.Now().Add(cacheTTL)} + mu.Unlock() + return full, nil +} + +func sweep() { + for range time.Tick(cacheTTL) { + now := time.Now() + mu.Lock() + for k, e := range cache { + if now.After(e.exp) { + delete(cache, k) + } + } + mu.Unlock() + } +} diff --git a/p987/pkg/logger/logger.go b/p987/pkg/logger/logger.go new file mode 100644 index 00000000..2722d06c --- /dev/null +++ b/p987/pkg/logger/logger.go @@ -0,0 +1,16 @@ +package logger + +import ( + "go.uber.org/zap" +) + +var Logger *zap.Logger +var SugarLogger *zap.SugaredLogger + +func Init(production bool) { + Logger = zap.Must(zap.NewProduction()) + if !production { + Logger = zap.Must(zap.NewDevelopment(zap.AddCaller(), zap.AddStacktrace(zap.ErrorLevel))) + } + SugarLogger = Logger.Sugar() +} diff --git a/p987/service/can.go b/p987/service/can.go new file mode 100644 index 00000000..a67c55b9 --- /dev/null +++ b/p987/service/can.go @@ -0,0 +1,161 @@ +package service + +import ( + "context" + "database/sql" + "errors" + "time" + + "github.com/gaucho-racing/mapache/p987/config" + "github.com/gaucho-racing/mapache/p987/database" + "github.com/gaucho-racing/mapache/p987/model" + + mapache "github.com/gaucho-racing/mapache/mapache-go/v3" + + "github.com/ClickHouse/clickhouse-go/v2/lib/driver" + ulid "github.com/gaucho-racing/ulid-go" +) + +// ErrNotFound is mapped to 404 by the API layer. +var ErrNotFound = errors.New("record not found") + +// MATERIALIZED columns are excluded from SELECT *, so we list explicitly. +const canColumns = `id, vehicle_id, node_id, timestamp, can_id, bytes, upload_key, metadata, produced_at, created_at` +const canColumnsC = `c.id, c.vehicle_id, c.node_id, c.timestamp, c.can_id, c.bytes, c.upload_key, c.metadata, c.produced_at, c.created_at` + +// GetCAN looks up a stored CAN frame by ulid. +func GetCAN(id string) (model.CAN, error) { + row := database.Conn.QueryRow(context.Background(), + "SELECT "+canColumns+" FROM p987_can FINAL WHERE id = ? LIMIT 1", id) + return scanCANRow(row) +} + +// GetCANForSignal joins back to the source CAN frame using the signal +// name's `_` prefix to recover node_id. +func GetCANForSignal(signalID string) (model.CAN, error) { + row := database.Conn.QueryRow(context.Background(), ` + SELECT `+canColumnsC+` + FROM p987_can AS c FINAL + INNER JOIN ( + SELECT vehicle_id, timestamp, splitByChar('_', name)[1] AS node_id + FROM signal FINAL WHERE id = ? + ) AS s + ON c.vehicle_id = s.vehicle_id AND c.timestamp = s.timestamp AND c.node_id = s.node_id + LIMIT 1`, signalID) + return scanCANRow(row) +} + +// GetSignalsForCAN returns every signal decoded from the given frame, +// ordered by name for a stable response. +func GetSignalsForCAN(canMessageID string) ([]mapache.Signal, error) { + rows, err := database.Conn.Query(context.Background(), ` + SELECT s.id, s.timestamp, s.vehicle_id, s.name, s.value, s.raw_value, s.produced_at, s.created_at + FROM signal AS s FINAL + INNER JOIN ( + SELECT vehicle_id, timestamp, node_id FROM p987_can FINAL WHERE id = ? + ) AS c + ON s.vehicle_id = c.vehicle_id AND s.timestamp = c.timestamp AND splitByChar('_', s.name)[1] = c.node_id + ORDER BY s.name ASC`, canMessageID) + if err != nil { + return nil, err + } + defer rows.Close() + + var signals []mapache.Signal + for rows.Next() { + var ( + id, vehicleID, name string + ts, rawValue int64 + value float64 + producedAt, createdAt time.Time + ) + if err := rows.Scan(&id, &ts, &vehicleID, &name, &value, &rawValue, &producedAt, &createdAt); err != nil { + return nil, err + } + signals = append(signals, mapache.Signal{ + ID: id, + Timestamp: int(ts), + VehicleID: vehicleID, + Name: name, + Value: value, + RawValue: int(rawValue), + ProducedAt: producedAt, + CreatedAt: createdAt, + }) + } + return signals, rows.Err() +} + +// Dedup on (vehicle_id, node_id, timestamp) is handled by the +// ReplacingMergeTree engine — latest created_at wins on merge. +const insertCANSQL = `INSERT INTO p987_can (id, vehicle_id, node_id, timestamp, can_id, bytes, upload_key, metadata) VALUES (?, ?, ?, ?, ?, ?, ?, ?)` +const insertCANBatchSQL = `INSERT INTO p987_can (id, vehicle_id, node_id, timestamp, can_id, bytes, upload_key, metadata)` + +func CreateCAN(can model.CAN) (model.CAN, error) { + can.ID = ulid.Make().Prefixed("can") + if !config.ClickhouseEnabled() { + return can, nil + } + ctx := database.InsertCtx(context.Background()) + if err := database.Conn.Exec(ctx, insertCANSQL, + can.ID, can.VehicleID, can.NodeID, int64(can.Timestamp), int32(can.CANID), + string(can.Bytes), int32(can.UploadKey), string(can.Metadata), + ); err != nil { + return model.CAN{}, err + } + return can, nil +} + +// CreateCANs is the bulk-ingest counterpart of CreateCAN: one buffered block +// insert per call — a single round trip regardless of frame count. async_insert +// (InsertCtx) doesn't apply to native block inserts, so plain ctx here. +func CreateCANs(cans []model.CAN) error { + for i := range cans { + cans[i].ID = ulid.Make().Prefixed("can") + } + if !config.ClickhouseEnabled() || len(cans) == 0 { + return nil + } + batch, err := database.Conn.PrepareBatch(context.Background(), insertCANBatchSQL) + if err != nil { + return err + } + defer batch.Close() + for _, c := range cans { + if err := batch.Append( + c.ID, c.VehicleID, c.NodeID, int64(c.Timestamp), int32(c.CANID), + string(c.Bytes), int32(c.UploadKey), string(c.Metadata), + ); err != nil { + return err + } + } + return batch.Send() +} + +func scanCANRow(row driver.Row) (model.CAN, error) { + var ( + id, vehicleID, nodeID string + bytesStr, metaStr string + ts int64 + canID, uploadKey int32 + producedAt, createdAt time.Time + ) + if err := row.Scan(&id, &vehicleID, &nodeID, &ts, &canID, &bytesStr, &uploadKey, &metaStr, &producedAt, &createdAt); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return model.CAN{}, ErrNotFound + } + return model.CAN{}, err + } + return model.CAN{ + ID: id, + VehicleID: vehicleID, + NodeID: nodeID, + Timestamp: int(ts), + CANID: int(canID), + Bytes: []byte(bytesStr), + UploadKey: int(uploadKey), + Metadata: []byte(metaStr), + ProducedAt: producedAt, + CreatedAt: createdAt, + }, nil +} diff --git a/p987/service/message.go b/p987/service/message.go new file mode 100644 index 00000000..7e623e51 --- /dev/null +++ b/p987/service/message.go @@ -0,0 +1,186 @@ +package service + +import ( + "context" + "encoding/binary" + "encoding/json" + "fmt" + "strconv" + "strings" + "time" + + "github.com/gaucho-racing/mapache/p987/model" + "github.com/gaucho-racing/mapache/p987/mqtt" + "github.com/gaucho-racing/mapache/p987/pkg/logger" + + mapache "github.com/gaucho-racing/mapache/mapache-go/v3" +) + +// headerSize is the relay's wire format: u64 BE microsecond timestamp +// followed by a u16 BE upload key, then the raw CAN payload. +const headerSize = 10 + +// minValidProducedAt is the cutoff for sane CAN-frame timestamps. A Pi +// with no RTC and no network boots to 1970, and the relay publishes +// whatever its clock says — anything stamped before this is pre-clock +// garbage. Kept in lockstep with the relay's own check. +var minValidProducedAt = time.Date(2003, 10, 31, 0, 0, 0, 0, time.UTC) + +// IsValidProducedAt reports whether the given microseconds-since-epoch +// resolves to a time at or after minValidProducedAt. +func IsValidProducedAt(tsMicros int) bool { + return !time.UnixMicro(int64(tsMicros)).Before(minValidProducedAt) +} + +// HandleInboundMessage routes one MQTT message. +// +// Topic shape is p987/{vehicle_id}/{bus}/{can_id}, exactly four segments. +// The bus segment takes the place of gr26's node segment: on stock Porsche +// CAN the sender is implied by the arbitration id, so the bus is the only +// routing fact the id cannot carry. +func HandleInboundMessage(topic string, payload []byte) { + parts := strings.Split(topic, "/") + if len(parts) != 4 { + logger.SugarLogger.Infof("[MQ] Received invalid topic: %s, ignoring", topic) + return + } + vehicleID, bus, canID := parts[1], parts[2], parts[3] + + if vehicleID == "" { + logger.SugarLogger.Infof("[MQ] Received invalid vehicle id: %s, ignoring", topic) + return + } + if bus == "" { + logger.SugarLogger.Infof("[MQ] Received invalid bus: %s, ignoring", topic) + return + } + + switch canID { + case "ping": + go HandlePing(vehicleID, bus, payload) + return + case "pong": + // Our own reply echoed back by the shared subscription. + return + } + + canIDInt, err := strconv.ParseInt(strings.TrimPrefix(canID, "0x"), 16, 64) + if err != nil { + logger.SugarLogger.Infof("[MQ] Received invalid can id: %s, ignoring", canID) + return + } + go HandleMessage(vehicleID, bus, int(canIDInt), payload) +} + +// ProcessFrame is pure data transformation: bytes in, one CAN row and its +// signals out. UploadKey is left at 0 for the caller. Unknown ids, decode +// failures, and invalid timestamps yield no signals but still return a row +// with a status blob in Metadata, so the raw frame is never lost. +func ProcessFrame(vehicleID, bus string, canID, timestamp int, data []byte) (model.CAN, []mapache.Signal) { + producedAt := time.UnixMicro(int64(timestamp)) + + var ( + decoded []mapache.Signal + meta []byte + ) + + switch { + case !IsValidProducedAt(timestamp): + logger.SugarLogger.Warnf("Frame with invalid timestamp: vehicle=%s bus=%s can_id=0x%X ts=%d decoded=%s", + vehicleID, bus, canID, timestamp, producedAt.UTC().Format(time.RFC3339Nano)) + meta = MustJSON(map[string]any{ + "status": "invalid_timestamp", + "note": fmt.Sprintf("ts=%d (%s) is before %s", timestamp, + producedAt.UTC().Format(time.RFC3339Nano), minValidProducedAt.UTC().Format(time.RFC3339Nano)), + }) + default: + decoded, meta = decodeFrame(bus, canID, data) + } + + can := model.CAN{ + VehicleID: vehicleID, + NodeID: bus, + Timestamp: timestamp, + CANID: canID, + Bytes: data, + Metadata: meta, + ProducedAt: producedAt, + } + + if len(decoded) > 0 { + now := time.Now().Truncate(time.Microsecond) + for i := range decoded { + // Prefixed with the bus for the same reason gr26 prefixes with + // the node: it keeps names unique across buses and lets the + // signal-to-frame join recover the segment from the name. + decoded[i].Name = fmt.Sprintf("%s_%s", bus, decoded[i].Name) + decoded[i].Timestamp = timestamp + decoded[i].VehicleID = vehicleID + decoded[i].ProducedAt = producedAt + decoded[i].CreatedAt = now + } + } + return can, decoded +} + +// decodeFrame looks up the decoder for this id on this bus and runs it. +// Unknown ids and decode failures return no signals but a status blob, so +// the raw frame is still stored — that is how an unknown id gets +// reverse-engineered later. +func decodeFrame(bus string, canID int, data []byte) ([]mapache.Signal, []byte) { + messageStruct := model.GetMessage(bus, canID) + if messageStruct == nil { + return nil, MustJSON(map[string]any{ + "status": "unknown_can_id", + "note": fmt.Sprintf("no decoder registered for can id 0x%X on bus %s", canID, bus), + }) + } + if err := messageStruct.FillFromBytes(data); err != nil { + return nil, MustJSON(map[string]any{ + "status": "decode_error", + "note": err.Error(), + }) + } + return messageStruct.ExportSignals(), MustJSON(map[string]any{"status": "ok"}) +} + +func HandleMessage(vehicleID string, bus string, canID int, message []byte) { + if len(message) < headerSize { + logger.SugarLogger.Infof("[MQ] Message too short, ignoring %d bytes", len(message)) + return + } + uploadKey := int(binary.BigEndian.Uint16(message[8:10])) + if !ValidateUploadKey(vehicleID, uploadKey) { + logger.SugarLogger.Infof("Upload key validation failed for vehicle %s, ignoring", vehicleID) + return + } + + ts := int(binary.BigEndian.Uint64(message[:8])) + can, signals := ProcessFrame(vehicleID, bus, canID, ts, message[headerSize:]) + can.UploadKey = uploadKey + + // Persist steps log-and-continue so one failure doesn't drop the rest. + if _, err := CreateCAN(can); err != nil { + logger.SugarLogger.Infof("Error creating CAN record: %s", err) + } + + if len(signals) == 0 { + return + } + if err := CreateSignals(signals); err != nil { + logger.SugarLogger.Infof("Error creating signals: %s", err) + } + for _, s := range signals { + mqtt.PublishJSON(context.Background(), fmt.Sprintf("query/live/%s/%s", s.VehicleID, s.Name), s) + } +} + +// MustJSON marshals v, falling back to a sentinel blob on error so callers +// always get valid json for the metadata column. +func MustJSON(v any) []byte { + b, err := json.Marshal(v) + if err != nil { + return []byte(`{"status":"marshal_error"}`) + } + return b +} diff --git a/p987/service/ping.go b/p987/service/ping.go new file mode 100644 index 00000000..38c05dc0 --- /dev/null +++ b/p987/service/ping.go @@ -0,0 +1,68 @@ +package service + +import ( + "context" + "encoding/binary" + "fmt" + "time" + + "github.com/gaucho-racing/mapache/p987/config" + "github.com/gaucho-racing/mapache/p987/database" + "github.com/gaucho-racing/mapache/p987/mqtt" + "github.com/gaucho-racing/mapache/p987/pkg/logger" + + "github.com/gaucho-racing/mapache/mapache-go/v3" +) + +// pingPayloadSize is the relay's ping: u64 BE microsecond timestamp +// followed by a u16 BE upload key. +const pingPayloadSize = 10 + +func HandlePing(vehicleID string, bus string, payload []byte) { + if len(payload) < pingPayloadSize { + logger.SugarLogger.Infof("[MQ] Ping too short, ignoring %d bytes", len(payload)) + return + } + ping := binary.BigEndian.Uint64(payload[:8]) + uploadKey := binary.BigEndian.Uint16(payload[8:10]) + if !ValidateUploadKey(vehicleID, int(uploadKey)) { + logger.SugarLogger.Infof("Upload key validation failed for vehicle %s, ignoring", vehicleID) + return + } + SendPong(vehicleID, bus, ping) +} + +// SendPong echoes the original ping alongside our own clock so the relay +// can compute round-trip time without trusting our clock offset. +func SendPong(vehicleID string, bus string, ping uint64) { + topic := fmt.Sprintf("%s/%s/%s/pong", config.TopicRoot, vehicleID, bus) + pong := uint64(time.Now().UnixMicro()) + latency := pong - ping + + payload := make([]byte, 16) + binary.BigEndian.PutUint64(payload, ping) + binary.BigEndian.PutUint64(payload[8:], pong) + + mqtt.Publish(context.Background(), topic, payload) + logger.SugarLogger.Infof("[PING] Received ping from %s/%s/%s in %dms", config.TopicRoot, vehicleID, bus, latency/1000) + + if err := CreatePing(mapache.Ping{ + VehicleID: vehicleID, + Ping: int(ping), + Pong: int(pong), + Latency: int(latency), + }); err != nil { + logger.SugarLogger.Infof("Error creating ping: %s", err) + } +} + +const insertPingSQL = `INSERT INTO ping (vehicle_id, ping, pong, latency) VALUES (?, ?, ?, ?)` + +func CreatePing(ping mapache.Ping) error { + if !config.ClickhouseEnabled() { + return nil + } + ctx := database.InsertCtx(context.Background()) + return database.Conn.Exec(ctx, insertPingSQL, + ping.VehicleID, int64(ping.Ping), int64(ping.Pong), int32(ping.Latency)) +} diff --git a/p987/service/signal.go b/p987/service/signal.go new file mode 100644 index 00000000..a4d06c01 --- /dev/null +++ b/p987/service/signal.go @@ -0,0 +1,59 @@ +package service + +import ( + "context" + "fmt" + "time" + + "github.com/gaucho-racing/mapache/p987/config" + "github.com/gaucho-racing/mapache/p987/database" + + mapache "github.com/gaucho-racing/mapache/mapache-go/v3" + ulid "github.com/gaucho-racing/ulid-go" +) + +// produced_at is MATERIALIZED from timestamp; created_at defaults to insert +// wall clock and acts as the ReplacingMergeTree version. Both omitted here. +const insertSignalSQL = `INSERT INTO signal (id, timestamp, vehicle_id, name, value, raw_value)` + +func CreateSignals(signals []mapache.Signal) error { + if len(signals) == 0 { + return nil + } + // Stamp CreatedAt locally before any MQTT publish or CH insert so + // downstream consumers (live cache eviction, SSE Last-Event-ID) have + // a reliable wall-clock anchor independent of producer/CAN-frame clock + // skew. The CH insert omits the column so the server-side now64(6) + // default still wins for the persisted row. + now := time.Now().UTC() + for i := range signals { + if signals[i].Timestamp == 0 { + return fmt.Errorf("signal timestamp cannot be 0") + } + if signals[i].VehicleID == "" { + return fmt.Errorf("signal vehicle id cannot be empty") + } + if signals[i].Name == "" { + return fmt.Errorf("signal name cannot be empty") + } + signals[i].ID = ulid.Make().Prefixed("sgnl") + signals[i].CreatedAt = now + } + if !config.ClickhouseEnabled() { + return nil + } + // One buffered block insert per call — a single round trip regardless of + // signal count, which matters with CH ~25ms away from foundry. async_insert + // (InsertCtx) doesn't apply to native block inserts, so plain ctx here. + batch, err := database.Conn.PrepareBatch(context.Background(), insertSignalSQL) + if err != nil { + return err + } + defer batch.Close() + for _, s := range signals { + if err := batch.Append(s.ID, int64(s.Timestamp), s.VehicleID, s.Name, s.Value, int64(s.RawValue)); err != nil { + return err + } + } + return batch.Send() +} diff --git a/p987/service/vehicle.go b/p987/service/vehicle.go new file mode 100644 index 00000000..58b401b2 --- /dev/null +++ b/p987/service/vehicle.go @@ -0,0 +1,104 @@ +package service + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "strconv" + "sync" + "time" + + "github.com/gaucho-racing/mapache/p987/config" + "github.com/gaucho-racing/mapache/p987/pkg/kerbecs" + "github.com/gaucho-racing/mapache/p987/pkg/logger" + + "github.com/gaucho-racing/mapache/mapache-go/v3" +) + +type uploadKeyCacheEntry struct { + UploadKey int + Found bool + ExpiresAt time.Time +} + +var uploadKeyCache sync.Map + +// missTTL keeps a failed lookup cached briefly so a vehicle service +// outage doesn't turn every inbound frame into an HTTP request. +const missTTL = time.Minute + +func ValidateUploadKey(vehicleID string, key int) bool { + if config.SkipAuthCheck { + return true + } + + if entry, ok := uploadKeyCache.Load(vehicleID); ok { + cached := entry.(uploadKeyCacheEntry) + if time.Now().Before(cached.ExpiresAt) { + if !cached.Found { + return false + } + return cached.UploadKey == key + } + } + + vehicle, ok := fetchVehicle(vehicleID) + if !ok { + uploadKeyCache.Store(vehicleID, uploadKeyCacheEntry{ + Found: false, + ExpiresAt: time.Now().Add(missTTL), + }) + return false + } + + hitTTL, err := strconv.Atoi(config.VehicleUploadKeyCacheTTL) + if err != nil { + hitTTL = 600 + } + uploadKeyCache.Store(vehicleID, uploadKeyCacheEntry{ + UploadKey: vehicle.UploadKey, + Found: true, + ExpiresAt: time.Now().Add(time.Duration(hitTTL) * time.Second), + }) + + if vehicle.UploadKey != key { + logger.SugarLogger.Infof("Upload key mismatch for vehicle %s: expected %d, got %d", vehicleID, vehicle.UploadKey, key) + return false + } + return true +} + +func fetchVehicle(vehicleID string) (mapache.Vehicle, bool) { + path := fmt.Sprintf("/api/vehicles/%s", vehicleID) + upstreamURL, err := kerbecs.Resolve("GET", path) + if err != nil { + logger.SugarLogger.Warnf("Failed to resolve vehicle route via kerbecs: %v", err) + return mapache.Vehicle{}, false + } + + resp, err := http.Get(upstreamURL) + if err != nil { + logger.SugarLogger.Warnf("Failed to fetch vehicle %s: %v", vehicleID, err) + return mapache.Vehicle{}, false + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + logger.SugarLogger.Warnf("Vehicle service returned %d for vehicle %s", resp.StatusCode, vehicleID) + return mapache.Vehicle{}, false + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + logger.SugarLogger.Warnf("Failed to read vehicle response for %s: %v", vehicleID, err) + return mapache.Vehicle{}, false + } + + var vehicle mapache.Vehicle + if err := json.Unmarshal(body, &vehicle); err != nil { + logger.SugarLogger.Warnf("Failed to unmarshal vehicle %s: %v", vehicleID, err) + return mapache.Vehicle{}, false + } + return vehicle, true +}