Kavryx Dev
← All products
Data & StreamingPROTOTYPE

Fleet CAN Bus Telematics

Decodes CAN bus frames from vehicles and resolves geofence crossings against real PostGIS geometry in real time.

At a glance

  • CAN frame decoding into normalized vehicle signals
  • Geofence enter/exit detection against PostGIS polygons
  • Redpanda streaming between ingest and evaluation
  • Redis cache for hot geofence lookups
  • GPS jitter handling that avoids false crossings

From the repository

README

Decodes raw CAN 2.0B vehicle frames into geofence events — in Go, with PostGIS doing the spatial work.


📖 Overview

A vehicle on the road emits a handful of CAN frames a second. Each frame is eight bytes of opaque payload. Somewhere downstream, a dispatcher needs to know one thing only: did this van just cross into the depot polygon, and was it over the speed limit?

Answering that well is harder than it sounds. The bytes need bit-level decoding. GPS drops out in exactly the places that matter most — tunnels, underground loading bays, urban canyons. And a vehicle sitting on a depot boundary produces a stream of contradictory positions that, naively evaluated, generate hundreds of phantom entry/exit alerts per hour.

This engine is built around those three problems:

ProblemApproach
Eight opaque bytes are meaninglessExplicit SAE J1939 / OBD-II PID decoders with correct byte-order handling per standard
GPS disappears in tunnelsDead reckoning projects a great-circle track from speed, bearing and elapsed time, and flags the result is_interpolated
Boundaries generate phantom eventsRedis-backed zone claims with a TTL convert raw coordinates into discrete, non-flapping transitions

✨ Core Capabilities

  • 🚌 CAN 2.0B frame model — 11-bit standard and 29-bit extended arbitration IDs, DLC-bounded payloads, no out-of-range reads.
  • 🔢 Standards-correct PID decoders — Engine RPM (0x0C), speed (0x0D), throttle (0x11), coolant (0x05), each with explicit big-endian / little-endian conversion.
  • 🚨 Mode 03 DTC parser — multi-byte fault bitfields resolved into standard alphanumeric codes (P0300, U0100, …) with their component class and status nibble.
  • 🛰️ Dead-reckoning interpolator — spherical-earth forward projection for GPS dropouts, with angular-step clamping and longitude normalisation.
  • 🗺️ PostGIS containment engine — ST_Contains over a GIST-indexed boundary column and ST_DWithin on the geography type, resolved in one indexed query rather than a per-zone loop.
  • 🧯 Flap-free transition events — ENTER, EXIT, DWELL, SPEEDING_VIOLATION, backed by an atomic Redis claim that expires after 60 seconds.
  • 🐳 One-command infrastructure — PostGIS 16, Redpanda and Redis 7 wired up with health checks in docker-compose.yml.

🏗️ System Architecture

Rendering diagram…

◦ planned — these two entry points are specified in the blueprint but not yet implemented; every other box is working, tested code.

Component Breakdown

Vehicle ECU — external (external) Emits CAN 2.0B frames. The engine's only input contract, and deliberately the narrowest one: an arbitration ID and up to eight data bytes.

CAN / OBD-II Decoder — internal/canbus/ The heart of the ingestion side. Decode(frame, endianness) reads the PID from DATA[0] and dispatches to a bitmask unpacker. Multi-byte values honour the caller's byte order — RPM is (256·A + B) / 4, and at decoder.go:69 that read goes through binary.ByteOrder rather than a hardcoded shift. ParseMode03 separately walks a Mode 03 response, validating the count against the buffer before it trusts it (dtc.go:62). Both are exercised at boundary byte values in the test suite.

Dead-Reckoning — internal/deadreckoning/ A pure function with no I/O. Given a start coordinate, bearing, speed and elapsed time it returns the next point on a great circle (interpolator.go:33). The angular step is clamped to π so a pathological dt cannot wrap the globe, and longitude is normalised back into [-π, π]. Callers are expected to mark the output is_interpolated.

Geofence Engine — internal/geofence/ Evaluate(t Telemetry) []GeofenceEvent is the entire contract (engine.go:95). It resolves containing zones, reads the previously claimed zone, and emits events only where state actually changed. The subtle part is overlap: when polygons nest, the first matching zone claims the vehicle and later zones can only contribute speed violations (engine.go:120-131).

PostgreSQL + PostGIS — migrations/ + internal/repository/postgres/ The source of truth. Four tables — fleet_vehicles, geofence_zones, vehicle_telemetry, geofence_events — with GIST indexes on both the zone boundary and the telemetry location. ZonesContaining (geofence_repo.go:74) lets the index do the work; the alternative, fetching every zone and looping in Go, is the mistake this method exists to avoid.

Redis 7 — internal/repository/redis/ Holds only derived, expiring state. Enter reads the current hash, refuses to re-claim the same zone, and refreshes a 60-second TTL (zone_state.go:57-77). That TTL is the safety property: a worker that crashes mid-geofence cannot pin a vehicle inside a depot forever.

Trust Boundaries & Data Ownership

Rendering diagram…
  1. Vehicle side → process. Raw bus bytes are attacker-controllable and are validated on entry: DLC bounds in frame.go:17, minimum payload length per PID in decoder.go:66-83, and a DTC count that is checked against actual buffer size before iterating (dtc.go:62).
  2. Process → stores. PostGIS is authoritative and durable. Redis is a cache of a decision, not the decision itself — losing it costs a re-evaluation, not an audit record.
  3. Honest note on transport auth. There is currently no authentication between the services. Redis runs without a password (zone_state.go:26), and docker-compose.yml publishes 5432, 9092 and 6379 on all interfaces. That is acceptable for local development and must not be exposed as-is; mTLS or in-network-only binding is the fix, and is called out in the roadmap rather than quietly omitted.

🔄 Core Workflow — One Fix, End to End

Rendering diagram…

Step by Step

  1. Frame arrives. The ECU emits a CAN 2.0B frame — arbitration ID plus a DLC-bounded payload.
  2. Decode. Decode reads the PID from DATA[0] and dispatches. RPM consumes two bytes through the requested byte order and divides by four; coolant temperature applies the standard -40 °C offset.
  3. Parse fault codes. Mode 03 responses are walked independently, yielding component class (P/C/B/U), four-hex code and status nibble.
  4. Fill a GPS gap (conditional). If the fix is stale, the interpolator projects the next position from speed, bearing and Δt. This is the only branch in the pipeline, and its output is flagged is_interpolated so downstream consumers can discount it.
  5. Publish. The enriched telemetry event is produced to the stream.
  6. Consume. A worker drains the partition, giving per-vehicle ordering for free.
  7. Persist the point. StoreTrajectory inserts the fix — with ST_GeomFromText($3, 4326) doing the WKT → geometry conversion in the database, not in Go.
  8. Evaluate. Evaluate is called with the fix and its speed.
  9. Resolve containment. A single indexed query returns every zone containing the point.
  10. Read prior state. The engine reads the vehicle's currently claimed zone from Redis. This read is what makes the rest correct — without it every sample inside a zone would re-emit ENTER.
  11. Claim on change. If the claimed zone differs, Enter writes the claim and refreshes the 60-second TTL.
  12. Check speed. If the zone carries a limit and the vehicle exceeds it, a SPEEDING_VIOLATION is appended.
  13. Exit. Only when the match set is empty and a zone was previously claimed does an EXIT fire and the claim get deleted.

Why the Flapping Guard Matters

A van idling on a depot boundary produces coordinates that alternate inside and outside on consecutive samples. Evaluated naively, that is an ENTER/EXIT storm. The engine reads the claim before deciding (engine.go:108-114) and only transitions on genuine change — the boundary noise collapses to a single event, and the 60-second TTL means even a hard crash self-heals.


🧰 Tech Stack

LayerTechnologyWhy
LanguageGo 1.27Static typing, real concurrency, trivial static binaries for vehicle gateways
Telemetry decodeCAN 2.0B · SAE J1939 · OBD-II (SAE J1979)The actual wire standards this engine speaks
StreamRedpanda (Kafka API) :9092Partition-ordered per-vehicle telemetry, replayable on consumer failure
Spatial storePostgreSQL 16 + PostGIS 3.4 :5432ST_Contains / ST_DWithin with GIST indexing and a durable audit trail
State cacheRedis 7 :6379Atomic HGET/HMSET zone claims with TTL expiry
Persistencelib/pq + database/sqlExplicit SQL, full control over the spatial queries
Clientgo-redis/v9Pipelining and connection pooling
TestingtestifyTable-driven assertions with require/assert discipline
DevOpsDocker Compose · MakeOne-command infrastructure; make for the common tasks

📁 Project Structure

fleet-telematics/
├── cmd/                                  # ⬜ Not yet implemented — see roadmap
│   ├── ingestion_gateway/                #    Ingests CAN packets, publishes telemetry
│   └── geofence_worker/                  #    Consumes stream, drives the engine
├── internal/
│   ├── canbus/
│   │   ├── frame.go                      # ✅ CAN 2.0B frame + 11/29-bit ID model
│   │   ├── decoder.go                    # ✅ OBD-II PID decoders, endianness-aware
│   │   └── dtc.go                        # ✅ Mode 03 Diagnostic Trouble Code parser
│   ├── deadreckoning/
│   │   └── interpolator.go               # ✅ Great-circle dead-reckoning projection
│   ├── geofence/
│   │   └── engine.go                     # ✅ ENTER/EXIT/DWELL/SPEEDING transition engine
│   └── repository/
│       ├── postgres/
│       │   ├── db.go                     # ✅ Pool, DSN, PostGIS bootstrap
│       │   ├── geofence_repo.go          # ✅ ST_Contains / ST_DWithin spatial queries
│       │   └── vehicle_repo.go           # ✅ Trajectory persistence
│       └── redis/
│           └── zone_state.go             # ✅ Atomic zone claim with TTL
├── migrations/
│   └── 000001_init_postgis_schema.up.sql # ✅ PostGIS baseline schema + GIST indexes
├── tests/
│   ├── can_decoder_test.go               # ✅ Frame, PID and DTC decoder suite
│   ├── deadreckoning/                    # ✅ Projection accuracy tests
│   └── geofence/                         # ✅ Point-in-polygon + event-type tests
├── docs/
│   └── diagrams/                         # Diagram sources (regenerable, see below)
├── docker-compose.yml                    # PostGIS + Redpanda + Redis with healthchecks
├── Makefile                              # up · down · migrate · test · simulate
├── go.mod
├── LICENSE                               # MIT © 2026 Kavryx Dev
├── .gitignore
└── automotive_can_bus_geofencing_autonomous_blueprint.md

🚀 Quick Start

Prerequisites

  • Go 1.27+
  • Docker with Compose v2

1. Clone

git clone https://github.com/Kavryx-Dev/Fleet-CAN-Bus-Telematics-Geofencing-Engine.git
cd Fleet-CAN-Bus-Telematics-Geofencing-Engine

2. Start infrastructure

make up

This starts three containers with health checks — PostGIS on 5432, Redpanda on 9092 (console UI on 18081), Redis on 6379. Wait for them to report healthy:

docker compose ps

3. Apply the schema

make migrate

Creates the four tables, the postgis extension, and the GIST indexes on geofence_zones.boundary and vehicle_telemetry.location.

4. Configure (optional)

Defaults already match docker-compose.yml. Override for any non-local setup:

export POSTGRES_HOST=localhost
export POSTGRES_PORT=5432
export POSTGRES_USER=fleet
export POSTGRES_PASSWORD=fleet-pass
export POSTGRES_DB=fleet_telemetry

5. Run the tests

make test          # or: go test ./...

6. Shut down

make down          # or: docker compose down -v   (also drops the volumes)

Makefile reference

TargetAction
make upStart PostGIS, Redpanda and Redis in the background
make downStop the stack
make migrateApply migrations/000001_init_postgis_schema.up.sql
make testRun the Go test suite
make simulateRun the fleet simulator (requires cmd/simulate — not yet implemented)

🧪 Testing

go test ./...              # all packages
go test -race ./...        # race detector — required before any streaming work lands
go test -v ./tests/...     # verbose

Three test packages cover the pure logic that does not need a live database:

  • tests/can_decoder_test.go — frame ID width and DLC bounds, each PID at both byte orders, the coolant -40 offset, throttle at 0xFF, unknown PID and empty payload rejection, and Mode 03 parsing including status nibbles and malformed responses.
  • tests/deadreckoning/ — projection accuracy over curved and high-latitude tracks.
  • tests/geofence/ — ray-casting point-in-polygon verified against the same WKT ring the migration writes, plus assertions that event-type constants exactly match the CHECK constraint in the schema.

The spatial tests deliberately use a pure-Go reference implementation rather than a live PostGIS container, so the suite stays fast and hermetic.


🗺️ Roadmap

  • cmd/ingestion_gateway — CAN → decoded telemetry producer (Phase 5)
  • internal/transport/kafka — Redpanda producer and partitioned consumer (Phase 5)
  • simulator/vehicle_simulator.go — synthetic fleet driving through zones with a simulated 20-second GPS blackout (Phase 5)
  • tests/e2e_pipeline_test.go — frame → stream → geofence event → PostGIS, end to end
  • internal/geofence/events.go — DWELL timer promotion, currently a declared constant only
  • Service-to-service authentication before any non-local deployment

📐 Diagrams

The diagrams above are native Mermaid, so they render directly on GitHub and stay diffable in review.

Interactive, explorable versions are authored as Archify diagram sources in docs/diagrams/:

SourceTypeProduces
docs/diagrams/architecture.candidate.jsonarchitecturedocs/architecture.html
docs/diagrams/telemetry-pipeline.candidate.jsonsequencedocs/telemetry-pipeline.html

Regenerate on a POSIX host with the archify skill installed. Set ARCHIFY_HOME to the skill's base directory (the folder containing bin/archify.mjs):

export ARCHIFY_HOME="<path-to>/.agents/skills/archify"

node "$ARCHIFY_HOME/bin/archify.mjs" finalize architecture \
  docs/diagrams/architecture.candidate.json docs/architecture.html --quality showcase --json

node "$ARCHIFY_HOME/bin/archify.mjs" finalize sequence \
  docs/diagrams/telemetry-pipeline.candidate.json docs/telemetry-pipeline.html --quality showcase --json

The checked-in meta.output paths match those targets. Archify's no-clobber publisher compares lstat().dev against fstat().dev and fails closed on Windows hosts where the two disagree, so the committed HTML is produced on Linux or macOS.


📜 Standards & References

DomainReference
Vehicle networkController Area Network 2.0B · SAE J1939
DiagnosticsOBD-II · SAE J1979 parameter IDs, Mode 03
GeospatialGeoJSON · PostGIS 3.4 spatial predicates

📄 License

MIT © 2026 Kavryx Dev — the same licence used across the Kavryx-Dev repos.