-
One button transmits. A test proves it's the only one.
5 days ago • 0 commentsLog 6 closed with a promise: the one control in the whole UI that transmits anything at all — the NMEA2K screen's GNSS source node, and how a heartbeat exception gets built so it can never quietly become a general-purpose one. This is that log.
Why anything on this screen would transmit at all
Everything else in Scottina listens. The CAN screen sniffs raw arbitration IDs, the NMEA2K screen decodes known PGNs against installed tables, and both enforce RX-only in code, not by promise. But an NMEA2000 bus doesn't let a silent node sit on it indefinitely — a device that never answers an ISO address claim or an ISO request looks broken to everything else on the bus, and there's a real bench case for wanting Scottina to be a GPS source: proving a freshly converted PGN table against real bus traffic without dragging an actual chartplotter to the workbench.
n2k/node.pyexists for exactly that case, and nowhere else. It claims a source address, defends it, answers ISO requests, and broadcasts five GNSS PGNs — 126992, 129025, 129026, 129029, 126993 — sourced from the samegpsdsnapshot the GPS tile already reads. Nothing about the rest of the CAN stack changes; one module gets permission to speak.A state machine that can say "no"
GnssSourceNodeisn't a flag, it's five states:OFF,CLAIMING,ACTIVE,CANNOT_CLAIM,STOPPED_FIX. The NMEA2K screen's one TX affordance — a single button, deliberately living on the bus screen and not the GPS tile, because sourcing PGNs is a bus action — reads its label straight off that state:Source GNSS → buswhen off,GNSS: claiming…mid-handshake,GNSS ■ stoponce active,GNSS: no addressif the claim loses out to another device already holding that source address,GNSS: fix lost ■if the bus keeps the node parked after the fix that justified it disappears. Every one of those is a real, distinct outcome shown in plain words — there's no state where the button just goes quiet and leaves the bench guessing whether Scottina is talking or not.The node doesn't get to guess it has a fix
_gnss_eligible()gates the button on two facts, not one:/dev/gps0has to exist, andgps.snapshot.read_position()has to return a current fix — the same staleness rule GPS.md already applies everywhere else a tile reads position. No fix, no button; the toast says so plainly, "No GPS fix — a node never sources stale data." That check doesn't stop mattering once the node is running: losing the fix mid-session drops the state toSTOPPED_FIXrather than letting the node keep broadcasting a last-known position as if it were current. A GNSS source that quietly goes stale is worse than one that visibly stops.Telling your own echo from the boat's GPS
Once the node is active, its own broadcasts land right back in the NMEA2K screen's decode view like any other bus traffic — which is useful, since watching your own PGNs arrive is how you confirm the TX actually worked. But that echo has to be told apart from a real GPS already on the bus, so every row sourced from the node's own claimed address gets tagged with a ▸ marker, and the screen's GPS-vs-bus comparison explicitly excludes self-traffic. Verifying your own transmission and trusting a second, independent GNSS source are two different questions, and the UI keeps them from blurring into one.
The part that keeps this from creeping
None of the above matters if the exception leaks. So it's checked the same way the LAN Scan screen's blocked nmap flags are checked: in code, at build time, not by anyone remembering a rule.
tests/test_txscan.pyAST-scans the entire tree against a positive allow-list of exactly one TX-permitted module —n2k/node.py. Any send-shaped call on a socket turning up anywhere else fails the build outright. That's one half of the gate;tests/test_busmon.pyandtests/test_n2k.pyare the independent reject pass, proving the CAN and NMEA2K screens and their bus models stay RX-only on their own terms, without relying on the allow-list to catch what they might do wrong. A heartbeat exception granted to one named module, checked from both directions, is not the same thing as a diagnostics tool that happens to transmit today and might transmit more tomorrow.Next log: the other half of that same discipline, working in the opposite direction — the LAN Scan screen, where six specific nmap flags are the ones that must never be reachable, and how a reject-list earns the same build-time trust as the GNSS allow-list did here.
-
Nobody is coming back to press Stop
08/20/2026 at 19:55 • 0 commentsThe converter is where vendor PDFs turn into NMEA2000 decode tables. That work happens on a laptop or a phone, on a real screen, because reviewing a bit-field offset on a 3.5" panel is not a thing anyone should do. So the usage pattern is: tap the Tables tile on the Pi, walk to the bench, do the review in a browser, close the laptop, forget about it.
That last step is the design problem. A Flask app with an upload endpoint, bound to
0.0.0.0so the laptop can reach it, is not something you want sitting there for the next six weeks because a person got distracted. And asking the person to come back and press Stop is not a design, it is a wish.So the shutdown does not live in kilodash at all.
kilodash/tableconv.pyruns a small watchdog thread that wakes every 15 s, asks one object how long the app has been idle, and callsos._exit(0)once that passes the--idle-minwindow — 15 minutes by default. The app hangs up on itself. The front panel is not involved and does not need to be running.Two things count as "busy", not one
The obvious idle clock is "time since the last HTTP request", and it is wrong. A big vendor PDF can take three minutes to parse in the extraction subprocess, and during those three minutes there are no requests at all — just a browser waiting on one that hasn't returned. An HTTP-only clock kills the job it is waiting on.
So the
Activityobject tracks two things behind a lock: the last touch time, and a count of in-flight jobs.idle_secs()returns a flat0.0while any job is running, whatever the clock says. An upload callsjob_start()before extraction andjob_end()in afinally, so a parse that crashes still releases the counter.There is a second, smaller trap in the same place. The Tables tile polls
/statusevery 5 s to draw its countdown — and if that poll counted as activity, the app would never idle out as long as anyone was looking at the tile. So/statusis excluded from the touch, by path, in thebefore_requesthook. The observer does not get to change what it observes.The unit has to agree
setup/kilodash-tables.serviceisRestart=no, and that line is load-bearing. A clean idle exit is exit code 0 — with a restart policy in place, systemd would dutifully bring the app back up, and the two halves of the design would spend all night fighting each other. On-demand start, self-managed stop, no supervisor second-guessing either.Being killable at any instant is what makes the whole arrangement safe. Every write into the table store is tmp-file plus atomic rename, so there is no moment where the timer firing leaves a half-written table behind. The timeout does not need to be polite because the storage layer does not need it to be.
The tile mirrors, it never computes
The Tables screen is deliberately thin. It starts the service, and it never stops it on leave — navigating away from the tile must not kill a review session in progress on the laptop. What it shows is read back from the service's own
/status: table count, and the idle clock rendered asrunning · idle-3:42, so the countdown to shutdown is visible rather than a surprise. Below that, the URL for the advertised address, and a QR code of the same, because typing an IP into a phone is the small friction this whole project exists to delete.Next log: the step inside that web app we have not talked about — the side-by-side review page, and why extraction is assistive but approval is always human.
-
A launched process is never a serving one.
08/14/2026 at 00:20 • 0 commentsA launched process is not a serving one
Starting Kismet or Node-RED is one line:
Popena command, orsystemctl starta unit. Neither of those tells you the thing actually works. A process can spawn and then crash three seconds later reading a stale config; a systemd unit can report success while the app inside it is still binding its listener. None of that is "ready" in any sense a person standing at the bench cares about — ready means the browser UI will actually load if they type the address in.So
kilodash/webapp.pydoesn't trust the launch call. It trusts a TCP probe against the app's own port.probe()is nothing exotic —socket. create_connectionwith a 0.4-second timeout — but it's the one signal that can't be faked by a process existing. Only once that connection succeeds does a tile's status card turn green and say "Web UI confirmed."
---------- more ----------A small state machine, not a boolean
WebApptracks four states:STOPPED,STARTING,UP,ERROR.launch()moves it toSTARTINGand stamps the time. From there,poll()— called once a tick from the screen's owntick()— does the throttled checking: a probe every 0.5 s whileSTARTING, promoting toUPthe moment the port answers; a check of the child process's own exit code in the same window, so a crash reports "Process exited (code …)" instead of just sitting inSTARTINGforever; and a 30-secondready_timeoutpast which a slow app just gets markedERRORwith a plain "Timed out waiting for web UI" rather than a tile that hangs on "Launching…" indefinitely. OnceUP, the probe backs off to every 2 s — enough to notice the app died mid-session without hammering the port while everything is fine.Adopt, don't duplicate
launch()probes before it starts anything. If the port already answers — the app autostarted at boot, or was left running from a previous visit to the screen — kilodash adopts it asUPinstead of spawning a second copy. That one check is what makes leaving a screen and coming back to it safe, and it's also why Node-RED and Kismet don't stop themselves onon_leaveby default: the app keeps serving, and the next time the tile opens,launch()finds it already up and just confirms it.What every screen gets for free
None of this is app-specific — the app-specific part is one subclass of
WebAppScreensettingapp_name,port, and aserviceorstart_cmd. In exchange it gets the same compact status card everywhere: border color is the state, body text is the exactIP:portto type into a phone or laptop once the state isUP, and a tap on the card stops the app (with a confirm) or launches it again. The probe itself always dials127.0.0.1— the app binds locally on the Pi — but the displayed address comes from a separatelan_ip()lookup, so the thing you'd type from another device is never localhost.That's the whole contract: launch, wait for a real answer on the wire, show where to point a browser. Kismet and Node-RED both run on it today without either of them knowing the other exists.
-
The day Scottina Prime turned into three documents arguing
08/10/2026 at 02:31 • 2 commentsEvery earlier log in this series describes a piece of Scottina working. This one is about a piece of process that had to work first, before any of that code could get written without stepping on itself.
Work stalled entirely, for a full day — not on a bug, but on not knowing which document to believe.
![]()
The scene
Early on, README.md and CLAUDE.md laid out a CAN addressing and message schema — one that had worked before, on an earlier project. As real edge cases turned up on the bench, that schema needed a small rework. Small on paper. In practice, code built against the old schema didn't fail loudly — it produced silently corrupted values instead of a visible fault. Anything built without the new schema in mind still ran, just wrong. That surfaced as a string of errors with no obvious common cause, and panic mode followed.
---------- more ----------The overhaul that came out of that panic was thorough — except it left README.md and CLAUDE.md untouched, on the theory that the old schema was just a useful historical note, not something actively poisoning new work. It was poisoning new work. A later session picked up README.md and CLAUDE.md as ground truth, never checked whether a newer document overrode them, and rebuilt against the stale schema all over again.
Timestamps on each document helped, but not enough — editing one section of CLAUDE.md updated the timestamp for the whole file, so stale schema elsewhere in the same document looked just as current as whatever had actually just been touched. A document isn't one fact, it's many, and a single timestamp can't tell you which ones are still true.
That's what forced the real fix: a document that can go stale in only one piece has to be disposable as a whole, not patched forever. Scratchpads.
The five-step discipline
Any working document that is not the authority document is a scratchpad, labeled as one from the moment it exists — a one-line stamp at the top naming the authority document it feeds and confirming it expires when the effort does.
SCRATCHPAD <------ TEMP OVERRIDE ------ AUTHORITY DOC
|
v
DISTILL
|
v
HARVEST ---------------------> AUTHORITY DOC
| |
v |
VERIFY <----------------------------+
|
v
PURGE ---- removes override pointer
|
v
DECLARE- Distill — one pass over the scratchpad, sorting real conclusions from dead ends.
- Harvest — write each surviving conclusion into the authority document, in plain terms, as a locked decision.
- Verify — read the authority document back and confirm each conclusion actually landed, not just got referenced. Nothing proceeds until this passes.
- Purge — the scratchpad leaves the working set. Loose research gets deleted; anything worth keeping gets archived out of reach, not left where new work could find it and build on it by mistake.
- Declare — the effort is stated complete. Nothing stale remains.
Verify and Purge are the two steps that got skipped the last time this was tried informally, and skipping them is exactly how a project ends up with three documents arguing again.
Closing the loop for the session that doesn't dig
The birth stamp solves half the problem: a scratchpad always says which authority document it feeds. It doesn't solve the other half — a session that opens only README.md or CLAUDE.md has no reason to go looking for a scratchpad it doesn't know exists. That's exactly the gap that let a later session rebuild against the old CAN schema: the authority-looking document was sitting right there, and nothing on it said "don't trust this section yet."
The fix runs the pointer the other direction too. When a scratchpad opens for a topic, the authority document gets a matching one-line note of its own — naming the scratchpad, stating plainly that it's the current source of truth for that topic until it's harvested. A session too rushed to search past CLAUDE.md still can't miss it; the contradiction is sitting in the same file it already trusts. The note comes out as part of Purge, the same moment the scratchpad itself does.
Who gets to say "done"
A smoke test passing is a signal, not a decision. Only one person declares an effort complete, and the trigger is a specific phrase, not a green checkmark. It's a small nod to Cave Johnson's Aperture Science broadcasts in Portal 2, which have a habit of signing off with "I'm Cave Johnson, we're done here." Say the phrase, and the effort closes — deliberately unmistakable, a little theatrical, not something that gets triggered by accident or inferred by a tool. The bit is fun, but the point behind it isn't: a human closes the loop, not the automation that thinks it found the finish line.
What it cost, and what it bought
Checking each step against real criteria made the CANTick effort longer to walk through. That's a real cost, worth saying plainly rather than selling this as free. What it bought back was control — the build and deployment process stayed predictable from one session to the next, instead of the familiar feeling of losing grip on a project overnight.
That matters more, not less, once an AI assistant has a hand in the work. A human collaborator carries yesterday's context forward without being asked. An AI one doesn't — every session starts from whatever's written down, and if that record disagrees with itself, so will the next session's work. Scratchpads with a hard harvest gate exist to give that context one place to land that can't quietly go stale underneath a live document.
Instruction for your LM:
## Effort lifecycle and scratchpad discipline ### Scratchpad birth stamp Any document that is not a locked source of truth is a scratchpad. Stamp it at birth. Put one line at the very top: > Scratchpad for <effort>. Not a source of truth. > Expires on conclusion. Authority lives in <authority document>. At the same time, add a matching one-line pointer to the authority document itself, wherever it covers the topic the scratchpad is working on: > Active scratchpad for this topic: <scratchpad name>. Treat it as the > current source of truth until harvested. This closes the loop for a new session that reads only the authority document and has no reason to go looking for a scratchpad it doesn't know exists. ### The five beats When an effort concludes, run these five steps in order. Never skip one. 1. **Distill** — one pass on the scratchpad. Separate real conclusions from dead ends. Pull the survivors into a clean list. 2. **Harvest** — write each conclusion into the authority document as a locked decision, in Scott's words. 3. **Verify** — read the authority document back. Confirm each conclusion is present, not just referenced. This is the gate. Nothing proceeds until it passes. 4. **Purge** — remove the scratchpad from the working set. Delete loose research. Archive signed paperwork to a folder nobody builds against. Remove the pointer note from the authority document at the same time. 5. **Declare** — state the effort complete. Nothing stale remains. Verify and Purge are the two steps that failed last time. Do not skip them. ### Trigger Code may signal an effort looks complete when smoke tests meet the to-do criteria. That is a signal, not a decision. Only Scott declares an effort complete. Scott's declaration triggers Distill. The trigger phrase is **"This is Scott McLeslie. We are done here!"**. When Scott says it, begin the five beats. ### Delete or archive - Loose research scratchpads — delete. - Signed team paperwork — archive out of the working set.The verdict
CANTick was the test case, and it held. This lifecycle is now the standing default for how effort work gets documented on Scottina, not a one-off experiment that happened to work once.
Next log: back to the hardware — CANTick Phase 7 bench work and the two-node CAN bus validation waiting on deck.
-
Now you see me, now you don't.
08/06/2026 at 17:19 • 0 commentsThe tile that shows up on its own — and disappears just as fast
Log 1 closed with a promise: hotplug, and how a screen's tile appears the instant its dongle is plugged in and vanishes the instant it isn't. That behavior is what makes the home grid trustworthy — you never wonder if a tool is available, you just look. Here is how it works.
Why not udev rules and hope
The obvious tool for USB presence is udev: write a rule, get an event, react to it. Scottina does not use it for tile visibility.
devices.pypolls sysfs instead — cheap, nopyudevdependency, no rule files to keep in sync across a Pi image rebuild. The launcher callsrefresh()a few times a second, and the tile grid shows a device's tile only while the check comes back positive. Simple beats clever here: one function, one data source, one place to look when a device does not show up.A catalog of USB ids, and their opinions
Each device screen is gated on a set of
(vendor, product)USB ids —SDR_IDSfor the RTL2832U dongles,ALFA_IDSfor the Wi-Fi sniff adapter,CANABLE_IDSfor candleLight/gs_usb boards,FTDI_IDSfor the serial adapters. Most of these are a clean lookup. A few are not:- The FX2LP logic analyzer changes identity mid-session. It enumerates
as a bare Cypress bootloader until firmware loads, then re-enumerates
under an fx2lafw id.
FX2LA_IDSmatches both, plus a couple of Saleae/ USBee clone EEPROM ids, because a board scanned once this session is sitting in its post-load state and the tile still has to recognize it. - CanTick shares its vendor id with every ESP32 board on the market. Espressif's VID is not enough to identify anything. Presence detection matches the USB product string instead — and on the ESP32-S3, that string is not even the firmware's own name; the built-in USB-serial-JTAG reports the fixed hardware string "USB JTAG/serial debug unit" regardless of what firmware is flashed. A string-less descriptor counts too, since that is what a fresh, unconfigured board reports.
None of this is guesswork. Every id in the table is a fact recorded on the bench with the actual device in hand, not a datasheet guess — the comments in
devices.pysay so, including the date for the CanTick case.The tile follows the truth on the panel
Fixed screens — LAN Scan, Wi-Fi, Pi Health — are always on the grid. Device tiles are not: they appear only while
refresh()finds their id present, and they carry a small green "live" badge so it is obvious at a glance which tools are physically available right now versus which ones would need a dongle first. Unplug the RTL-SDR mid-session and its tile is gone on the next poll, no stale entry pointing at hardware that is not there anymore.Adding a device is a known recipe, not a one-off
This pattern gets reused every time a new piece of hardware joins Scottina. The logic-analyzer integration is the clearest recent example: Phase 2 of that work is nothing but "add the FX2LP ids to
devices.pyusing the same mechanism the other screens use, gate the new screen's tile on that presence, done." No new detection framework, no special case in the launcher — just another entry in the same table. That reuse is the actual payoff of building the mechanism once and keeping it boring.Next log: what happens after a tile is tapped and the tool is a web app — how Scottina waits for Node-RED or Kismet to actually answer on their port before it tells you where to point a browser, instead of guessing that a spawned process is a serving one.
- The FX2LP logic analyzer changes identity mid-session. It enumerates
as a bare Cypress bootloader until firmware loads, then re-enumerates
under an fx2lafw id.
-
No X server, no mouse — and the panel that made me earn it
07/30/2026 at 17:12 • 0 commentsLog 1 mentioned "render directly to the framebuffer" as one of six ground rules and moved on. It deserves its own post, because this is the decision that almost every other design choice in Scottina traces back to.
![]()
Why not just run Xorg like everybody else?
The obvious path for a touchscreen kiosk is a desktop stack: Xorg or Wayland, SDL or Qt, a browser or a toolkit on top. I tried variations on that and kept running into the same wall — anything that can lose the DRM device eventually does, and on a 3.5" panel wired over SPI, "eventually" shows up as a frozen or blank screen at the worst possible bench moment. The only approach that's proven reliable on this hardware is the boring one: own
/dev/fb0directly.---------- more ----------Scottina composes every frame with PIL + numpy and writes it straight to the
ili9486drmfbframebuffer. Touch comes from the ADS7846 controller's evdev node, polled, not grabbed. No SDL, no Xorg, nothing else that can fight Scottina for the display.The ILI9486 has its own opinions
Owning the framebuffer doesn't mean the panel behaves. A few things it took real bench time to work around:
- No controllable backlight.
/sys/class/backlightis empty on this panel, so "dimming" after idle is a software screensaver that darkens the rendered image rather than a hardware fade. If a PWM backlight ever gets wired in, Scottina will auto-detect the sysfs node and use it — the code's already there, just unused. - Rotation lives in the overlay, calibration doesn't. The panel comes up
through one line in
config.txt—dtoverlay=piscreen,drm,rotate=90— and that's the only display setting that needs a reboot. Touch axis mapping (swap X/Y, invert X, invert Y) is a runtime config value instead, so re-calibrating after a mount change is a Settings-screen toggle or aconfig.jsonedit over SSH, never a re-flash. - The console has to be told where to live. The panel only scans out
over SPI once its DRM pipe is enabled, and that's driven by whatever the
console is mapped onto — not guaranteed to be
fb0by default. The systemd unit forces it withcon2fbmapbefore Scottina starts, blanks the cursor, and gives the modeset a moment to settle first.
Making an SPI panel feel fast
SPI is not a fast bus, so full-frame redraws at interactive rates were never going to happen. Every screen that ticks reports the boxes that actually changed;
framebuffer.pymerges those into row bands (bands within 8 rows of each other get fused — a seek costs more than a few extra rows) and writes only those, since the DRM fbdev layer derives its SPI damage from the byte range touched anyway. That's a 2–15× saving over a full blit, and it's what lets live screens like CAN Bus tick at ~20 Hz while everything idle sits at ~1 Hz without cooking the bus.Next log: hotplug — how a screen's tile appears the instant its dongle is plugged in, and disappears the instant it isn't.
- No controllable backlight.
-
Who said I need all these buttons in my face?
07/26/2026 at 23:50 • 0 commentsStarting the logs where the project started: with the thing that annoyed me enough to build hardware.
The problem, stated plainly
Every diagnostic session I run lands in one of two bad places.
Place one: SSH into a headless box and squint at a terminal. Fine if you already know the exact command and the exact flag. Miserable when you're standing at a bench with one hand on a probe.
Place two: carry a laptop to the bench, open the full GUI, and navigate a tool built for someone doing everything, when you are doing one thing.
The observation that turned into a project: for any single diagnostic question, I use roughly two controls and watch roughly two numbers. Consistently. Across CAN, I2C, serial, Wi-Fi, SDR. The tools aren't wrong — they're general-purpose, and general-purpose means the 90% you're not using is between you and the 10% you are.
---------- more ----------The three annoyances that made it concrete
Getting a headless Pi onto a network. You pull one off the rack and you have two bad options: hook up a screen and keyboard for the sole purpose of joining Wi-Fi, or go hunting for an Ethernet cable — and then arp-scan into a forest of identical "Raspberry Pi Foundation" MACs and SSH in blind. This costs me hours a year.
Port roulette. Half these tools ship a browser UI. Which port did it land on? Is it actually serving, or did it just spawn and die? You find out by trying.
Bitrate guessing. Unknown CAN bus, unknown rate. You try 250k. You try 500k. You get nothing and can't tell whether the bus is silent or you're just wrong.
None of these are hard problems. They're all friction problems. Friction problems don't get solved because each one individually isn't worth solving.
The approach
A Raspberry Pi 5 and a 3.5" touchscreen that boots straight to a tile grid. Tap a tile, get an answer, tap Back. No desktop, no keyboard, no mouse.
The rules I'm building to, stated up front so I can be held to them:
- Front-end existing tools, don't reinvent them. Underneath it's arp-scan, i2cdetect, candump, gpsd, rtl_433 — software that's already trusted and already debugged. I'm building the panel, not the tool.
- Render to the framebuffer, not through a display server. Straight to /dev/fb0, touch straight off evdev. Nothing in the stack that can lose the display device. This one was learned the hard way and gets its own log.
- Don't fake interactions the hardware can't do. Resistive touch cannot reliably distinguish a horizontal swipe from a vertical drag. So there are no swipes. Every navigation is a discrete tap. Home is a grid, every screen has a Back button, long lists get real scroll buttons.
- Contract first. Any coupling point between components gets a written spec before either side is built. Contracts are the only coupling point.
- Prove it headless before it touches hardware. Software gets verified without the board, then the board bring-up is its own gated phase. Debugging a logic error and a wiring error at the same time is a bad afternoon.
- Fail safe by default. Anything unconfigured resolves to the restrictive behavior, never the open one.
Scope, declared up front
It runs Kali, so let me get ahead of the obvious question. Scope is strictly diagnostics. No offensive or attack tooling. Not "discouraged," not "behind a warning dialog" — the UI is built so it cannot express an offensive operation. Enforced in code, not convention.
The one exception is CAN bus, which does normal TX and RX. That's not a loophole, it's a requirement: heartbeats and replies are how you diagnose a bus. A listen-only CAN tool can't tell you whether a node is deaf or merely quiet.
This is a shop multitool, not a gadget.
Next
Next log: why I threw out the X server, and what an ILI9486 panel does to you before you figure that out.
Scottsky


