Files
jasonwitty ad8ebcaa71 docs: record the LattePanda deployment and the traps it turned up
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>
2026-09-09 14:03:30 -07:00

481 lines
24 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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*: `-1x0` is above `0x0`, `1x0` is below it.
- **col** increases *rightward*: `0x1` is right of `0x0`.
- `0x0` is 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, 1` and rows `-7, 0, 42` behave
identically.
- Within a row, cells sort by column ascending. A horizontal swipe moves to the next
*defined* cell. `0x1` and `0x5` are 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
1. Find the next defined row in that direction. If there is none, do nothing.
2. **If you have been in that row before, go back to exactly where you were** — same
cell, same sub-screen. No calculation.
3. 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 `0x0` screens → 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:
1. `--config <path>`
2. `$XDG_CONFIG_HOME/socktop-swipe/config.yaml` (i.e. `~/.config/socktop-swipe/config.yaml`)
3. `/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
```yaml
# ── 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`, no `libX11-dev`,
no `git clone git.sr.ht/~mil/lisgd`, no `make`. On the Wyse's eMMC that is real.
- **`grab: true` replaces `--xignore`.** `EVIOCGRAB` takes the device exclusively, so X
never sees the touches at all — which is what the `Option "Ignore"` InputClass was
faking. That removes an `/etc/X11/xorg.conf.d` file *and* the logout/login step it
required. The X rule stays documented in `notes/` as a fallback for anyone who wants
touch to reach X for other apps.
- **Compositor-agnostic for free.** Reading `/dev/input` directly 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, and `doctor` can 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.
```sh
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
1. **Preflight** (§6.1). Print the full report, get one confirmation.
2. **Toolchain** — if no cargo: *"Rust is needed to build from source. Install rustup? [Y/n]"*
3. **Build and install** socktop-swipe from source to `/usr/local/bin`.
4. **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.
5. **Optional payload TUIs**, each a separate prompt, all skippable:
- `socktop``cargo install socktop`
- `uptime-kuma-status``cargo install uptime-kuma-status`
- `unifly` — clone `github.com/jasonwitty/unifly`, `cargo build --profile release-small -p unifly`, record the resulting path in `binaries:`
6. **Device access** — udev rule (default) or `input` group.
7. **Autostart?** — i3 / systemd user unit / none.
8. **Autologin?** — lightdm drop-in, with the physical-access warning stated at the
prompt, not buried in the README.
9. **Never blank, never sleep, never lock?** — Xorg `ServerFlags` snippet + `xset` in
the autostart + disable any detected screen locker. (Keeping v1's hard-won note that
`sudo xset` silently targets root's X connection and does nothing.)
10. **Write a starter config** if none exists — one socktop group with a placeholder
profile, commented, and a pointer to `socktop-swipe validate`.
11. **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:
1. **What this is** — one paragraph, then the rack photo and the swipe video.
2. **What you're looking at** — annotated: the panel, the 19" adapter, what is on each
screen and why.
3. **Hardware** — tested table (LattePanda, Wyse 3040), the GeeekPi 9" panel, and a link
across to [`cad/`](../cad) for the adapter that mounts it in a 19" rack.
4. **Quick start** — the curl one-liner and what it will ask you.
5. **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.
6. **The grid** — §2 of this document, with the diagram.
7. **Monitor types** — the §3.3 table.
8. **Configuration reference** — full key table.
9. **Unattended operation** — autologin, blanking, phantom outputs, does it come back
after a power cut.
10. **Troubleshooting** — lead with `socktop-swipe doctor`, then the specific failures.
11. **Uninstall.**
12. **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 35 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.