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:
| Problem | Approach |
|---|---|
| Eight opaque bytes are meaningless | Explicit SAE J1939 / OBD-II PID decoders with correct byte-order handling per standard |
| GPS disappears in tunnels | Dead reckoning projects a great-circle track from speed, bearing and elapsed time, and flags the result is_interpolated |
| Boundaries generate phantom events | Redis-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_Containsover a GIST-indexed boundary column andST_DWithinon thegeographytype, 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
◦ 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
- 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 indecoder.go:66-83, and a DTC count that is checked against actual buffer size before iterating (dtc.go:62). - 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.
- Honest note on transport auth. There is currently no authentication between the services. Redis runs without a password (
zone_state.go:26), anddocker-compose.ymlpublishes5432,9092and6379on 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
Step by Step
- Frame arrives. The ECU emits a CAN 2.0B frame — arbitration ID plus a DLC-bounded payload.
- Decode.
Decodereads the PID fromDATA[0]and dispatches. RPM consumes two bytes through the requested byte order and divides by four; coolant temperature applies the standard-40 °Coffset. - Parse fault codes. Mode 03 responses are walked independently, yielding component class (
P/C/B/U), four-hex code and status nibble. - 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 flaggedis_interpolatedso downstream consumers can discount it. - Publish. The enriched telemetry event is produced to the stream.
- Consume. A worker drains the partition, giving per-vehicle ordering for free.
- Persist the point.
StoreTrajectoryinserts the fix — withST_GeomFromText($3, 4326)doing the WKT → geometry conversion in the database, not in Go. - Evaluate.
Evaluateis called with the fix and its speed. - Resolve containment. A single indexed query returns every zone containing the point.
- 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. - Claim on change. If the claimed zone differs,
Enterwrites the claim and refreshes the 60-second TTL. - Check speed. If the zone carries a limit and the vehicle exceeds it, a
SPEEDING_VIOLATIONis appended. - Exit. Only when the match set is empty and a zone was previously claimed does an
EXITfire 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
| Layer | Technology | Why |
|---|---|---|
| Language | Go 1.27 | Static typing, real concurrency, trivial static binaries for vehicle gateways |
| Telemetry decode | CAN 2.0B · SAE J1939 · OBD-II (SAE J1979) | The actual wire standards this engine speaks |
| Stream | Redpanda (Kafka API) :9092 | Partition-ordered per-vehicle telemetry, replayable on consumer failure |
| Spatial store | PostgreSQL 16 + PostGIS 3.4 :5432 | ST_Contains / ST_DWithin with GIST indexing and a durable audit trail |
| State cache | Redis 7 :6379 | Atomic HGET/HMSET zone claims with TTL expiry |
| Persistence | lib/pq + database/sql | Explicit SQL, full control over the spatial queries |
| Client | go-redis/v9 | Pipelining and connection pooling |
| Testing | testify | Table-driven assertions with require/assert discipline |
| DevOps | Docker Compose · Make | One-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
| Target | Action |
|---|---|
make up | Start PostGIS, Redpanda and Redis in the background |
make down | Stop the stack |
make migrate | Apply migrations/000001_init_postgis_schema.up.sql |
make test | Run the Go test suite |
make simulate | Run 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-40offset, throttle at0xFF, 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 theCHECKconstraint 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—DWELLtimer 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/:
| Source | Type | Produces |
|---|---|---|
docs/diagrams/architecture.candidate.json | architecture | docs/architecture.html |
docs/diagrams/telemetry-pipeline.candidate.json | sequence | docs/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.outputpaths match those targets. Archify's no-clobber publisher compareslstat().devagainstfstat().devand fails closed on Windows hosts where the two disagree, so the committed HTML is produced on Linux or macOS.
📜 Standards & References
| Domain | Reference |
|---|---|
| Vehicle network | Controller Area Network 2.0B · SAE J1939 |
| Diagnostics | OBD-II · SAE J1979 parameter IDs, Mode 03 |
| Geospatial | GeoJSON · PostGIS 3.4 spatial predicates |
📄 License
MIT © 2026 Kavryx Dev — the same licence used across the Kavryx-Dev repos.