diff --git a/.github/workflows/armbian-builder.yaml b/.github/workflows/armbian-builder.yaml index ceece9a..38e3a47 100644 --- a/.github/workflows/armbian-builder.yaml +++ b/.github/workflows/armbian-builder.yaml @@ -283,7 +283,14 @@ on: jobs: build: - runs-on: ubuntu-latest + # Native arm64 runner -- the images we build are arm64, and Packer's + # arm-image plugin skips its QEMU/binfmt chroot steps entirely when + # image_arch (set in dpx-buttonode.pkr.hcl) matches the host's actual + # runtime arch (pkg/builder/builder.go: `!ImageArch.IsNative()`). On + # ubuntu-latest (x86_64), every apt-get/dpkg inside the chroot ran + # through qemu-aarch64-static emulation, which is commonly 5-10x + # slower than native for that kind of CPU-bound work. + runs-on: ubuntu-24.04-arm permissions: contents: read outputs: @@ -294,7 +301,15 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 - # ARM emulation is required to chroot into the ARM64 Armbian image + # Still needed even on a native arm64 runner: the plugin's Prepare() + # step unconditionally resolves a qemu_binary path via exec.LookPath + # regardless of whether it'll actually be used later -- only its + # separate Run()-time IsNative() check decides whether QEMU is + # actually invoked for the chroot. Without this installed, Prepare() + # falls back to an "embedded qemu" feature that's amd64-only and + # fails outright on arm64 (confirmed: "embedded qemu is not + # available - currently, embedded qemu is only available for linux + # amd64"). This is just a fast package install, not the slow part. - name: Install QEMU user-static run: sudo apt-get update -q && sudo apt-get install -y qemu-user-static diff --git a/.github/workflows/artifact-sweep.yaml b/.github/workflows/artifact-sweep.yaml new file mode 100644 index 0000000..78ca285 --- /dev/null +++ b/.github/workflows/artifact-sweep.yaml @@ -0,0 +1,35 @@ +name: Sweep expired Actions artifacts + +# Safety net for issue #18: release-action.yaml's own artifacts are +# deleted immediately once they land in a release, but stray builds that +# never go through that job (feature branches, force-rebuilds, manual +# workflow_dispatch runs someone kicked off and forgot about) just sit +# there. GitHub's own retention-days cleanup can lag by weeks in +# practice -- confirmed 2026-09-05, 12.1GiB of artifacts sitting around +# up to three weeks past their own expiry -- so this sweeps anything +# already past expires_at rather than trusting GitHub to do it. +on: + schedule: + - cron: '0 5 * * 0' # weekly, Sunday 05:00 UTC + workflow_dispatch: {} + +jobs: + sweep: + runs-on: ubuntu-latest + permissions: + actions: write + steps: + - name: Delete artifacts past their own expiry + env: + GH_TOKEN: ${{ github.token }} + run: | + NOW=$(date -u +%s) + gh api "repos/${{ github.repository }}/actions/artifacts" --paginate \ + --jq '.artifacts[] | [.id, .expires_at] | @tsv' | while IFS=$'\t' read -r id expires_at; do + [[ -z "$expires_at" || "$expires_at" == "null" ]] && continue + expires_epoch=$(date -u -d "$expires_at" +%s 2>/dev/null || echo 0) + if (( expires_epoch > 0 && expires_epoch < NOW )); then + echo "Deleting expired artifact $id (expired $expires_at)" + gh api -X DELETE "repos/${{ github.repository }}/actions/artifacts/$id" || true + fi + done diff --git a/.github/workflows/raspios-builder.yaml b/.github/workflows/raspios-builder.yaml index 459e766..9e548cb 100644 --- a/.github/workflows/raspios-builder.yaml +++ b/.github/workflows/raspios-builder.yaml @@ -41,7 +41,12 @@ on: jobs: build: - runs-on: ubuntu-latest + # See armbian-builder.yaml's build job for why: native arm64 runner + # lets Packer's arm-image plugin skip QEMU/binfmt entirely + # (image_arch in dpx-buttonode.pkr.hcl matches the host arch), instead + # of emulating every chroot apt-get/dpkg call via qemu-aarch64-static + # on an x86_64 runner. + runs-on: ubuntu-24.04-arm permissions: contents: read outputs: @@ -52,8 +57,9 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 - # ARM emulation is required to chroot into the ARM64 image, same as - # the Armbian pipeline -- unrelated to which OS built the base image. + # Still needed on a native arm64 runner -- see armbian-builder.yaml's + # build job for why (Prepare()-time qemu_binary resolution is + # unconditional, only actual usage is skipped on native arch). - name: Install QEMU user-static run: sudo apt-get update -q && sudo apt-get install -y qemu-user-static diff --git a/.github/workflows/release-action.yaml b/.github/workflows/release-action.yaml index 2da3c9c..3161a14 100644 --- a/.github/workflows/release-action.yaml +++ b/.github/workflows/release-action.yaml @@ -139,6 +139,7 @@ jobs: runs-on: ubuntu-latest permissions: contents: write + actions: write steps: - name: Checkout repository @@ -180,3 +181,20 @@ jobs: --notes-file /tmp/release-notes.md \ release-assets/*.img.gz \ /tmp/buttons-version.txt + + # Once the images are attached to the release as real assets, the + # raw CI artifacts this job downloaded from `build` serve no purpose + # -- delete them immediately rather than let retention-days expire + # them on GitHub's own timeline (issue #18: found 12.1GiB of already- + # expired-but-uncollected artifacts sitting around, silently + # billing against the account's Actions storage cap). + - name: Delete this run's CI artifacts (now redundant with the release) + if: success() + env: + GH_TOKEN: ${{ github.token }} + run: | + gh api "repos/${{ github.repository }}/actions/runs/${{ github.run_id }}/artifacts" \ + --jq '.artifacts[].id' | while read -r id; do + echo "Deleting artifact $id" + gh api -X DELETE "repos/${{ github.repository }}/actions/artifacts/$id" || true + done diff --git a/ACTION-PLAN.md b/ACTION-PLAN.md index a3e7c42..636b2a0 100644 --- a/ACTION-PLAN.md +++ b/ACTION-PLAN.md @@ -111,6 +111,13 @@ falling back to splash only if none is." Solving that cleanly handles both the the right thing should win automatically" case (#12) with one mechanism instead of two bolted-on fixes that could disagree with each other. +**Implemented 2026-09-05**, in `scripts/install-deck-splash.sh`: +- `OnFailure=dpx-deck-splash.service` drop-ins (`/etc/systemd/system/.service.d/dpx-recovery.conf`) on all three mode units. Drop-ins, not direct edits, since all three ship from vendor `.deb`s, not this repo — survives a package upgrade. +- `dpx-mode-select.service` (new oneshot, `WantedBy=multi-user.target`): reads `/etc/dpx-mode` at boot and starts exactly that one mode service, falling back to `dpx-deck-splash.service` if nothing's persisted or the target refuses to start. +- `dpx-deck-splash.service` no longer auto-enabled — it's only ever started by the fallback above or by an `OnFailure` recovery, never racing the mode service for `multi-user.target` on its own. +- **Not yet live-verified** (no device access this pass) — needs a real boot-cycle test: confirm the persisted mode wins every time, and force a mode service into permanent failure (e.g. `systemctl kill` past its restart burst) to confirm splash actually comes back. +- Also worth re-checking against this fix once live: the reported "device is already in a mode but not started, hitting GO does not start the thing" symptom. `execute_staged()`'s `mode_dead` check in `dpx-deck-splash.py` already looks correct on paper (re-applies if the persisted mode's service isn't actually active) — this may already have been a downstream effect of the same boot race rather than a separate bug. Confirm rather than assume once testable. + Dashboard's own boot-time auto-start (the "and dashboard on/off" half of #12) is simpler and independent of the above — it's just "should `dpx-dashboard.service` be enabled or not," already a persisted systemd state via `set_dashboard_enabled()`, @@ -128,3 +135,41 @@ new for it. 5. #11 + #12 together (the real design work — biggest single piece here) 6. #17's "doesn't launch" half — verify once a fresh build exists (falls out of #11/#12 work naturally, since that's a rebuild anyway) 7. #10 and #17's "doesn't reflect state" half — both need live device access, batch them into one SSH session once available + +--- + +## #18 — GitHub Actions artifact storage cleanup (housekeeping) + +**2026-09-05: found and fixed once.** `gh api repos/.../actions/artifacts` showed +10 artifacts totaling 12.1 GiB, ALL already past their `retention-days: 3` +expiration (the oldest by three weeks) but never garbage-collected by GitHub — +they were still billing against the 2GB storage cap the whole time. Deleted +manually via `gh api -X DELETE .../actions/artifacts/`, storage now at 0. + +Not a one-time cleanup — this will silently refill: `release-action.yaml`'s +nightly cron (`0 6 * * *`) builds new images whenever the Buttons mirror has an +unreleased version, `armbian-builder.yaml`/`raspios-builder.yaml` upload +1-1.7GB artifacts per board/variant, and the `release` job downloads them into +a GitHub Release but never deletes the source CI artifacts afterward — nothing +sweeps them once `retention-days` lapses, they just sit there until someone +notices. + +**Squash it down properly, don't just re-delete manually next time:** +- Add a step at the end of `release-action.yaml`'s `release` job (after the + release is successfully created) that deletes the just-downloaded build + artifacts immediately via `gh api -X DELETE` — once they're in the + release as `.img.gz` assets, the raw CI artifacts serve no purpose. +- Consider a small separate scheduled workflow (e.g. weekly) that lists and + deletes any artifact past its `expires_at`, as a safety net for stray + manual/debug-branch builds (feature branches, force-rebuilds) that don't + go through the release job at all. +- Low priority relative to #10-#17, but cheap to build once — fold into + the work whenever convenient, or do it standalone. + +**Implemented 2026-09-05**: `release-action.yaml`'s `release` job now deletes +its own run's CI artifacts immediately after the release is created +(`actions: write` added to its permissions). New `artifact-sweep.yaml` +workflow runs weekly (Sunday 05:00 UTC) plus `workflow_dispatch`, deleting +any artifact anywhere in the repo already past its own `expires_at` — the +same category of already-expired-but-uncollected artifact found and +manually cleared this session. diff --git a/dpx-buttonode.pkr.hcl b/dpx-buttonode.pkr.hcl index a7a46c3..b309098 100644 --- a/dpx-buttonode.pkr.hcl +++ b/dpx-buttonode.pkr.hcl @@ -73,6 +73,7 @@ source "arm-image" "base" { iso_url = var.url target_image_size = var.variant == "full" ? 8000000000 : 5000000000 output_filename = "output-dpx-buttonode/dpx-buttonode.img" + image_arch = "arm64" qemu_binary = "qemu-aarch64-static" image_mounts = var.image_mounts diff --git a/images/009_deck_splash.png b/images/009_deck_splash.png deleted file mode 100644 index aceafc3..0000000 Binary files a/images/009_deck_splash.png and /dev/null differ diff --git a/scripts/install-dashboard.sh b/scripts/install-dashboard.sh index cd75d70..f997ed4 100755 --- a/scripts/install-dashboard.sh +++ b/scripts/install-dashboard.sh @@ -86,7 +86,7 @@ xset s off xset s noblank unclutter -idle 0.5 -root & openbox-session & -exec companion-dashboard --kiosk --no-sandbox +exec companion-dashboard --kiosk-mode --no-sandbox XINITRC chmod +x "$DASH_HOME/.xinitrc" @@ -113,7 +113,7 @@ chown -R dpx-dashboard:dpx-dashboard "$DASH_HOME" cat > /etc/systemd/system/dpx-dashboard.service << 'UNIT' [Unit] Description=Companion Dashboard Display Service -After=network-online.target graphical.target +After=network-online.target Wants=network-online.target [Service] @@ -129,7 +129,7 @@ StandardOutput=journal StandardError=journal [Install] -WantedBy=graphical.target +WantedBy=multi-user.target UNIT systemctl daemon-reload diff --git a/scripts/install-deck-splash.sh b/scripts/install-deck-splash.sh index 712bbda..c67e75e 100755 --- a/scripts/install-deck-splash.sh +++ b/scripts/install-deck-splash.sh @@ -105,8 +105,81 @@ KillMode=process WantedBy=multi-user.target UNIT -systemctl enable dpx-deck-splash.service -echo "==> dpx-deck-splash.service: enabled" +# NOT enabled directly. dpx-mode-select.service (below) is now the only +# thing that starts this at boot -- only as the no-persisted-mode +# fallback -- instead of both it and the current mode service racing +# multi-user.target with Conflicts= picking whichever happens to win +# (dpx#12, confirmed nondeterministic on hardware). The [Install] block +# stays so `systemctl enable dpx-deck-splash.service` still works for +# anyone who wants the old always-auto-start behavior back. +echo "==> dpx-deck-splash.service: installed (started via dpx-mode-select.service, not auto-enabled)" + +# ── Recovery: bring the splash back if a mode service dies for good ──────── +# OnFailure= only fires when a unit's ActiveState actually reaches +# "failed" -- with Restart=on-failure, systemd holds the unit in +# "activating (auto-restart)" between individual retry attempts, and +# only lands in "failed" once StartLimitBurst is exhausted. So this +# fires once per real, permanent outage, not once per transient restart +# (dpx#11 -- "what's not clear is when the splash comes back"). Purely +# event-driven, no polling loop. +# +# Drop-ins, not edits to the vendor unit files themselves -- all three +# mode services ship from their own .deb packages (Buttons/Satellite/ +# Companion), not this repo, and a drop-in survives a package upgrade +# that a direct edit wouldn't. +for MODE_UNIT in bitfocus-buttons-usb-relay.service satellite.service companion.service; do + mkdir -p "/etc/systemd/system/${MODE_UNIT}.d" + cat > "/etc/systemd/system/${MODE_UNIT}.d/dpx-recovery.conf" << 'UNIT' +[Unit] +OnFailure=dpx-deck-splash.service +UNIT +done +echo "==> OnFailure=dpx-deck-splash.service drop-ins installed for all 3 mode services" + +# ── Boot-time mode selection: exactly one of {persisted mode, splash} ────── +# The other half of dpx#12/dpx#11: decide once, at boot, which single +# thing should run instead of leaving it to a Conflicts= race. Reads +# /etc/dpx-mode (same file switch_mode() in dpx-buttonode-ui.py writes) +# and starts that mode's service; falls back to the splash if nothing's +# persisted or the target service refuses to start. Mirrors +# get_dpx_mode()'s own "buttons" default for consistency. +cat > /usr/local/bin/dpx-mode-select.sh << 'SCRIPT' +#!/usr/bin/env bash +set -u +# Existence-based toggle, same convention as /var/lib/dpx-hostname-set -- +# absent (the default on a fresh image) means autostart is ON, matching +# the web UI's Mode tab checkbox defaulting to checked. +if [ -e /var/lib/dpx-mode-autostart-disabled ]; then + systemctl start dpx-deck-splash.service + exit 0 +fi +MODE="$(cat /etc/dpx-mode 2>/dev/null || echo "buttons")" +case "$MODE" in + buttons) SVC="bitfocus-buttons-usb-relay.service" ;; + satellite) SVC="satellite.service" ;; + companion) SVC="companion.service" ;; + *) SVC="bitfocus-buttons-usb-relay.service" ;; +esac +systemctl start "$SVC" || systemctl start dpx-deck-splash.service +SCRIPT +chmod +x /usr/local/bin/dpx-mode-select.sh + +cat > /etc/systemd/system/dpx-mode-select.service << 'UNIT' +[Unit] +Description=Start the persisted dpx-buttonode mode (fallback: deck splash) +Documentation=https://github.com/dubpixel/dpx_buttonode +After=dpx-set-hostname.service + +[Service] +Type=oneshot +ExecStart=/usr/local/bin/dpx-mode-select.sh + +[Install] +WantedBy=multi-user.target +UNIT + +systemctl enable dpx-mode-select.service +echo "==> dpx-mode-select.service: enabled" # ── sudoers: the ONLY door from dpx-splash (buttons group, nothing else) # to actually changing system state ───────────────────────────────────────── diff --git a/src/dpx-buttonode-ui/dpx-buttonode-ui.py b/src/dpx-buttonode-ui/dpx-buttonode-ui.py index 84bb7d3..65470db 100755 --- a/src/dpx-buttonode-ui/dpx-buttonode-ui.py +++ b/src/dpx-buttonode-ui/dpx-buttonode-ui.py @@ -279,6 +279,58 @@ def write_networkd_config(iface, mode, ip_cidr=None, gateway=None, dns="8.8.8.8" "systemctl", "restart", "dpx-buttonode-ui"]) +def write_nmcli_config(iface, mode, ip_cidr=None, gateway=None, dns="8.8.8.8"): + """Apply network config through NetworkManager. `nmcli connection + modify` writes the change straight to the connection's on-disk + profile (/etc/NetworkManager/system-connections/*.nmconnection), so + unlike the networkd path there's no separate config file to manage — + the same command that applies it live is what makes it persist.""" + out, _, _ = run(["nmcli", "-t", "-f", "NAME,TYPE", "connection", "show", "--active"]) + conn = "" + for line in out.splitlines(): + parts = line.split(":") + if len(parts) >= 2 and "ethernet" in parts[1].lower(): + conn = parts[0] + break + if not conn: + return + if mode == "dhcp": + run(["nmcli", "connection", "modify", conn, + "ipv4.method", "auto", + "ipv4.addresses", "", + "ipv4.gateway", "", + "ipv4.dns", ""]) + else: + run(["nmcli", "connection", "modify", conn, + "ipv4.method", "manual", + "ipv4.addresses", ip_cidr, + "ipv4.gateway", gateway, + "ipv4.dns", dns]) + run(["nmcli", "connection", "up", conn]) + run(["systemctl", "reload-or-restart", "avahi-daemon"]) + active_svc = { + "buttons": "bitfocus-buttons-usb-relay", + "satellite": "satellite", + "companion": "companion", + }.get(get_dpx_mode(), "bitfocus-buttons-usb-relay") + run(["systemctl", "restart", active_svc]) + run(["systemd-run", "--no-block", "--quiet", + "systemctl", "restart", "dpx-buttonode-ui"]) + + +def apply_net_config(iface, mode, ip_cidr=None, gateway=None, dns="8.8.8.8"): + """Persist network config through whichever backend actually manages + this interface. Raspberry Pi OS defaults to NetworkManager; Armbian + defaults to systemd-networkd/Netplan. Writing networkd files on an + nmcli-managed box doesn't survive reboot — NetworkManager reasserts + its own connection profile on boot, reverting straight back to DHCP + (dpx#14) — so the two paths need picking, not just one used blindly.""" + if nmcli_available(): + write_nmcli_config(iface, mode, ip_cidr, gateway, dns) + else: + write_networkd_config(iface, mode, ip_cidr, gateway, dns) + + def toggle_net(): """Flip DHCP<->static. No argument needed — a caller with no way to type an address (a deck keypress) should have nothing to get wrong. @@ -301,9 +353,9 @@ def toggle_net(): if current["mode"] == "dhcp": if not current.get("gateway"): return False, "No gateway detected — can't safely pin a static config" - write_networkd_config(iface, "static", current["ip_cidr"], current["gateway"], current["dns"]) + apply_net_config(iface, "static", current["ip_cidr"], current["gateway"], current["dns"]) return True, f"Pinned static {current['ip_cidr']}" - write_networkd_config(iface, "dhcp") + apply_net_config(iface, "dhcp") return True, "Switched to DHCP" @@ -334,7 +386,7 @@ def pin_static(cidr_str): else: prefix = current["ip_cidr"].split("/")[-1] if "/" in current["ip_cidr"] else "24" ip_cidr = f"{ip_str}/{prefix}" - write_networkd_config(iface, "static", ip_cidr, current["gateway"], current["dns"]) + apply_net_config(iface, "static", ip_cidr, current["gateway"], current["dns"]) return True, f"Pinned static {ip_cidr}" @@ -644,12 +696,19 @@ def render_status(alert="", alert_cls="a-ok"): {svc_label} {mode_detail}""" + # Only shown when Dashboard was actually installed on this image (#20) + dashboard_card = "" + if dashboard_installed(): + dash_on = dashboard_enabled() + dashboard_card = f"""
Dashboard
+
{'active' if dash_on else 'inactive'}
""" + grid = f"""
Hostname
{host}
-
IP Address
-
{ip}
+
IP Address
+
{ip}
MAC
{mac}
Network
@@ -661,6 +720,7 @@ def render_status(alert="", alert_cls="a-ok"):
{uptime}
RAM
{esc(ram_str)}
+{dashboard_card}

USB Devices

    @@ -837,6 +897,7 @@ def dashboard_section(): {'
    ' if on else ''} + {f'⚙ Remote Config ↗' if on else ''}
""" @@ -974,16 +1035,26 @@ def switch_mode(new_mode): run(["systemctl", "stop", old_svc]) run(["systemctl", "disable", old_svc]) run(["systemctl", "enable", new_svc]) - # Nudge udev before handing the deck to any HID-consuming mode. - # Confirmed live 2026-08-29: heavy mode-switch churn can leave the - # kernel holding the Stream Deck bound but with its /dev/hidraw* node - # missing -- invisible to libusb-based consumers (Satellite, this - # process itself) but fatal to Companion's hidraw-only surface - # driver. Previously only fixed by manually hitting /power-cycle-deck - # after the fact; baking it into every switch means it's already - # fixed by the time the new mode's service starts, not something - # that has to be noticed and triggered separately. - udev_retrigger() + # Recover hidraw before handing the deck to any HID-consuming mode. + # Confirmed live 2026-09-06 (issue #10): a libusb consumer (Buttons/ + # Satellite/deck-splash) detaching the kernel driver to claim the + # device removes /dev/hidraw* until a real USB unbind/bind -- the + # gentle udev_retrigger() alone does NOT bring it back (verified: ran + # it in isolation, hidraw stayed missing). Companion's surface module + # only scans for hidraw devices once at startup, so if it's missing + # right then, Companion silently finds nothing and never retries -- + # this was the actual root cause of "Companion doesn't pick up the + # Stream Deck after a mode switch," not a permissions or timing issue. + # usb_power_cycle() already tries the gentle retrigger first and only + # escalates to the disruptive unbind/bind if that alone wasn't enough + # (see its docstring), so this is a safe drop-in -- previously that + # full fallback was only reachable manually via /power-cycle-deck, + # never from the mode-switch path itself. + deck_path = find_streamdeck_usb_path() + if deck_path: + usb_power_cycle(deck_path) + else: + udev_retrigger() _, err, rc = run(["systemctl", "start", new_svc]) if rc != 0: return False, f"Failed to start {new_svc}: {err}" @@ -992,6 +1063,50 @@ def switch_mode(new_mode): return True, f"Switched to {LABELS[new_mode]}" +def stop_current_mode(): + """Stop whichever mode service is currently running and show the deck + splash instead -- a pure 'go idle' action, deliberately distinct from + switch_mode(): it does NOT touch /etc/dpx-mode or enable/disable + anything, so the persisted mode is unchanged and a later GO press (or + a reboot, via dpx-mode-select.service) still resumes it. Since + dpx-mode-select.service only runs once at boot, stopping a mode + service manually would otherwise leave the deck dark with nothing to + bring splash back -- this starts it explicitly instead of relying on + that boot-time-only coordinator.""" + SVC_MAP = { + "buttons": "bitfocus-buttons-usb-relay", + "satellite": "satellite", + "companion": "companion", + } + svc = SVC_MAP.get(get_dpx_mode(), "bitfocus-buttons-usb-relay") + run(["systemctl", "stop", svc]) + run(["systemctl", "start", "dpx-deck-splash"]) + return True, "Stopped -- deck splash active" + + +MODE_AUTOSTART_MARKER = "/var/lib/dpx-mode-autostart-disabled" + + +def mode_autostart_enabled(): + """True unless the marker file is present -- absent (the default on a + fresh image) means dpx-mode-select.service starts the persisted mode + at boot, matching the Mode tab checkbox defaulting to checked.""" + return not Path(MODE_AUTOSTART_MARKER).exists() + + +def set_mode_autostart_enabled(enable): + """Toggle whether dpx-mode-select.service starts the persisted mode + at boot, or always falls back to the deck splash instead. Requested + directly: some setups want to land on splash every boot and switch + modes manually rather than auto-resuming.""" + marker = Path(MODE_AUTOSTART_MARKER) + if enable: + marker.unlink(missing_ok=True) + else: + marker.parent.mkdir(parents=True, exist_ok=True) + marker.touch() + + # ── SSH management ─────────────────────────────────────────────────────────── # # Ships with SSH DISABLED by default (see dpx-buttonode.pkr.hcl) — this is @@ -1544,12 +1659,29 @@ def mode_btn(target, label, active): f'Companion (Full only)', ]) + any_svc_active = bs or ss or (cs and has_companion) + stop_btn = ( + f'
' + f'
' + if any_svc_active else "" + ) + companion_link = ( f'

Companion web UI: ' f'http://{esc(ip)}:{COMPANION_PORT}

' if mode == "companion" and cs else "" ) + autostart_on = mode_autostart_enabled() + autostart_toggle = f""" +
+ + +
""" + bs_badge = 'active' if bs else 'inactive' ss_badge = 'active' if ss else 'inactive' cs_badge = ('active' if cs else 'inactive') if has_companion else 'not installed' @@ -1561,8 +1693,9 @@ def mode_btn(target, label, active): padding:18px 20px;margin-bottom:16px">
{badge_text}
/etc/dpx-mode = {esc(mode)}
-
{btns}
+
{btns}{stop_btn}
{companion_link} + {autostart_toggle}
@@ -2048,6 +2181,24 @@ def _apply(): alert_cls="a-ok" if ok else "a-err", )) + # ── /mode/stop ──────────────────────────────────────────────────── + elif path == "/mode/stop": + ok, msg = stop_current_mode() + self.html(render_mode( + alert=("✓ " if ok else "✗ ") + esc(msg), + alert_cls="a-ok" if ok else "a-err", + )) + + # ── /mode/autostart ────────────────────────────────────────────── + elif path == "/mode/autostart": + # Unchecked checkboxes simply omit the field from the POST body + enable = params.get("enabled", "") == "1" + set_mode_autostart_enabled(enable) + self.html(render_mode( + alert="✓ " + ("Autostart enabled" if enable else "Autostart disabled -- will always land on splash"), + alert_cls="a-ok", + )) + # ── /satellite-config ────────────────────────────────────────── elif path == "/satellite-config": host = params.get("host", "").strip() diff --git a/src/dpx-deck-splash/dpx-deck-splash.py b/src/dpx-deck-splash/dpx-deck-splash.py index 058f91f..83495c3 100644 --- a/src/dpx-deck-splash/dpx-deck-splash.py +++ b/src/dpx-deck-splash/dpx-deck-splash.py @@ -298,6 +298,39 @@ def render_key(deck, text, font_size=16, bg=(0, 0, 0), fg="white"): return PILHelper.to_native_key_format(deck, image) +def render_password_key(deck, password, bg=(0, 0, 0), fg="white", chunk=4): + """Like render_key(), but wraps `password` into fixed-width chunks + (default 4 chars) on separate stacked lines instead of shrinking one + line to fit the whole string. A 10-char password on a single line + shrinks small enough that similar-looking characters (5 vs S, 0 vs O) + become genuinely hard to tell apart on the deck's tiny screen -- + confirmed live 2026-09-06, misread as a transcription error while + reading it off. Wrapping means each line only has to fit `chunk` + characters, so the font can stay much larger.""" + image = PILHelper.create_key_image(deck) + draw = ImageDraw.Draw(image) + if bg != (0, 0, 0): + draw.rectangle([(0, 0), image.size], fill=bg) + lines = [password[i:i + chunk] for i in range(0, len(password), chunk)] + margin = image.width * 0.12 + size = 24 + while size > 7: + font = load_font(size) + widths = [draw.textbbox((0, 0), line, font=font)[2] for line in lines] + line_h = draw.textbbox((0, 0), "Ag", font=font)[3] + total_h = line_h * len(lines) + if max(widths) <= image.width - margin and total_h <= image.height - margin: + break + size -= 1 + y = (image.height - line_h * len(lines)) / 2 + for line in lines: + bbox = draw.textbbox((0, 0), line, font=font) + w = bbox[2] - bbox[0] + draw.text(((image.width - w) / 2, y), line, font=font, fill=fg) + y += line_h + return PILHelper.to_native_key_format(deck, image) + + def blank_key(deck): image = PILHelper.create_key_image(deck) return PILHelper.to_native_key_format(deck, image) @@ -636,7 +669,7 @@ def on_key(deck, key, pressed): return # nothing left to reveal — password already changed state["ssh_revealed"] = not state.get("ssh_revealed", False) if state["ssh_revealed"]: - deck.set_key_image(key, render_key(deck, pw, font_size=13, bg=SSH_PW_COLOR)) + deck.set_key_image(key, render_password_key(deck, pw, bg=SSH_PW_COLOR)) else: draw_ssh_key(deck, key) return