9. SOFTWARE STACK
rev. 3: runs on the OptiPlex 5080 (Linux), CPU-only. No GPU, so model choices are bounded by CPU inference speed (~5β15 tok/s on a 3β4B model; drops sharply above ~8B). The local model's job is to be fast and good at routing/classifying + simple answers, NOT to be smart. Real reasoning goes to Fireworks. The whole stack is a router with a small near-brain and a big far-brain.
Carputer OptiPlex 5080 (Linux Mint 22.3, headless β Cinnamon disabled, multi-user.target):
- Ollama (localhost:11434) serving the local models below
- Mic β reSpeaker XMOS XVF3800 with Case (USB) (Seeed SKU 114993701, ~$57; rev 3.13). 4-mic circular array, 360Β° far-field to ~5m, USB firmware = plug-and-play UAC (Linux shows it as ALSA card
XVF3800). Why USB, not the OptiPlex's onboard 3.5mm mic-in: the analog jack runs an unshielded line past the alternator/ignition/inverter/DC-DC β hum + alternator whine into the mic = worse Whisper accuracy. USB does the AβD at the mic, so only clean digital travels. Onboard XMOS DSP earns its keep in a cab: AEC (won't transcribe its own TTS β even though playback comes from the Uconnect speakers, the array's AEC cancels the acoustic echo), noise suppression, AGC, beamforming, dereverb. 16 kHz (matches Whisper), 5V/low-draw, flat 102Γ102Γ10mm β mount on the headliner/overhead console, mics facing down. Not using its onboard speaker/3.5mm out β voice-OUT stays the 5080's 3.5mm β Uconnect (below). Linux gotcha: if output is quiet,alsamixerβ F6 β XVF3800 β raisePCM-1to 100%. (The XIAO-ESP32S3 variants ship I2S firmware for standalone wake-word use β not this build; we want the no-XIAO USB SKU.) - Picovoice Porcupine (
pvporcupine) β wake word detection on the XVF3800 array; "Hey JARVIS" built-in free model; triggers whisper.cpp listen window - whisper.cpp (STT, offline) β
base.enis the CPU sweet spot; ~300-400ms per clip on i7-10700T;tinyif too slow - TTS β bench-test Kokoro vs Piper on the 10700T, then pick (UNDECIDED). Both local/offline. Piper = lightest + lowest-latency on CPU, proven for short command replies (the safe default if latency matters). Kokoro = noticeably more natural voice but heavier on a CPU-only box β verify its latency is acceptable before committing. Same pipeline either way; only the TTS step swaps.
- PipeWire β Linux audio server; S24 Ultra pairs via the built-in Intel AX201 BT 5.2 as A2DP sink (no USB dongle);
module-role-duckingauto-ducks music (stream role) when TTS speaks (notification role); OptiPlex 3.5mm AUX β Uconnect AUX input. All audio plays from the 5080 out one 3.5mm jack, so ducking is pure software (music dims β TTS talks β restores), no mute-wire/relay. β οΈ It can only duck audio the 5080 produces β keep the Uconnect parked on AUX = the computer (its own FM/BT can't be ducked). - python-can β SH-C31G (CAN, negligible CPU)
- OpenCV / go2rtc β RTSP camera ingest/restream (Phase 1, while awake)
- ChromaDB β local vector store on NVMe
- Flask + Flask-SocketIO β multi-screen WebSocket UI (0.0.0.0:5000)
- Frigate (Docker) β NVR while awake; detection OFF in Phase 1; OR plain
ffmpeg -c copysegmented recording for max simplicity - **
openai/requests** β Fireworks API client (OpenAI-compatible endpoint) - The router module β local-vs-cloud classifier + action dispatch + confirmation gating
- Wake/sleep glue β listens for MQTT hibernate command, executes
systemctl hibernate
Pi 3 B+ (always-on, Raspberry Pi OS Lite β NO audio, NO mic, NO TTS):
- Mosquitto MQTT broker β all devices subscribe/publish here
- wake_coordinator.py β subscribes to proximity + door events; sends WOL; 3-strike false-positive lockout; quiet hours (10PM-6AM); midnight reset
- ffmpeg β records the Wolfbox dashcam only (5GHz WiFi to Wolfbox hotspot). Perimeter cams are the OptiPlex's job while awake (Β§7.2) β keep the wake-critical broker Pi lightly loaded.
- ESPresense β BT phone proximity via WROOM-32 + MQTT publish
9.1 Model Choices (June 2026)
Local (on the OptiPlex, via Ollama):
- Primary β Qwen3 4B (Q4_K_M), ~2.5GB. Default for voice commands, intent classification, the route decision, and action+response JSON. Best instruction-follower in the sub-4B class; strong at structured output (critical, since conversation handling depends on clean JSON). ~8β12 tok/s on the 10700T β fine for short replies.
- Fallback/fast β Llama 3.2 3B (Q4), ~2GB. Slightly faster; use for the lightest templated responses if Qwen feels sluggish.
- **Embeddings β
nomic-embed-text, ~0.5GB.** Standard RAG embedder, CPU-fine. - Avoid 7B+ locally β a 7B Q4 on this CPU feels like molasses for voice.
Cloud (rev 3.34 β OpenRouter primary, Venice for privacy, Fireworks shelved):
- Default heavy β Qwen3 235B-A22B-2507 on OpenRouter ($0.09/M in, $0.10/M out β ~$0.029 per 1k typical 100/200-tok queries). Cheaper than the rev-3.1 Fireworks/DeepSeek-V3 plan AND same Qwen family as the local 4B, so the original "one dialect for prompts/parsers" rule still holds. Verified live in
scripts/slm-bench.pyagainst the driving-prompt suite β passes the FX-rate honesty test (correctly admits it doesn't have live exchange rates; tool-use for live FX is mandatory regardless of model size β applies Β§9.3 "FACTS are direct lookup, not RAG" to FX too). - **Free cushion β Qwen3-Next-80B-A3B-Instruct
:freeon OpenRouter.** Rate-limited (bursty HTTP 429) so NOT a primary route; usable as the "free above local" leg of the Β§9.2 ensemble matrix, or a soft-cost shoulder when 235B's quota is noisy. - **Privacy lane β Venice (
openai-gpt-oss-120b,deepseek-v4-flash).** Zero-log, no training. Route here when the utterance carries personal context you'd rather not put through OpenRouter. Higher $/M but the volume that needs it is small. - Cross-provider control β Groq (Llama 3.1 8B). Same-model-different-provider sanity check used in the benchmark; NOT a runtime route.
- Fireworks DEAD on this account as of rev 3.34: the account now exposes only frontier SKUs (V4-pro, gpt-oss-120b, Kimi K2.5/6, GLM 5.1, Flux). DeepSeek V3 (the original rev-3.1 pick) is gone. Key retained in
scripts/.slm-bench.envfor future re-eval β not in the runtime route. - Cost reality: single-digit $/month, almost certainly low single. OpenRouter has no signup credit, but per-call is ~6Γ cheaper than the old Fireworks plan, so the math still works. Cell data: even the heaviest ensemble pattern (1000 q/day, all-ensemble) β ~450 MB/month β inside the 10 GB Keepgo tier.
9.2 The Router (local-vs-cloud β rev 3.34, OpenRouter)
The local Qwen3 4B does a fast first-pass classification on every utterance, deciding local-vs-cloud BEFORE generating a full answer. Five strategies, mixable:
- Rule-based (start here): commands/state-queries β no LLM at all (regex/keyword β relay/DI/
vehicle_state); short/simple/offline/RAG-lookup β local; deep reasoning/long-context/"analyze/explain/write code" β OpenRouter. - Local-LLM-as-router: the 4B classifies every query (
{"route":"local"|"cloud"}) and gatekeeps the expensive model. - Confidence/fallback: local attempts; low confidence β retry on OpenRouter Qwen3 235B.
- Capability-based (practical mix): voice command/car control β no LLM; quick chat/"what's my temp" β local; RAG over docs β local + ChromaDB (private); heavy reasoning/code/research β OpenRouter; anything offline β local only (graceful degradation).
- Ensemble-matrix (rev 3.34, cell-up only): when accuracy outranks latency (explain/recommend/route-planning/cross-border-cost), fan out 3 models in parallel β local Qwen3 4B + OpenRouter Qwen3 235B + OpenRouter Qwen3-Next 80B
:freeβ and pick the best. Two judge modes: (a) deterministic β string-similarity + numeric agreement (clear winner on unit/price math); (b) LLM judge β Qwen3 235B graded the 3 candidates and returnedWINNER: <letter>+ one-line reason. Wall time β slowest of the three (typically 3-5s). Cost ceiling at the heavy end (1000 q/day, every query ensembled) β ~450 MB/month cell + ~$0.10/day OpenRouter, well inside Keepgo 10 GB + single-digit-$/month. Offline β strategy 5 collapses to local-only (rev-3.4 graceful-degradation rule unchanged). Validated empirically inscripts/slm-bench.py --ensemble.
Key rules: a cloud timeout silently falls back to local (never errors). High-power actions require confirmation before any relay fires. Offline-resilience matters a lot for a truck frequently out of signal in BC.
Local-fast floor (rev 3.34): The benchmark showed sub-4B models break unit/price math β Llama 3.2 1B returned 67 for 35 mph β km/h (correct 56.33), Phi-4-mini and Gemma 3 4B failed similar conversions. Do not regress the "local-fast" tier below Qwen3 4B for the sake of tok/s. Half of what gets asked in a truck is conversion math; a "fast" model that's wrong is worse than a slower one that's right.
RAG split: retrieval is ALWAYS local (private docs, fast CPU vector search). Only the synthesis step is routable β short factual lookup β local 4B; deeper synthesis β Fireworks with retrieved chunks injected. For the personal life archive you may pin synthesis to local on privacy grounds. Heavy ingestion (PDF/Word/Excel/email/photos, nightly NAS rsync) lives at home on Z440 #1 (Β§11) β the truck carries a small synced subset (build doc, manuals, CAN notes, field reference); in-truck ingestion is a lightweight pypdf/unstructured β chunk β nomic-embed-text β ChromaDB pipeline for small drops only.
RAM budget β β οΈ the box ships with 16GB, not 32GB: local LLM (~3GB) + whisper (~2GB) + ChromaDB (~2GB) + Flask/app (~1GB) + Frigate routing + OS/Mint (~2-3GB) + browsers/go2rtc β 12β15GB β at 16GB total that's only ~1-4GB headroom = TIGHT (risk of swapping under load, which kills latency). Highest-value upgrade in the whole build: add a 16GB DDR4 SODIMM β 32GB (empty slot, 64GB max) β then the budget is comfortable. Teardown: the as-bought stick is a single Kingston 16GB DDR4-3200 SO-DIMM = single-channel right now; add a matching 16GB SO-DIMM (~$25-35 used) β 32GB dual-channel β dual-channel also widens the UHD 630 iGPU's shared memory bandwidth β better Quick Sync camera decode, so it helps both the LLM headroom and video. Treat 32GB as a need-soon, not optional, if you run LLM + RAG + Frigate together. Disk, not CPU or RAM, is the real constraint once cameras record (see Β§7.3).
AI tiers (matched to compute tiers):
ESP32 8DI-8DO (~0.5-1W): DO triggers + DI wires + LVD + proximity β MQTT β Pi wake coordinator
Pi 3 B+ (~3-5W): MQTT broker, wake logic, ffmpeg recording (NO voice, NO LLM)
OptiPlex (~35W): router, local LLM (Qwen3 4B), Porcupine, whisper.cpp, TTS (Piper/Kokoro),
RAG retrieval, CAN, Flask UI, camera record/restream, PipeWire audio
OpenRouter (cloud, rev 3.34): heavy reasoning (Qwen3 235B-A22B-2507; Qwen3-Next 80B :free as ensemble leg)
Venice (cloud, privacy lane): zero-log path for personal-context queries
[ Pi 5 + Hailo-8L, ~1yr out: takes over all camera record/restream/detect ]
Conversation handling: LLM returns action + spoken response in ONE JSON so mid-conversation commands don't break context. Multi-action arrays; confirmation for high-power actions. Three-layer memory: session + 5-min recent-context + persistent memory.json (LLM-decided saves); "Hey truck forget everything" clears it.
Multi-screen: WebSocket push to named screens (?screen=radio/tablet); voice targets a screen ("on the radio"); each screen independently shows its own page; one always-open kiosk browser per screen.
Audio: S24 Ultra pairs to OptiPlex via the built-in Intel AX201 BT 5.2 (A2DP sink β no USB dongle). OptiPlex 3.5mm AUX β Uconnect AUX input. PipeWire module-role-ducking auto-ducks S24 music when TTS speaks β no app integration needed. Music = any app on S24 Ultra. Mic = reSpeaker XVF3800 USB array, headliner/overhead mount (Β§9.1).
Flask pages: /dashboard, /controls, /cameras, /gauges, /trans (8HP70 monitor), /rag, /music. WebSocket-synced across screens, large touch targets, dark theme.
9.3 Truck RAG, Facts, History & Replication (rev 3.5)
The truck carries a small offline-resilience subset; the big personal archive lives on the home RAG box (Β§11), queried over Tailscale when you have signal. Three distinct stores, each with the right tool:
A) Structured FACTS β direct lookup, NOT RAG. Exact values that a fuzzy vector search could paraphrase wrong belong in a small table (SQLite/config) the rule-based tier answers instantly, offline, no LLM:
- relay + DI map Β· device IPs Β· the CAN ID map (once discovered) Β· torque/fluid specs Β· tire pressures Β· fuse layout Β· part numbers
- Lives on the Pi too β so the always-on side can answer "what relay are the rock lights / Pi's IP / trans capacity" with the OptiPlex asleep.
B) DOCS β the actual RAG (ChromaDB + nomic-embed-text on the OptiPlex). Unstructured prose worth semantic search:
- RAM 1500 owner + service manual Β· ZF 8HP70 manual Β· Hemi/rebuild + tune specs Β· this build doc (truck explains its own system) Β· warning-light decoder Β· field/overland procedures (recovery, winch operation+safety, tire-airdown, first aid, knots, nav).
- Scope rule: only what you'd need without signal. Anything you usually have cell for β leave on the home RAG.
C) Usage/event HISTORY β structured event-log, NOT RAG. "When were the KC lights on / when did the ESP32 fire a relay" is a SQL question, not a semantic one:
- Raw events
(ts, device, event, value)β SQLite event-log on the Pi (always-on broker sees every MQTT message; gapless even while the OptiPlex sleeps β if the OptiPlex were the logger you'd lose all the parked history). - The OptiPlex, on wake, queries the log (text-to-SQL or canned queries + LLM phrasing) and rolls distilled summaries into RAG/memory ("week of Jun 1: 3 night drives, rock lights ~4h, battery dipped to 40%"). Only the summaries are RAG β raw events stay structured.
OptiPlex-asleep behavior: RAG, voice, and the LLM all run on the OptiPlex. While it hibernates, the Pi serves facts + control + status from its own data; anything needing the LLM/RAG/voice wakes the OptiPlex (~20s), then answers.
3-tier history replication (edge β master β archive):
Pi 3 B+ = gapless RECORDER / buffer (rolling recent window; small disk)
ββ(append-only, delta-on-wake)β OptiPlex 5080 = MASTER of record (full history, 1TB) + analyst
ββ(periodic, over Tailscale)β Home = offsite ARCHIVE / backup (later)
One-way, append-only β no merge conflicts. Logs are tiny (years β tens-hundreds of MB; the 4TB is for video). The Pi prunes once the 5080 confirms receipt. β οΈ Until home exists the 5080 is the ONLY full copy β back it up to the owned NAS or Hetzner over Tailscale (nightly 3am window, Β§5.4) so nothing is single-copy. It's the recorded DATA that flows up the tiers β the Pi's software/config is version-controlled separately, not mirrored.
9.4 CAN-write voice control of HVAC (Phase 3 future-work, rev 3.35)
Stub, not buildable yet β captured here so the architecture doesn't get lost on the next pass. Phase 1 (Phoenix install) and Phase 2 (CAN-read via SH-C31G into a
vehicle_statedict) carry NO Phase 3 dependency and ship on the current rev. Phase 3 is the future-work layer that adds voice control of HVAC/heated-seats/defrost without replacing what Phoenix already does.
Design principle β Phoenix is NOT replaced, it is layered. The temptation when you realize the OptiPlex is ~1000Γ more powerful than Phoenix's $3 CAN-decoder microcontroller is to drop Phoenix and roll your own everything. Don't. Phoenix at $695 buys you ~150 hr of vehicle-specific R&D (CAN decoder firmware for the 2014 RAM + bezel mold + harness + AM/FM tuner + US warranty); the OptiPlex's wins are in the layer Phoenix can't reach, not by re-doing what Phoenix already does. Keep Phoenix as the day-1 baseline (steering wheel, AC touchscreen, backup cam, AM/FM, factory-feature retention); add OptiPlex as a parallel CAN writer for voice control.
Topology β the 3-bus problem the SH-C31G alone CAN'T solve
The 2014 RAM has THREE CAN buses with a Gateway Module between them:
ββββββββββββββββββββββββ
PCM / Trans / ABS ββββ€ C-CAN (500 kbps) β
β powertrain β
ββββββββββββ¬ββββββββββββ
β GATEWAY MODULE (selective forwarder)
ββββββββββββ΄ββββββββββββ
HVAC / Doors / BCM βββ€ B-CAN (125 kbps) β
Lights / Heated seatsβ body β
ββββββββββββ¬ββββββββββββ
β
ββββββββββββ΄ββββββββββββ
Radio / Steering whl β€ Infotainment-CAN β
buttons / Backup cam β β
ββββββββββββ¬ββββββββββββ
β
β
OBD-II port (READ-FILTERED VIEW
of all three; WRITES often blocked
by the gateway)
The SH-C31G hangs off OBD-II β reads a diagnostic-filtered view of all three buses, but CAN-writes to B-CAN HVAC frames are usually swallowed by the gateway. This is the gotcha most carputer projects discover the hard way. To actually control HVAC, the OptiPlex needs a second CAN tap at the radio harness behind the gateway β same place Phoenix's decoder taps it.
Hardware needed for Phase 3 (~$30-$200)
| Option | Cost | What it is |
|---|---|---|
| Second USB-CAN dongle wired to radio harness | $30-80 | Same form factor as SH-C31G (Korlan USB2CAN or similar), spliced into the Phoenix harness CAN pair. Cheapest path, most DIY wiring. |
| Comma.ai Panda | $199 | Purpose-built dual-CAN dongle with bus-bridging firmware β already supports Chrysler/Ram in OpenPilot. Cleanest path, no soldering. |
| CAN gateway-bypass board (DIY) | ~$40 | STM32 + dual MCP2515 transceivers acting as a one-way bridge from OBD-II to radio-harness CAN. Power-user move; only if you want full control. |
DBC starting point β opendbc
Chrysler doesn't publish the CAN message database; community has reverse-engineered ~70% of it:
- GitHub: commaai/opendbc β
chrysler_pacifica_2017_hybrid_generated.dbc,chrysler_ram_dt_generated.dbc, and 2014-era Ram entries - Coverage typically good for: steering wheel buttons, gear/RPM/speed, HVAC current state (read), some HVAC commands (write)
- Coverage typically thin for: heated seats, defrost-rear, mirror controls β these need your own sniffing
- Validation: SH-C31G β
candump -t a can0 > log.txtβ press the actual button βgrepthe log for delta frames β confirm
Voice-control flow (Phase 3 target)
"Hey JARVIS, set driver temp to 72"
β Porcupine β whisper.cpp β Qwen3 4B local (intent + slot extraction)
β router maps intent='set_hvac', slot={zone:driver, temp:72}
β Python builds CAN frame from DBC: ID 0x29C, payload {driver_temp: encode(72)}
β python-can writes to /dev/ttyUSB1 (second CAN tap, NOT SH-C31G)
β HVAC controller obeys β actual blower changes
β Phoenix touchscreen automatically updates (it's reading the same B-CAN bus)
β TTS: "Driver side, seventy-two."
Both control channels coexist. Touch the Phoenix screen to set temp β CAN frame on B-CAN β both Phoenix and OptiPlex see the new state. Speak to JARVIS β OptiPlex writes the same frame β both screens update. No mode-switching, no override needed.
Risk register
- Phoenix decoder + OptiPlex both writing to B-CAN = potential frame collision. Mitigation: OptiPlex writes ONLY in response to voice intent, never on a timer; Phoenix writes ONLY in response to user touch. Watchdog: if OptiPlex frame doesn't take (HVAC controller drops it), retry once, then back off β never spam.
- Aftermarket tune sensitivity to extra CAN traffic (same as Β§4.2.1 gate #1, raised one tier β Phase 3 adds a writer not just a reader). Mitigation: stay on B-CAN HVAC frames, NEVER touch C-CAN powertrain.
- Wrong DBC entry = wrong HVAC setting (e.g., asked for 72Β°F driver, got 72Β°F passenger). Mitigation: read-back verify the frame in
vehicle_stateafter every write; speak the actual confirmed temp. - HVAC controller refuses spoofed frame because checksum/counter byte is wrong. Mitigation: opendbc usually has the checksum function; if not, sniff a real Phoenix-originated frame and reverse-engineer the counter.
- Insurance/warranty after a claim β disclosed CAN-write activity is a gray area. Mitigation: scope writes to non-safety systems (HVAC, heated seats, defrost β none of which can cause an accident); keep an event log of every write; never touch ABS/airbag/steering/powertrain frames.
Why not just do this on the Phoenix Android side?
You could β Phoenix's Android image already has the AC overlay app, and an APK could in principle add voice control on top. But:
- Voice + Qwen3 4B + whisper.cpp lives on the OptiPlex, not the Phoenix (Phoenix has 8GB RAM and an entry SoC; OptiPlex has 16-32GB and an 8c/16t i7).
- The carputer architecture (Β§9) puts the brain on the OptiPlex on purpose β adding voice control means an OptiPlex feature, not a Phoenix one.
- Phoenix's Android image is locked-down enough that maintaining a custom APK across factory firmware updates is a maintenance tax. CAN write from OptiPlex is debian/Python β version-controlled, reproducible.
Out of scope for Phase 3 (deferred further)
- Voice control of powertrain (gear lock, cruise control, etc.) β never, do not write to C-CAN.
- Door lock / window control β possible on B-CAN, but security-sensitive (someone shouts "unlock truck" near the cab β bad). Defer.
- Bypassing/replacing the Phoenix entirely with a custom dash screen β see "Design principle" above. Not the plan.
Phase 3 ship-criteria (not blocking Phase 1/2)
- [ ] opendbc Chrysler/Ram DBC imported + 4-5 HVAC frames validated by sniff-and-compare against Phoenix touchscreen actions
- [ ] Second CAN tap installed (Panda preferred, USB-CAN at radio harness acceptable)
- [ ] python-can write of
set_driver_tempround-trips through the HVAC controller and Phoenix screen reflects the change - [ ] Voice intent
set_hvacwired through the router (Β§9.2) β local-only path (no cloud for HVAC commands; latency + privacy) - [ ] Read-back verify + retry-once-then-abort watchdog in code
- [ ] Event log entry per CAN-write (ts, frame ID, payload, ack-state) into the Β§9.3 history store
- [ ] Tested with the aftermarket tune installed and no error codes after 100 mi of use