Architecture¶
The package is layered so that everything except the outermost layer can be tested without a terminal and without a vehicle.
obd_tui/
├── cli.py argument parsing, process entry point
├── config.py user settings, read from a TOML file
├── logs.py routing log records to the interface, not the terminal
├── app.py Textual application: tabs, key bindings, workers
├── services/ talking to the adapter and the vehicle
│ ├── detection.py find the serial port of an adapter
│ ├── connection.py open the link, query commands, discover capabilities
│ ├── diesel_monitoring.py the aftertreatment in one view, derived rows
│ ├── polling.py one sweep of the sensors into a state snapshot
│ ├── recording.py append each sweep to a JSON Lines file
│ ├── reporting.py everything the session knows, dated, as data
│ ├── simulation.py a vehicle that only exists in memory
│ └── session.py the connection lifecycle the dashboard renders
├── obd/ capabilities: what to ask, resolved per vehicle
│ ├── standard.py SAE/ISO PIDs past the end of python-obd's table
│ ├── uds.py proprietary identifiers, declared with provenance
│ ├── manufacturers/ one profile per make, tables per engine
│ └── registry.py standard first, then the manufacturer
├── models/ plain data: adapter, command catalogue, vehicle state,
│ reading history, exhaust bank, particulate filter
└── views/ turning readings into text
├── format.py one reading into one string
├── units.py metric or imperial display of a metric reading
├── report.py a report as a person reads it, panels included
├── gauges.py block-character bars
├── panel.py assembling the lines of a panel
└── panels/ the eight panels and their registry
Dependencies point inwards: views and services both know models,
services never imports views, and only app.py knows Textual exists.
Session: the whole model¶
Session owns the connection state machine, the adapter it is bound to,
the discovered catalogue and the latest readings. It is everything the
dashboard draws and it knows nothing about the UI, so the lifecycle -
connect, discover, poll, disconnect - is exercised in tests with a fake
connection and no terminal at all.
States are DISCONNECTED, CONNECTING, CONNECTED, NO_DEVICE, FAILED
and LOST. Only CONNECTED polls. LOST is a disconnect that keeps the
last readings on screen, because what the vehicle was doing when it went
quiet is the interesting part.
Beside the state sits one flag, held: set by disconnect, cleared by
connect. The state says whether the link is up; the flag says whether
the user wants it down, which the state alone cannot tell, since
DISCONNECTED is also where every session starts. wants_link combines
the two into the one question a reconnect policy has to ask.
Failures degrade, they do not propagate¶
python-obd raises freely: a port that vanished, a clone adapter that stops
replying, a protocol it fails to negotiate. ObdConnection turns each of
those into "no answer" - a None reading, an empty catalogue, a failed
open - so a flaky adapter degrades the dashboard instead of taking it down.
The same idea runs through the rendering: a missing reading is dropped rather than shown as a placeholder, so a panel always reflects what the ECU really answered.
Capabilities, not commands¶
The poller, the state and the views ask for a capability - EGT_BANK_1,
what the dashboard wants to know - never for the bytes that fetch it. The
obd package turns one into the other, per vehicle, in a fixed order:
SAE/ISO PID the vehicle vouches for?
↓ yes ↓ no
standard command manufacturer profile has one?
↓ yes ↓ no
manufacturer command not available
python-obd's mode 01 table stops at PID 0x5F, and its capability scan
stops with it. The standard registry declares the PIDs beyond in the
library's own terms, an OBDCommand with a decoder, and sends them
forced, since python-obd would otherwise refuse a command its scan never
found:
| Capability | PID | Reading |
|---|---|---|
EGT_BANK_1, EGT_BANK_2 |
0x78, 0x79 |
Exhaust gas temperatures, four sensors per bank |
DPF_DIFFERENTIAL_PRESSURE |
0x7A |
Particulate filter differential, inlet and outlet pressures |
DPF_TEMP_INLET, DPF_TEMP_OUTLET |
0x7C |
Particulate filter inlet and outlet temperatures, one frame for both |
DPF_TEMP_INTERNAL |
none | Manufacturer only |
DPF_REGEN_STATUS |
0x8B |
Regeneration status and normalised trigger |
DPF_SOOT_LOAD |
none | Manufacturer only |
Two capabilities may share one command - the filter's inlet and outlet
come in the one frame of PID 0x7C - and a sweep asks the adapter once
for it, handing the same answer to both: the connection remembers every
answer for the length of a sweep. Each is still a capability of its own,
so that a manufacturer can answer either alone, as a bare number, and
the internal temperature only that way.
Discovery covers them the way the ECU does: it asks for the supported-PID
bitmaps of their blocks, PIDs 0x60 and 0x80, and a standard command
only ever answers a capability whose bit is set.
The manufacturer registry is where a make is allowed to appear, and the
only place. Discovery reads the VIN and matches it against the profiles,
the generic one last since it claims everything; a profile answers the
capabilities its manufacturer exposes some non-standard way, and is asked
only after the standard has come up empty. A profile is bound to the
engine the user declared, and answers from tables keyed by engine code:
each entry is a DataIdentifier, a UDS service 0x22 reading declared
with the engines it was seen on, how far to trust it and where that
comes from, whose decoder refuses a negative response, another
identifier or a payload of the wrong length. An engine with no table
gets the standard and no more. Suzuki is recognised; its tables are
empty until an identifier can be cited. Nothing outside the package
tests a make.
The profile also knows what only a wiring diagram can say: which exhaust gas sensor, if any, sits at the particulate filter's inlet or outlet. The generic profile places none, so an exhaust sensor never passes for a filter temperature by assumption from its number.
Discovery settles the answer for every capability once, on connect, and the catalogue lists each under its own name whichever command serves it. A capability neither registry can answer on this vehicle is never sent.
A bank answers several sensors at once and a vehicle may have several banks, so the banks are one family in the state - a mapping by bank number - rather than a field per sensor. Every bank command feeds that one field, each adding its own member to what the sweep holds, and the exhaust panel draws whatever is there. The decoded bank is a plain model of its own, indexed by sensor, with no knowledge of python-obd.
The aftertreatment in one view¶
After each sweep, a monitoring service completes the snapshot with what
the profile can add - exhaust sensors standing in for filter temperatures
the ECU did not report, a regeneration guessed from a hot filter at
moderate load when the ECU reports none - and builds one view from the
capabilities alone: the readings as they came, and beside them what
follows. Restriction per unit of air flow, inlet-to-outlet delta, a
descriptive state for the pressure that says elevated or
inconsistent and never clogged, and the time since a regeneration
was seen to end, which is why the service is an object with a clock.
Every stand-in and every guess is flagged, and the panel words it as one; an estimate never replaces what the ECU said. The service never sends a command and never tests a make: what a profile knows about an engine is asked of the profile.
Adapter work runs off the event loop¶
Connecting probes the protocol for seconds; a sweep queries dozens of PIDs; clearing the codes waits on the ECU. Any of them would freeze a Textual application if it ran on the event loop, so all three run on worker threads in one exclusive group - exclusive because a serial link cannot serve a connect and a poll at once - and hand their result back to the UI thread when done.
Panels are a registry, not a switch¶
Each panel is a function of the readings and the catalogue. A single registry holds their order, titles and shortcuts; the application builds tabs, key bindings and content widgets from that one list, so adding a panel is one entry rather than edits in three parallel places.
Discovery drives polling¶
On connect, the vehicle is asked which commands it supports. The poller queries only those, at one of three cadences, promoting whatever the open panel displays to every sweep. When discovery comes back empty - an adapter that will not report its capabilities - it falls back to querying everything, which is slower but still works.
Five supported commands going unanswered in a row is taken as a lost link
rather than dropped frames: the sweep is abandoned and the session moves to
LINK LOST, keeping the last readings on screen.
Two timers, one running¶
The application owns a poll timer and a retry timer, and every transition - a connect answering, a sweep ending, the user hanging up - settles them from the session's state in one place: polling while the link is up, retrying while it is down and wanted, neither once the user has hung up. The retry timer is also paused for the length of a connect attempt, since a second open landing on the serial link mid-probe helps nothing.
A state is a snapshot, a history is a series¶
VehicleState is frozen. A sweep builds a new one from the previous one
and the session rebinds it in a single assignment, because the sweep runs
on a worker thread while the UI renders on another - one rebind is atomic,
forty field assignments are not.
The rolling history of the charted readings lives beside the state rather than inside it, for the same reason: a state describes one sweep, a history spans many.