Close
0%
0%

Scottina

A pocket-sized front panel for the diagnostic tools you already use

Similar projects worth following
A pocket-sized touchscreen front panel for the bench diagnostic tools you already use — a Pi 5 + 3.5" screen that boots straight to a tap-driven tile grid, strips each tool down to the two controls and two numbers you actually watch, and skips the other 90%. Diagnostics only — not a Flipper, not a Marauder, not a wardriving toy.

The gap I'm closing

Bench diagnostics live at two extremes. Either you're squinting at a terminal over SSH on a headless box, or you're hauling a laptop to the bench to open a full-size GUI. Headless is too little. The full interface is far too much.

Here's what I kept noticing: for any single diagnostic question, I'm pressing about two buttons of that tool and watching about two numbers come back. The other 90% of the interface is just in the way. I don't need the ribbon menus, the config panels, the twelve tabs. I need to plug a thing in, glance at a 3.5" screen, and get my answer.

Scottina is that middle ground. It doesn't invent new tools — it gives the ones you already trust a front panel sized to the one question you're asking.

Read more »

  • 1 × Raspberry Pi 5 Runs Kali. The whole panel is a Python app rendering to the framebuffer — the Pi 5's headroom is what makes SDR decode and live CAN counters feel instant instead of laggy.
  • 1 × 3.5" ILI9486 SPI touchscreen, 480×320, ADS7846 resistive touch The panel Scottina is built around. Driven by the piscreen DRM overlay, rendered straight to /dev/fb0, touch read from evdev. No X server anywhere in the stack.
  • 1 × Cheapo Pi Case off Amazon This one comes with a screen already: https://amzn.to/3R4uJzr
  • 1 × microSD card, 32GB+ Kali install. A10/U3-class or better — the panel is I/O-sensitive at boot.
  • 1 × USB-C power supply, 27W PD Pi 5 wants the official 5V/5A brick. Underpowering a Pi 5 with dongles attached produces the worst class of bug: intermittent and blamed on your code.

View all 9 components

  • Four modes are the whole surface. A reject-list checks that from the other side.

    Scottsky8 hours ago 0 comments

    Log 7 closed with a promise: 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 there. This is that log.

    Three questions, not a flag string

    LAN Scan exists to answer three things a bench engineer actually asks: what's alive on this subnet, what's each host running, is this one expected port open. A fourth mode, Identify, adds a best-effort OS guess. That's the entire menu — Discover, Ports, Services, Identify — and there is no fifth control anywhere on the screen that accepts a raw flag. The guard-rail principle is stated plainly in scan.py's own docstring: the UI has to be physically incapable of expressing an offensive scan, not merely discouraged from it by a house rule someone has to remember.

    The builder only knows intents

    build_scan_command(mode, target, ports) takes a mode name, a validated target, and an optional port string, and returns an argument array — never a shell string, so there is nothing downstream to inject into. target goes through ipaddress first (a plain IP or a CIDR passes outright) and falls back to a hostname-label regex; anything else, including a bare flag like -sS or a ; shell separator, is refused before it ever reaches the builder. The validated target is always appended last, which matters for a reason that's easy to miss: nmap can't mistake it for an option, because the validator already guarantees it can't start with -.

    Refusing it anyway

    None of that would matter much if _enforce_rejects() weren't checking the assembled command regardless. It runs an exact-match set against -sC, -sS, -sF, -sX, -sN, -A, -D, -S, -f, -T4, -T5, plus a prefix check for --script, --spoof-mac, --mtu, --data-length — NSE scripting, stealth/evasion scan types, aggressive mode, decoys and MAC spoofing, fragmentation, and evasion-tuned timing, six categories the project's to-do list names by rationale, not just by flag. The matching is case-sensitive on purpose: -sn (Discover), -sT (connect scan) and -sV (version detection) are flags this screen legitimately emits every run, and they must never be confused with the rejected -sN, -sS variants one character away.

    Scope-limiting is a different thing from safety

    Services and Identify don't sweep nmap's default top-1000 ports — they're pinned to a curated COMMON_PORTS list and a 60-second per-host timeout. That's not a guard against offense, it's what makes those two modes actually finish on a /24 instead of grinding for minutes against one unresponsive host. Worth stating separately, because it would be easy to file every constraint in this module under "the safety feature" when some of them are just honest scope management.

    Root is required, never assumed

    Identify needs raw sockets for OS fingerprinting, which needs root. ScanJob checks _is_root() up front and refuses with a plain message, "OS Identify needs root," rather than auto-escalating or letting the scan fail silently partway through. The same posture shows up in Ports and Discover, which default to -sT specifically so the unprivileged case works at all.

    One test per rule, in both directions

    tests/test_scan.py splits cleanly into a positive half and a negative half, the same split log 7 described for the GNSS carve-out. TestAllowedModes locks each of the four modes to its exact argument array — there's a test that fails the moment Discover emits anything but ["nmap", "-sn", target]. TestRejectList runs the opposite direction, one test per rejected flag, plus test_nse_provably_unreachable, which builds all four allowed modes and asserts --script, -sC, and -A show up in none of them, and test_allowed_flags_survive, which proves the screen's own -sn/-sT/-sV never trip the reject-list they sit one letter away from. A reject-list nobody tests is a comment. This one fails the build the day someone "helpfully"...

    Read more »

  • One button transmits. A test proves it's the only one.

    Scottsky09/03/2026 at 20:32 0 comments

    Log 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.py exists 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 same gpsd snapshot 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"

    GnssSourceNode isn'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 → bus when off, GNSS: claiming… mid-handshake, GNSS ■ stop once active, GNSS: no address if 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/gps0 has to exist, and gps.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 to STOPPED_FIX rather 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.py AST-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.py and tests/test_n2k.py are 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...

    Read more »

  • Nobody is coming back to press Stop

    Scottsky08/20/2026 at 19:55 0 comments

    The 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.0 so 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.py runs a small watchdog thread that wakes every 15 s, asks one object how long the app has been idle, and calls os._exit(0) once that passes the --idle-min window — 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 Activity object tracks two things behind a lock: the last touch time, and a count of in-flight jobs. idle_secs() returns a flat 0.0 while any job is running, whatever the clock says. An upload calls job_start() before extraction and job_end() in a finally, so a parse that crashes still releases the counter.

    There is a second, smaller trap in the same place. The Tables tile polls /status every 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 /status is excluded from the touch, by path, in the before_request hook. The observer does not get to change what it observes.

    The unit has to agree

    setup/kilodash-tables.service is Restart=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 as running · 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.

    Scottsky08/14/2026 at 00:20 0 comments

    A launched process is not a serving one

    Starting Kismet or Node-RED is one line: Popen a command, or systemctl start a 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.py doesn't trust the launch call. It trusts a TCP probe against the app's own port. probe() is nothing exotic — socket. create_connection with 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."

    Read more »

  • The day Scottina Prime turned into three documents arguing

    Scottsky08/10/2026 at 02:31 2 comments

    Every 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.

    Read more »

  • Now you see me, now you don't.

    Scottsky08/06/2026 at 17:19 0 comments

    The 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.py polls sysfs instead — cheap, no pyudev dependency, no rule files to keep in sync across a Pi image rebuild. The launcher calls refresh() 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_IDS for the RTL2832U dongles, ALFA_IDS for the Wi-Fi sniff adapter, CANABLE_IDS for candleLight/gs_usb boards, FTDI_IDS for 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_IDS matches 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.py say 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.py using 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.

  • No X server, no mouse — and the panel that made me earn it

    Scottsky07/30/2026 at 17:12 0 comments

    Log 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/fb0 directly.

    Read more »

  • Who said I need all these buttons in my face?

    Scottsky07/26/2026 at 23:50 0 comments

    Starting 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.

    Read more »

View all 8 project logs

  • 1
    Step 1 — Gather the hardware

    Full list with notes is in the Components section. The minimum to get a working panel: Pi 5, the 3.5" ILI9486 screen, a 27W USB-C supply, an active cooler, and a decent microSD. The ALFA, CAN dongle, and SDR are all optional — their tiles simply don't appear until they're plugged in.

    Do not skimp on the power supply. An underpowered Pi 5 with dongles attached produces intermittent faults you will spend a day blaming on software.

  • 2
    Step 2 — Flash Kali and get in over SSH

    Write the Kali Raspberry Pi image to the microSD, enable SSH, and boot the Pi with a network cable and no screen attached. Everything from here is done over SSH — the panel isn't alive yet.

    Update before you touch anything else:

    sudo apt update && sudo apt full-upgrade -y
  • 3
    Step 3 — Bring up the panel

    This is the step that eats afternoons, so it's deliberately minimal. Two lines in /boot/firmware/config.txt:

    dtparam=spi=on
    dtoverlay=piscreen,drm,rotate=90

    Reboot. That's the entire display configuration.

    rotate is the only display setting that requires a reboot — pick 90 or 270 depending on which way up your case holds the panel. Touch orientation is handled in software, so you never come back to this file to fix taps landing in the wrong place.

    Verify the framebuffer exists:

    ls -l /dev/fb0
    cat /sys/class/graphics/fb0/name

    You should see the ILI9486 DRM framebuffer. If /dev/fb0 is missing, stop here — nothing downstream will work.

View all 10 instructions

Enjoy this project?

Share

Discussions

Similar Projects

Does this project spark your interest?

Become a member to follow this project and never miss any updates