The rack display has been running v2 since today and passed a physical swipe test and a cold boot, so the hardware table now says what is actually in service and what is merely intended: the Wyse 3040 has not been tried yet and should not read as though it has. notes/HARDWARE-NOTES.md gains the two traps that cost the most time during the deployment, both of which will recur: lightdm autologin fires when a seat starts, not after a logout, so `i3-msg exit` does not test the autostart -- it strands a keyboard-less wall display at a greeter that nobody standing at the rack can get past. Only a real reboot tests the boot path. And `ssh host 'sudo ...'` allocates no TTY, so sudo has nowhere to prompt and fails; chaining the rest of the recovery behind `&&` then swallows it silently. Use `ssh -t`, and `;` for steps that must run regardless. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
24 KiB
socktop-swipe v2 — release plan
Status: implemented and in service, 2026-09-09. Milestones 1-7 done on branch
v2-rust (PR #1); the LattePanda rack display has been running v2 since, and
passed a physical swipe test and a cold boot. Milestone 8, the Wyse 3040 install
following only the README, is the remaining acceptance gate.
Where the built thing differs from this plan, notes/DESIGN.md is authoritative.
v1 is three POSIX shell scripts driving lisgd and tmux. It works and is running on
the rack display today, but it hard-codes a single linear carousel of socktop hosts,
and every new kind of screen is a special case (SOCKTOP_AUX_CMD is the aux screen
because there was nowhere else to put it). v2 makes the layout a first-class,
user-authored thing and rewrites the logic in Rust.
1. Decisions locked in
| Question | Decision |
|---|---|
| Rust scope | One Rust binary owns config, the grid model, gesture input (evdev, replacing lisgd) and drives tmux. tmux stays the pane engine; zellij shelved (§4.3) |
| Grid coordinates | Sparse ordinals — they define order and relative position, not physical slots |
| socktop group | One cell, regardless of host count; expands into a sub-sequence internally |
| Payload TUIs | Installer offers socktop / uptime-kuma-status / unifly, never requires them |
| Distribution | Source only for now. Clone from Gitea, cargo build --release. No prebuilt binaries, no apt repo yet — revisit after feeling the pain on the Wyse |
| v1 config | Clean break. No .env reader, no migrate command. Rack display's YAML gets hand-written once |
| Canonical repo | gt.wittyoneoff.com/jason/socktop-swipe |
2. The grid model
2.1 Coordinates
Every screen declares at: "<row>x<col>".
- row increases downward:
-1x0is above0x0,1x0is below it. - col increases rightward:
0x1is right of0x0. 0x0is where you start.
Coordinates are sparse ordinals. Only the ordering matters:
- Rows sort ascending. A vertical swipe moves to the next defined row in that
direction, not literally
row ± 1. Rows-1, 0, 1and rows-7, 0, 42behave identically. - Within a row, cells sort by column ascending. A horizontal swipe moves to the next
defined cell.
0x1and0x5are interchangeable as long as the ordering is right.
This is the "ezpz for users" property: you never have to count how many screens a socktop group will produce in order to place the thing next to it.
2.2 Horizontal movement
A socktop cell with four hosts is one cell containing a sub-sequence of five screens:
cell 0x0 (socktop, 4 hosts) cell 0x1 (socktop, 2 hosts)
┌───────────────────────────────────┐ ┌──────────────────────┐
│ tiled → host1 → host2 → host3 → h4│→ │ tiled → host1 → host2│
└───────────────────────────────────┘ └──────────────────────┘
Swiping forward walks the sub-sequence, and only leaves the cell after its last sub-screen. Non-socktop cells have a sub-sequence of length 1, so they are entered and left in a single swipe.
Entry direction sets the landing sub-screen:
- entering a cell from the left → its first sub-screen (the tiled overview)
- entering a cell from the right → its last sub-screen
So swiping back left from 0x1 lands you on 0x0's last host, not its overview.
That is what v1 does today and it is what makes the carousel feel continuous.
No wrap-around at either end of a row — same reasoning as v1: on a wall display, wrapping makes it impossible to tell where you are.
2.3 Vertical movement
- Find the next defined row in that direction. If there is none, do nothing.
- If you have been in that row before, go back to exactly where you were — same cell, same sub-screen. No calculation.
- Only if the row has never been visited: go to the cell at the same column if one exists, otherwise snap to the nearest defined column in that row. (Ties break toward the lower column — see open question Q1.)
Return memory always wins over snapping. Each row remembers its last position, and only
a horizontal move within that row updates it. This is what makes "swipe up to unifly,
swipe down, you're back on the same Pi" work — and it holds for wider grids too: from
0x1, up to -1x0, then down returns you to 0x1, not 0x0.
Snapping therefore only runs on the first entry into a row, and a layout where every row has a cell in the same column never runs it at all.
2.4 The default layout, walked through
┌──────────────┐
row -1 │ unifly │ -1x0
└──────────────┘
↕
┌───────────────────────────────────┐ ┌──────────────────────┐
row 0 │ 4 Pis: tiled → each Pi zoomed │→ │ orangepi + trixie │
└───────────────────────────────────┘ └──────────────────────┘
0x0 0x1
↕
┌──────────────┐
row 1 │ uptime-kuma │ 1x0
└──────────────┘
- Swipe up from any of the five
0x0screens → unifly. - Left/right on unifly → nothing; it is the only cell in row -1.
- Swipe down from unifly → back into
0x0, on the exact Pi you left. - Swipe down again → uptime-kuma-status.
From 0x1 (orangepi + trixie) the same holds: up snaps to -1x0 the first time, and
down brings you back to 0x1 on the host you left.
3. Configuration
3.1 Format and location
YAML. Resolution order:
--config <path>$XDG_CONFIG_HOME/socktop-swipe/config.yaml(i.e.~/.config/socktop-swipe/config.yaml)/etc/socktop-swipe/config.yaml
Per-user first, because the display box runs as one dedicated user and that keeps the
file editable without sudo. /etc stays supported so a future .deb has somewhere
sensible to drop a default.
socktop-swipe validate parses the file, resolves the grid, checks every referenced
binary exists, and prints the resulting map. It is the thing to run after every edit,
and the README will say so.
3.2 Schema
# ── how the session is built ────────────────────────────────────────────────
session: socktop-swipe # tmux session name
terminal: alacritty # used only by the autostart the installer writes
# Full paths solve the "i3 has no ~/.cargo/bin on PATH" problem in one place.
# ~ is expanded. Anything already on PATH can be given as a bare name.
binaries:
socktop: ~/.cargo/bin/socktop
uptime-kuma-status: ~/.cargo/bin/uptime-kuma-status
unifly: ~/Documents/GitHub/unifly/target/release-small/unifly
# ── touch panel ─────────────────────────────────────────────────────────────
touch:
device: /dev/input/by-id/usb-ILITEK_ILITEK-TOUCH-event-if00
width: 1280 # the PANEL's resolution, not the X screen
height: 720
grab: true # exclusive grab; see §5
threshold: 80 # px of travel before a drag counts as a swipe
leniency: 30 # degrees off-axis tolerated
fingers: [1, 2, 3] # contact counts accepted
gestures:
forward: RL # right-to-left finger motion moves forward
back: LR
up: DU
down: UD
# ── the grid ────────────────────────────────────────────────────────────────
screens:
- at: -1x0
type: unifly
- at: 0x0
type: socktop
socktop_group: [rpi-master, rpi-worker-1, rpi-worker-2, rpi-worker-3]
layout: tiled
- at: 0x1
type: socktop
socktop_group: "orangepi, trixie" # CSV string also accepted
layout: even-vertical
- at: 1x0
type: uptime-kuma-status
url: https://status.wittyoneoff.com/status/wittyoneoff
3.3 Monitor types
Each type knows how to turn its parameters into one or more tmux panes. Every type
also accepts command: to override the generated command outright, and title: to
override the pane border label — so nobody is ever blocked by a type not yet
supporting a parameter they need.
| Type | Parameters | Panes | Generated command |
|---|---|---|---|
socktop |
socktop_group (list or CSV, required), layout (default tiled) |
one per profile, plus the tiled overview as sub-screen 0 | <socktop> -P <profile> |
uptime-kuma-status |
url |
1 | <uptime-kuma-status> <url> |
unifly |
(none yet) — site/controller carved out for later |
1 | <unifly> tui |
generic |
command (required), title |
1 | as given |
layout accepts the tmux layout names (tiled, even-horizontal, even-vertical,
main-horizontal, main-vertical).
Only socktop produces a multi-screen sub-sequence today. The internal representation
is a plain Vec<Pane> per cell plus an optional overview, so if a future type wants
the same treatment it is a data change, not a redesign.
4. Architecture
One binary, one process at runtime.
src/
main.rs CLI dispatch
config/
mod.rs serde types, defaults, path resolution
coord.rs Coord parsing ("0x-1"), Grid construction, neighbour lookup
grid.rs navigation state machine — pure, no I/O, unit tested
monitor.rs MonitorType → panes + command lines
input.rs evdev reader → Swipe { direction, fingers }
session/mod.rs Multiplexer trait — the ONLY module that knows tmux vs zellij (§4.3)
session/tmux.rs build the session, select window/pane, zoom
doctor.rs diagnostics (replaces tools/diag.sh and tools/find-device.sh)
ui.rs optional on-screen position indicator (§8, cuttable)
4.1 Why this is better than v1 even before the grid
v1 runs lisgd as a daemon which shells out to socktop-swipe next per gesture. That
script has no memory, so on every single swipe it re-derives "where am I" by
interrogating tmux (list-windows, display-message, window_zoomed_flag). v2 holds
the cursor in memory: one gesture becomes two or three tmux calls instead of six, and
the navigation logic becomes a pure function over a struct that can be unit tested
without a display, a touch panel or a tmux server. That test suite is the main reason
the grid model is worth attempting at all.
4.2 CLI
| Command | What it does |
|---|---|
socktop-swipe run |
Build the tmux session, spawn the terminal attached to it, run the gesture loop, exit when the session exits. The single autostart line. |
socktop-swipe daemon |
Gesture loop only, against an existing session (for a systemd-user split) |
socktop-swipe attach |
Build and attach the session only |
socktop-swipe validate |
Parse config, resolve the grid, check binaries, print the map |
socktop-swipe doctor |
Interactive touch diagnostics; list candidate devices; report what the panel actually sends |
socktop-swipe next|back|up|down |
Drive a running instance over its control socket — for keybindings and testing without a panel |
run as one process also kills the v1 double-instance footgun by construction: the
second one fails to grab the device and exits with a clear message.
4.3 Multiplexer: tmux, with zellij shelved
tmux stays. session/ is a Multiplexer trait with a tmux implementation behind
it, so the question can be reopened cheaply, but it is not being evaluated for v2.
Shelved rather than rejected. The reasoning, recorded in notes/DESIGN.md so it is not
re-litigated: "available as a crate" does not buy in-process integration —
zellij-server/zellij-utils are workspace crates for the binary, not a library
surface, so it would still be a subprocess driven over a CLI exactly like tmux. The
blocking issue is addressing: the grid needs "focus cell 0x1, sub-screen 3, zoomed" as
one deterministic call, which tmux gives directly as select-pane -t session:window.3
plus resize-pane -Z, whereas zellij's CLI is direction-oriented (move-focus left).
Footprint also runs the wrong way on 2 GB boxes, and a zellij source build on an Atom is
a much worse story for the install guide than apt install tmux.
The one idea worth keeping on the shelf: running the navigation state machine as a
WASM plugin inside zellij via zellij-tile, which would give real event
subscriptions instead of CLI polling. Revisit only if tmux becomes the bottleneck.
5. Input: evdev instead of lisgd
input.rs opens the event device and reads multitouch protocol B (ABS_MT_SLOT,
ABS_MT_TRACKING_ID, ABS_MT_POSITION_X/Y), tracking per-slot start and end points.
A swipe fires when the dominant axis exceeds threshold, the off-axis angle is within
leniency, and the peak simultaneous contact count is in fingers.
What this buys:
- Drops the C toolchain from the install path. No
libinput-dev, nolibX11-dev, nogit clone git.sr.ht/~mil/lisgd, nomake. On the Wyse's eMMC that is real. grab: truereplaces--xignore.EVIOCGRABtakes the device exclusively, so X never sees the touches at all — which is what theOption "Ignore"InputClass was faking. That removes an/etc/X11/xorg.conf.dfile and the logout/login step it required. The X rule stays documented innotes/as a fallback for anyone who wants touch to reach X for other apps.- Compositor-agnostic for free. Reading
/dev/inputdirectly means nothing in the gesture path is X11-specific. This is not a Wayland port — the terminal and tmux still are what they are — but it removes one of the reasons v1 could not be one. - The
fingers: [1,2,3]workaround becomes honest. Instead of binding three separate lisgd gestures per direction, the peak contact count is just a field on the detected swipe, anddoctorcan print it.
Still required: read access to the device. Installer offers the input group (needs a
relogin) or a udev rule granting the display user access to that one device (no
relogin). Prefer the udev rule and say why.
6. Installer
install.sh stays POSIX sh — it has to run before any Rust exists.
curl -fsSL https://gt.wittyoneoff.com/jason/socktop-swipe/raw/branch/main/install.sh | sh
Piping to sh leaves no stdin for prompts, so the script reads answers from
/dev/tty explicitly. If there is no tty it falls back to defaults and says so.
--yes takes every default non-interactively; --no-<thing> declines individually.
6.1 Dependency preflight
Every dependency is checked before anything is installed, and the whole report is printed at once so you can see the full cost up front rather than discovering it one prompt at a time. Each missing item comes with a concrete offered fix; anything with no known fix on the detected distro is reported as such rather than silently skipped, with the manual step spelled out.
| Checked | How | If missing |
|---|---|---|
| distro + package manager | /etc/os-release, which of apt-get/dnf/pacman/zypper |
unsupported → print manual package list and continue in degraded mode |
tmux (or zellij, per §4.3) |
command -v + version |
offer the distro package |
| terminal emulator | probe alacritty, foot, kitty, xterm |
offer to install one; explain that it must be GPU/GL-capable-ish and legible at panel size |
cargo / rustup |
command -v cargo |
offer rustup, stating the disk cost (~1.2 GB toolchain + target dir) before asking |
| C toolchain | only if something still needs building | offer build-essential equivalent |
| window manager | probe i3, detect a running WM | needed only for the --i3-equivalent autostart; skip that step cleanly if absent |
| display manager | /etc/lightdm, /etc/gdm3, /etc/sddm.conf |
autologin is only offered for the one detected; unknown DM → print the manual snippet |
| screen locker | probe xss-lock, light-locker, i3lock, xscreensaver |
offer to disable, naming the specific unit or process found |
| touch device | enumerate /dev/input/by-id/*event* with touchscreen capabilities |
none found → point at socktop-swipe doctor and stop before writing a broken config |
| device read access | try opening the chosen device | offer udev rule (default) or input group |
git |
command -v |
needed only for the unifly source build |
| disk space | df on /usr/local, $HOME, and the cargo target location |
warn before starting a build that will not fit — this is the Wyse's 8 GB eMMC case specifically |
The report ends with a single summary — "3 things will be installed, 2 files changed, ~1.4 GB of disk used. Continue? [y/N]" — and nothing before that point has modified the system.
6.2 Flow
- Preflight (§6.1). Print the full report, get one confirmation.
- Toolchain — if no cargo: "Rust is needed to build from source. Install rustup? [Y/n]"
- Build and install socktop-swipe from source to
/usr/local/bin. - Touch panel — enumerate
/dev/input/by-id/*touch devices, show them, ask which, write it into the config. Offer a live confirm ("swipe now") before committing. - Optional payload TUIs, each a separate prompt, all skippable:
socktop—cargo install socktopuptime-kuma-status—cargo install uptime-kuma-statusunifly— clonegithub.com/jasonwitty/unifly,cargo build --profile release-small -p unifly, record the resulting path inbinaries:
- Device access — udev rule (default) or
inputgroup. - Autostart? — i3 / systemd user unit / none.
- Autologin? — lightdm drop-in, with the physical-access warning stated at the prompt, not buried in the README.
- Never blank, never sleep, never lock? — Xorg
ServerFlagssnippet +xsetin the autostart + disable any detected screen locker. (Keeping v1's hard-won note thatsudo xsetsilently targets root's X connection and does nothing.) - Write a starter config if none exists — one socktop group with a placeholder
profile, commented, and a pointer to
socktop-swipe validate. - Print exactly what changed, file by file, and what to do next.
uninstall.sh reverses each of the above and, as in v1, prints what it deliberately
left alone.
Cheap future-proofing: add [package.metadata.deb] to Cargo.toml now (paths
/usr/bin, /etc/socktop-swipe/, /lib/udev/rules.d/). It costs about fifteen lines
and means the apt-repo follow-up is cargo deb rather than a packaging project.
7. README rewrite
Written for someone who has just found the repo and does not know what socktop is.
Current README leads with mechanism ("the carousel is tmux pane zoom, not extra
instances") — correct, and it moves to notes/DESIGN.md.
New order:
- What this is — one paragraph, then the rack photo and the swipe video.
- What you're looking at — annotated: the panel, the 19" adapter, what is on each screen and why.
- Hardware — tested table (LattePanda, Wyse 3040), the GeeekPi 9" panel, and a link
across to
cad/for the adapter that mounts it in a 19" rack. - Quick start — the curl one-liner and what it will ask you.
- Your first config — build it up: one socktop group → add unifly above → add uptime-kuma above/below. Each step shows the YAML and the resulting map.
- The grid — §2 of this document, with the diagram.
- Monitor types — the §3.3 table.
- Configuration reference — full key table.
- Unattended operation — autologin, blanking, phantom outputs, does it come back after a power cut.
- Troubleshooting — lead with
socktop-swipe doctor, then the specific failures. - Uninstall.
- Related projects — socktop, uptime-kuma-status, unifly, Uptime Kuma.
The Wyse 3040 install is done by following this README and nothing else, and every place it is wrong or incomplete is a bug fixed before release.
8. Wayfinding indicator (proposed, cuttable)
A 2D grid is harder to stay oriented in than v1's single line, and there is no wrap to
tell you you have reached an end. Proposal: a small persistent indicator in the tmux
status line or pane border showing position, e.g. unifly ▲ · rpi-worker-2 (3/5) · ▼ kuma
— the current screen plus what is above and below. Off by default, indicator: true
to enable. Flagging it as a distinct decision rather than assuming it.
9. Repo layout after v2
socktop-swipe/
├── README.md rewritten (§7)
├── Cargo.toml / Cargo.lock
├── src/ §4
├── config.example.yaml the default rack layout, heavily commented
├── install.sh uninstall.sh
├── packaging/
│ ├── socktop-swipe.service systemd user unit
│ └── 70-socktop-swipe.rules udev
├── cad/ unchanged
├── media/ unchanged
└── notes/
├── PLAN-v2.md this document
├── DESIGN.md why tmux, why evdev, the grid model's reasoning
├── HARDWARE-NOTES.md ILITEK contact counts, phantom DSI-1, the sudo-xset
│ trap, multi-monitor SCREEN_W, LattePanda specifics,
│ and: screen size and rack size are separate measurements
│ (9" screen, 10" mini-rack mount, 19" rack via the adapter)
├── V1-BASH.md what v1 did and where to find it (tag v1.2)
└── TODO.md
Tag the current tree v1.2 before deleting the shell scripts, so the working bash
version stays recoverable.
10. Work order
Each milestone ends somewhere testable.
| # | Milestone | Done when |
|---|---|---|
| 1 | Tag v1.2. Scaffold the crate. Config types + coord.rs + validate |
socktop-swipe validate prints the rack layout map from YAML |
| 2 | grid.rs navigation state machine, no I/O |
Unit tests cover: sparse columns, sub-sequence entry from left vs right, vertical snap, cursor persistence, both ends of every row |
| 3 | session/tmux.rs + attach |
Session builds correctly from YAML on the desktop; drive it with next/back/up/down by hand — no touch panel needed |
| 4 | input.rs evdev + doctor |
doctor correctly reports direction and contact count on the LattePanda's ILITEK panel |
| 5 | run wires it together |
LattePanda runs the full default grid by touch, unifly above and kuma below |
| 6 | Installer rewrite | Clean-VM install works, prompts behave when piped from curl, --yes works |
| 7 | README + notes/ split | Reads correctly to someone who has not seen the project |
| 8 | Wyse 3040 validation | Debian minimal → working rack display, following only the README. Every stumble is a fix, then re-run |
| 9 | Release | Tag v2.0, config.example.yaml matches the rack, cargo clippy clean with no #[allow] |
Milestone 5 is the point at which the LattePanda switches over. Its v1 install stays untouched until then — v2 uses a different config path and a different tmux session name, so both can be installed side by side during milestone 3–5 testing.
11. Open questions
Q1 — column tie-break on snap. From 0x2, if the row above has cells at -1x1
and -1x3, both are distance 1. Plan says lower column (left) wins. Fine? This only
ever fires on the first entry into a row, so it is low-stakes.
Q2 — the indicator (§8). Build it, or cut it from v2?
Q3 — repo visibility. Distribution is Gitea-source-only for now, which is settled. But the README is being written for a public audience and the Wyse is reference hardware for a public guide. Is the Gitea repo publicly readable without a login? If not, the curl one-liner in the README will not work for anyone but you.