From 8eda77420f596eb03799a7a9d6a1fc5714393f82 Mon Sep 17 00:00:00 2001 From: Joshua B Fleitell Date: Sat, 5 Sep 2026 11:52:31 -0400 Subject: [PATCH 01/15] docs: log Actions artifact cleanup (#18) in action plan Found 12.1 GiB of already-expired-but-uncollected artifacts via the API, deleted them manually, and logged the recurring fix (delete CI artifacts after they land in a release) as a housekeeping item. Co-Authored-By: Claude Sonnet 5 --- ACTION-PLAN.md | 30 ++++++++++++++++++++++++++++++ images/009_deck_splash.png | Bin 12676 -> 0 bytes 2 files changed, 30 insertions(+) delete mode 100644 images/009_deck_splash.png diff --git a/ACTION-PLAN.md b/ACTION-PLAN.md index a3e7c42..45b99ce 100644 --- a/ACTION-PLAN.md +++ b/ACTION-PLAN.md @@ -128,3 +128,33 @@ 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. diff --git a/images/009_deck_splash.png b/images/009_deck_splash.png deleted file mode 100644 index aceafc304442cbb3a2aa74c436a8a07463da4f97..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 12676 zcmeI2XH--Bw)U}XOAyg*Ha41DMMD>*Ny)Zhp-P0%BWOT@&|7G+MNmOR2pwXBsFct< zi3S8ssM2c;5Q20PAOu44F1&Y)%Q<(PG4B0vzT9_zVgL)W)_=|NJij@gn3-G_104d1 zh=_ii9L}cqd5s}R~J2nG9IqY{mSVZKM;nj=hZUm)G54t4bIxDu_e0uWFH@iC@ zyf4^ucxfn~+w!Ktpew{1Pf;6L1=+74VQxHM(ubgnM) zULdMP5u*cUYV3%!u)NA>(xe+PsyWSuj6OGFI_L6CZ;=bgCC%Fa6f#``2l@N^L!uDyA+2SkKH88G4>P&SsGQ7m8{f3w*1Z61 z0(>-hxBl`EaPZ;;j?(W*PN#TcR{O!qH4ES6(Avc}MMuTXyX~3NDMRwTH=oglZwSg1 z?vCMRo!K;sjBvRDoM7Yn;ju^c*FQN6PTZl8S+Ga-eEnmOqNP4J!dwKDe*~*+U-{1K z+wsIRe#AWcsw~ye}i>V0bj`kl%_WHs101qE<3S zNIixkhp(A0FSMJ&%4bQd=32bbIM@N5fT=X$XD=Qzk&r(a(4RT??F9aY{$(?{yX2LL zYzcIuaq}Plm8LJo^hyhsho2EzOHut`>Cm~)Ur3nKe0Yy#ad%Yx8z}axC6eXOla6LBLXr-778GCXeJIP zS}t6p3)3mZciU5j|7iDV9%@e4ft%#2Sp-eymSw31&HVspgv?ZcvrN*@_Fo>l`A|$g zE&^GR>lw~Tg;WEljMKbwkjDFIim|ayoh)k6cbm$7cj-y!!`6>m`OT<LsxFb8Ef1fkL@q>M4%ByNvf~e2|s~vK;pV{ ze}UrUj6df145&Rv3%oZ}tas=b*&#G5T`X7ShQ^<14cUA{#e$2V$($k+Q0-%}>>Rlp z!XRpA<33Z}0<~8b7;ddv%^VOOnO?y2!viQv)VSRTRAsS)fpk4AGZ8^598}8XPbq~j zyp^$#6Q_ZjRKn+ z(JPcjV)jWmGj$a&4R1Fnxc2t$u<*{$`8y}dRo)x*X* zBL7jL3R=5>SU&vpv4L;u$f*`+-i8W%W_E9#h}2oXq-prXWKY2@rowvCUV!sxzZ)EH zJm0&yFh?Jr@Nu=9EKu*SYCjvst)k~DPFs`;6pvzP%2n=2^c%HuE1?f&p3iB4OZ+&b z<#iKu;qk7kj-y{=arbMLN_+mq&W|(@=4Ps9n>pEAlWG#?bu@p(>>G;^(;Ubzl%PLT zAzAgR1)1mqfr}%?tLxHEh+0}XVyR9UZw;bTrnBn%J*rS3AYV)wy)Jmvc)h9a{6%I< zRADB&-4r^w`Jdie-Hu^*qcp8k*hGim4~6>%VlPWu_x9%Jyu0CC_O#H>d?X$csU~e{ z8NJGD!DLY4A+RwfN zgzHhaHxGF2(dGL;@mnP^EaewalRL#O8E83dWo0EAot7Rrq>a_%4ix!pj{mY|X^SeM znhY>&HxV$ab?-w7bZjU8uexWCdLl0#aVfK&rj1+q&< zUl%)z5w7B*N5|@bQf!O3I~;k`JZPcq z^5MP%-gS|Can^M!MjA{*K{=5Kt@P!mkzqgRJbhVe*+{Fws8G;8b-i_r1>^$L)=NcF$jO!6O*mmT<$$$ zZsqzRQ(i&78xfH&kY%pUlIbu1d-T{Nig+Y!SH>v0Lg;<-EaR)D0e$7`~Vd&Io0H zdu)JQe-~hrvp?T~yMRsxZmbnKWz#b|z~eUT6n(8`t?56}K^?AN>=b@E`z-7!E#Mn+ z(`dBjkQVktM$j9d5j6=bfzL@!p3=yFix>?D?#v*pCwz4prp0MI4YTs^w15TuxW8S# z7QQGIIN*&tj>s}jO`zP{w8gx9vCEcKtMIDiz*)aL$gKrAE>>L>d7Z-<0eyFIRf#+6 zSAdTBkP6?nTQZ4~?^tVo;Iy+5@@k$M4;J4q(g;@)1bV>Myea(rq2Ni2tT8ITzsuHB zj}-$;*sXjaki7gw#r%M7kQZpM(%+ELb1TM}vDl69yrW7ypmzHTNTqu2Jcukp9=Ex{l3F+`<26zdm3}89qlp7Vs4bP=uNovSzsc19jPt?v%p!$^E^_ZAo zUt(3LIi4R1CTfGULMBb$#b9{<7ESJ_C*XhIEA~Uf)+BPu0nc zqMo^&J2!=L^nB5kvzS&BOZH7&lsVCK#aB0k_L7X;>J8L6h*7+6P#E)P$27im9JaX!>LnsKM8n;dPgC0Cn!7?B3D{+g{y=mQ0_-%i2D3ILdJwWG6U5cGWJ#H8nwc8-x?+#_les?<4 zP!c8U4XA(Zkh`R`nlp%v5Ae6L|G%j0|Hp6jt}VPrE*Bqy6xLI?}KR!c3dA%+i z?q9Z`jo}_8D}eU97a4<&UVd)Z+OWCxcc2v<#Rk&DG_lX6bSyo5rwSUfyxK19Z(3q; zDp(n*6hJhkQbJ~H5-Bkl{sOQf*XEH4X`nakQ@_b(A_%=mgV#)whU?cg@< zZM8$}pxQAk&~3`tc#WZ;5pV{2ELlV8x?A`(AE6S?t%A4*HAq2<5l&CQ8GUyN;J#8% z{CQSM*EPqczLrMi$=pOrm?&?}sk$2?Tl&7;wYYVA4_v@s~>h zR|d{zP==qWWKdj6{OuFo+sElK9_^DKQ8?|~c!lvmRQU=6_^pZKr+b?q1Agts5TguT zZEPnTp@-cr6RL_;@3uRQ1$t4Y7ZpM{)0XWrqgc{m4gsv^c8IQEc?2@0^$hnQShIAE0eA`ruZAze(*aXG)R=ZP8LO^Ty?l)lwP!~@ z`RLODMTFztOwOp}jiti=qrrvrm!e?h)2039<&$|;^5kplUMzF20V+6!4PRSnnyI3k zzfE3S$_`~__qlp1IRIzam`ilUFvC|UqL6BStxQ(FH%hw>xsEZ`071T|p~t;^Ljc-oe}V#%yuryIKbxmw6CzXIHl zGS@-d zmx(T*7ZrSeB8N)&H&ouznA>+KSy_u0Mdf=Gw;4eA9K6to!Ga3WpumoVBn2wsw?sRi zt02HBSbz+bD{eFEg*?14nF4rQhYEP|yXo(lh9Dwo#T7))Rq}#cPCb&F?*;U+Di3f^ z;|{4!8V|JGHtK}pH+${H>0OYfHAM}?qa zE{R)1-7&1qN{gc$a?6RO5HV|$RELPmlHPR#Wc>&6m~yqj0zR8Y@Di?Z30@u7FK2pr zqU>K8YYv{lUXu9cS;@jb-A?ofhwPZT**wz4ygfLA7q!b zt`_#8xPCeNp5}np1*Vv}K`FKBRnl31SLDkj9Xij1S<=$!i)$m&*Jc7CQ>4`y9Qwx> z3ELdD7P90!;EEN0AsmQNs)iC4@FuU=`bu<}u?V&}y!jE^)5hdw;Z65sm0Zu7zUO$r z58Y-e!Ax>hNXu`oX4Itpjw~2>la`F!mcs|BqI(X->yd;j-Q+AU0gnYwxHSj_wF0!4 zI-G2(w|IkjB7k@1BJW=9yQ^{XWV?w~N7TW}->I@52a{BaVv*&0Q@R#*?lZT_ffIiV zQ*3&;QC;WW*;=6JY+)&vLu`}_1O7X3-quD6IKoLc$4OcM6`Ss;B)Tc z4=XGrcJjyH#yE$JAQSCNUt>NO%Jtipe8EtA%&kx4<}|~%ah?`hxUFHB0IaKFg9+&Y zHlTh0#JIR3Xm_oo3DN z-D3PEY}#|`V9A%-I`XR7RBFl<!(Hpsz^1+MOkr60%J34(O*VoKJx1F=Z4qNEK*to* zlT|Y19kgh!wLVz}A#Yx%p467wmAHr^vo3fwfEX2y#{O!uT0q%TpdC5iK4At=JWuGf zG<0l?)>Lji>ZPwD(rs*uxNg3hp2ZSJ2?0qRO^9})$=hyKXuq}&0n-j6Ye>-=eJ8PwwT~^9{L@d} zG5{)H&QUlfjhLpyJO;_G!CeB=uH8dQ6Q=@02fQ1Afd_Q8w?%%nF=4+IAK;JI7!*;J zoPipn6?=d~{OVM=%o^&XJV(`c(ayfo?<#{!7KVXtH9R+Fv|W}lcIeIe}26$IEh2kBt6C|8G@on#A z$ykaenb$;(p{hLzoA^Mm?3z;~pQE|I&uBwS85Yx+mi1TS61q3oq@|4eVsf=**rS6 zRz|fPK_>SWO5EwKs^0Z>$jVmB?Z>D4xCTfqd>xu14Vh|3?3RM{*B07gK|s!@jP}w* zZXZ078h89J8h;unVttAOHa)$z+b&MV$R?a(uekLm94^Z?wrV=x+g}rU-jSwMmJ{0b zY8NQsbWs!};qJ84NVI02yRe4w_^@TxKHA+-Ku+8!jUfZjSRqwCF}bI(HVBP~55alT zbV*BNK}$n@+Qd6lVTsA~!`}x31`1yHhg#?yA#?{6;LTS3TA*%KFHEzQ&NThQ(>rYi z3^~Xhso7FkfjYI$tDi#!%RO)1v~7A0?8<#wlsHBM24cvqv17CiwCsgt@m}ANc=enF zJD+br0R|>woUy-M$losHU%8O)rFnU_9lpSbwAgi{PWm4^@Bigs^=^nYJ>pG<=pny} zo=wr^@R(b(8SB$smnqLB_qv*ZL4Ov{OHnm&hAg~qFyKac9g+) z)Rz=(l-lTd{#_RT-*p22%LDuS@{J??+p+IYY`!OC0iw{vaiZe`#Ll-#U34K*lVg5bzqn zSxsp}S-6b@yk8$2uCspX1>-R zXy{lZLwrg3t`KIG;H5jNsK8mO2LSBcYf(h||I#P#iQR;e5pLKKO3Zg$6$E$QHcu zYgpMOSkK~1fB6PZ&nr!~rjBW< z1}YqLg$~w0pg&MCRKoRCjoIfdi9b9;$7;cOArlDP;n{v-2 z8LDJpk_Hyn{9$+K8su|NlS|-QDiCD(P#asWVupc4i3%$Mo8fL z7#VtN&F-_~uc)JpAD*2mE1zp&3TU@y`r2VK%)DU(E&%$*`&IFDX(1_s_qwC=6> zFDh?8gaNZ@@$lt~AQy5l5=0DJF#!!qG?C}aCJU~DSj5DJ&69538VGVlaNKEyxQLkW zg&1)7A{zhxlo$5IvB&&9Z>);O)!;@rOxU>x7u6@$|(3n#>_Bs^g zS?1?2W%W$WuS4+aB#K^%b)B9v<_I>hp4IPed6-=NW@$4$S+&IHX0h(tDl1GI*qcbA zth>Vg5G697jcZClgsBUc<$vu&Y}<9<1qGO^pJg45qzjhH6cm(A()(;Ihd0|KsVCS$ zu!0gtN-TaXrc-ih6e5NSwbO7SF$Sw(J=L~ks-IIqSPXwlDg8SyaXIn{kF$dwsvEPv zd0Adqb6uC+b{aCZI4%x5P+IE`*=6U=1vg9aR>Ac~cNsE9{}dZ}BvXgc+oRLy-4lbC zKN`ZLr3Ky~Oplz(b*mg-vy5YCUV`qa<>p%S6UvGqONxDSC_R^T7M)eF?Bkj-Y5O)n zcUFw1u)NqO8>7*bllwyxULV6(#F`E@6`30nJ3gpADCcLEmq{c4s8cfv`gRl@#YqyNaiyuQlYcg! zggxOnb4%EiB*Q=|RLq2Q+H^X69=T}j*Nhl4b}YhiD(2@$Cl1sq##`)>!6=B6NYGP6 zoys9LdZl}Z6RjOsrlAUG$VH0g&{t@HGXt3FCQqZN@{xv$u@SdU7^j@+4?zLTIKLKX zAiC@;8L9~HFMqg%1m458uFytJcm9Eq^v7HP4Cq{~ON;9`6xFGRF(H(XMyDQ1(arH3 z*k1v$<2KtvB#Ty#K-`DaRc?g$kCTh<_mxW#9F3Bg_-xTZ6sJz|T0m z-+)5r{r8YRYZ7dV)vDo=1-*qhGrCj@{IY5Hcyfe0;zN^O z7hYFU|HJgx0pXkK^J8|_?y-E~@>E4YMu?qbGXE%YStptod%h_Dn;iOhUYv<~eF__Aa`wa3=X+$WSeMwu#o@Po zUlCy!N~=#|bE`a6`!6f|KK6e$*0Kto4HXCmWbUS}a6j7StbA3%ZNN`hpRL{#b=9HQ z2-4`?u6dyva-JybvS5ezA4vhz{g#O`IZDK`#*aJ)L}&`@)zEds|=KBeBiz?uzZ=|G~S z)4N6WR;P+!0PmQH@&NEk$h+2>5P@Sj;CZv=dR@c>b=&SpOsM#m*ZtYq!aifd>Kidi zN=Q^wUQvt{?#6@LwlE%Y z!rvCn^;o0VK5YtgSHo+lXEe-)+M||z?@NZdbrFVN>`6k+bG3g?XiJuG+{42c7?s<{`54aoB+;_HbT+yq zdfK(!PAYN6OuNIa^pGX*`C_lKC)_3;9=v?ZHY@*1!;)Cd)7Ua*=bW^@y6|+E)^MCb zD679?TPu~D@4dZ6@?*L+YQ?XK#owv3WWuN;h_u&hxM~QGiQ8)AaR(=0W4HBgXhM(|sk` zivCs-P)Q9%VGnO`HawA%h!}(UnMeYwC`C0*$u1ez))j4|M_Lti9a4u6pFWOhbtW>| zHguO<4gzAg+T02@FmA@!`?=#2HRT01r3qVF9TXivQOjTda*8Mi3s_Y#Fq#>s7kde7N{6GYNxbL ztStPDqr5uz@R0Wba4+3^HO21mytqPeFRvU0Z&$vdfQK;i%not__eMD5aDmHtlxnC0_V8%br za7ylb`Q${7cMvF~E5TvPvOfUbG0Nvf+8PYvDM-(;nhZiyBfk2M-i9}+7=QTGmQRYm zHh>8k!ScVwMTm&DPU1XmtrgqW_M%KIu(Bd@^^(cOyz@67{6Ezt@?`)3 From e157c445090dbc2da4902c75041bd8544d3dc632 Mon Sep 17 00:00:00 2001 From: Joshua B Fleitell Date: Sat, 5 Sep 2026 11:53:10 -0400 Subject: [PATCH 02/15] fix: widen IP address card on status page (#16) Card was single-width at 17px monospace, which wraps mid-octet on a full IPv4 address (word-break:break-all). Matches the existing Hostname card's span-2 treatment for another field with unpredictable length. Co-Authored-By: Claude Sonnet 5 --- src/dpx-buttonode-ui/dpx-buttonode-ui.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/dpx-buttonode-ui/dpx-buttonode-ui.py b/src/dpx-buttonode-ui/dpx-buttonode-ui.py index 84bb7d3..4b3724c 100755 --- a/src/dpx-buttonode-ui/dpx-buttonode-ui.py +++ b/src/dpx-buttonode-ui/dpx-buttonode-ui.py @@ -648,8 +648,8 @@ def render_status(alert="", alert_cls="a-ok"):
Hostname
{host}
-
IP Address
-
{ip}
+
IP Address
+
{ip}
MAC
{mac}
Network
From f4959f468ae095b1eddfced6df7b0e3e2d765b31 Mon Sep 17 00:00:00 2001 From: Joshua B Fleitell Date: Sat, 5 Sep 2026 11:53:10 -0400 Subject: [PATCH 03/15] fix: correct Dashboard kiosk flag from --kiosk to --kiosk-mode (#13) companion-dashboard's main.js checks process.argv.includes('--kiosk-mode'), not '--kiosk' -- the wrong flag meant kiosk mode (fullscreen + the auto-started :80 web server) never actually triggered. Confirmed against upstream source, not guessed. Co-Authored-By: Claude Sonnet 5 --- scripts/install-dashboard.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/install-dashboard.sh b/scripts/install-dashboard.sh index cd75d70..439a66c 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" From bd5199c75ca8dc0a073ed03575cfbf892140f746 Mon Sep 17 00:00:00 2001 From: Joshua B Fleitell Date: Sat, 5 Sep 2026 11:54:13 -0400 Subject: [PATCH 04/15] feat: add Dashboard remote-config link to web UI (#15) Depends on #13's fix landing first -- companion-dashboard only auto-starts its /control web server (port 80) when real kiosk mode is detected. Link only shown while the kiosk service is actually active (dashboard_enabled() == svc_active), same gating as the existing Toggle Fullscreen button. Co-Authored-By: Claude Sonnet 5 --- src/dpx-buttonode-ui/dpx-buttonode-ui.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/dpx-buttonode-ui/dpx-buttonode-ui.py b/src/dpx-buttonode-ui/dpx-buttonode-ui.py index 4b3724c..4e7f1a0 100755 --- a/src/dpx-buttonode-ui/dpx-buttonode-ui.py +++ b/src/dpx-buttonode-ui/dpx-buttonode-ui.py @@ -837,6 +837,7 @@ def dashboard_section(): {'
' if on else ''} + {f'⚙ Remote Config ↗' if on else ''}
""" From 37e23ee3b15bad31420acfeeace5ca2a696a064f Mon Sep 17 00:00:00 2001 From: Joshua B Fleitell Date: Sat, 5 Sep 2026 11:56:11 -0400 Subject: [PATCH 05/15] fix: network settings not persisting on NetworkManager boards (#14) toggle_net()/pin_static() -- the functions the deck's NET key drives -- called write_networkd_config() unconditionally, writing systemd-networkd files and restarting systemd-networkd. On Raspberry Pi OS, which ships NetworkManager by default, that does nothing durable: NetworkManager keeps managing the interface and reasserts its own connection profile's DHCP setting on next boot, which is exactly the "has to be reselected every boot" behavior reported. The manual /network web form already branched on nmcli_available() vs networkd correctly -- the deck-driven functions never got the same treatment. Added write_nmcli_config() (nmcli connection modify, which persists straight to the on-disk profile) and an apply_net_config() dispatcher, and pointed toggle_net()/pin_static() at the dispatcher instead of the networkd-only function directly. get_current_net_mode() in dpx-deck-splash.py already reads live kernel state via `ip addr`, so it correctly reflects whichever backend actually applied the change -- no read-side fix needed, this was write-path only. Co-Authored-By: Claude Sonnet 5 --- src/dpx-buttonode-ui/dpx-buttonode-ui.py | 58 ++++++++++++++++++++++-- 1 file changed, 55 insertions(+), 3 deletions(-) diff --git a/src/dpx-buttonode-ui/dpx-buttonode-ui.py b/src/dpx-buttonode-ui/dpx-buttonode-ui.py index 4e7f1a0..11d3918 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}" From dcd21db1ad3a8ad37d718cf88361652c7d048c3d Mon Sep 17 00:00:00 2001 From: Joshua B Fleitell Date: Sat, 5 Sep 2026 12:01:12 -0400 Subject: [PATCH 06/15] feat: event-driven splash recovery + deterministic boot mode selection (#11, #12) Two related gaps, one mechanism, per the design decision to solve them together rather than as separate bolted-on fixes: - #11: nothing brought dpx-deck-splash.service back once a mode service's own Restart=on-failure exhausted its StartLimitBurst -- the deck just went dark/stale forever. Fixed with OnFailure=dpx-deck-splash.service drop-ins on all three mode units (as .service.d/ overrides, not direct edits, since they ship from vendor .deb packages). OnFailure= only fires once a unit's ActiveState actually reaches "failed" -- systemd holds it in "activating (auto-restart)" between individual retries -- so this is inherently once-per-real-outage, not once-per-retry. Event-driven, no polling. - #12: dpx-deck-splash.service and the persisted mode service were both WantedBy=multi-user.target, racing at boot with Conflicts= picking whichever won -- confirmed nondeterministic on hardware ("sometimes splash wins and blocks it without a GO press"). Fixed with a new dpx-mode-select.service oneshot that reads /etc/dpx-mode at boot and starts exactly that one service, falling back to the splash only if nothing's persisted or the target refuses to start. Splash is no longer auto-enabled on its own, so there's nothing left to race. Not live-verified yet -- no device access this pass. Needs a real boot-cycle test and a forced-permanent-failure test once hardware is available; also worth rechecking the "GO does nothing" symptom against this fix, since execute_staged()'s existing mode_dead check already looks correct on paper and may have been a downstream effect of the same race rather than its own bug. Co-Authored-By: Claude Sonnet 5 --- ACTION-PLAN.md | 7 ++++ scripts/install-deck-splash.sh | 70 +++++++++++++++++++++++++++++++++- 2 files changed, 75 insertions(+), 2 deletions(-) diff --git a/ACTION-PLAN.md b/ACTION-PLAN.md index 45b99ce..6140f5a 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()`, diff --git a/scripts/install-deck-splash.sh b/scripts/install-deck-splash.sh index 712bbda..0ef2767 100755 --- a/scripts/install-deck-splash.sh +++ b/scripts/install-deck-splash.sh @@ -105,8 +105,74 @@ 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 +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 ───────────────────────────────────────── From 7e19e232ee1945e98006e45ec918ff29fa3d7488 Mon Sep 17 00:00:00 2001 From: Joshua B Fleitell Date: Sat, 5 Sep 2026 12:02:07 -0400 Subject: [PATCH 07/15] ci: auto-delete redundant + expired Actions artifacts (#18) Two layers: release-action.yaml's release job now deletes its own run's CI artifacts right after they land in the GitHub Release (they're redundant once the .img.gz is a real release asset). New weekly artifact-sweep.yaml workflow catches everything else -- stray feature- branch/debug builds that never go through the release job -- by deleting anything already past its own expires_at, since GitHub's own cleanup can lag by weeks in practice (confirmed this session: found and manually cleared 12.1GiB that was up to three weeks overdue). Co-Authored-By: Claude Sonnet 5 --- .github/workflows/artifact-sweep.yaml | 35 +++++++++++++++++++++++++++ .github/workflows/release-action.yaml | 18 ++++++++++++++ ACTION-PLAN.md | 8 ++++++ 3 files changed, 61 insertions(+) create mode 100644 .github/workflows/artifact-sweep.yaml 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/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 6140f5a..636b2a0 100644 --- a/ACTION-PLAN.md +++ b/ACTION-PLAN.md @@ -165,3 +165,11 @@ notices. 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. From 42d843235636804641d5d4222ecf14dc7d57da98 Mon Sep 17 00:00:00 2001 From: Joshua B Fleitell Date: Sat, 5 Sep 2026 13:46:19 -0400 Subject: [PATCH 08/15] ci: build on native arm64 runners instead of QEMU-emulated x86_64 Both image builds ran on ubuntu-latest (x86_64) and emulated the whole chroot provisioning stage via qemu-aarch64-static -- every apt-get/dpkg call inside the mounted arm64 image ran through QEMU user-mode translation, which is commonly 5-10x slower than native for that kind of CPU-bound work. Packer's arm-image plugin already supports skipping this: it only adds the QEMU/binfmt steps when `!ImageArch.IsNative()` (pkg/builder/builder.go). Set image_arch = "arm64" in the shared dpx-buttonode.pkr.hcl and switched both builder workflows to ubuntu-24.04-arm (free, GitHub-hosted, native arm64) -- with host arch matching image_arch, the plugin skips QEMU entirely on its own, no custom logic needed. Removed the now-unnecessary qemu-user-static install steps. Verified before touching CI: Packer itself installs via HashiCorp's own apt repo (multi-arch, resolves correctly on arm64), and packer-plugin-arm-image v0.2.7 publishes a linux_arm64 build. Isolated on its own branch off main, not mixed into the action-plan hardware-fix branch -- this is unverified until a real CI run confirms it, and a broken build here shouldn't be confused with a regression in the already-tested fixes. Co-Authored-By: Claude Sonnet 5 --- .github/workflows/armbian-builder.yaml | 13 ++++++++----- .github/workflows/raspios-builder.yaml | 12 ++++++------ dpx-buttonode.pkr.hcl | 1 + 3 files changed, 15 insertions(+), 11 deletions(-) diff --git a/.github/workflows/armbian-builder.yaml b/.github/workflows/armbian-builder.yaml index ceece9a..5889689 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,10 +301,6 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 - # ARM emulation is required to chroot into the ARM64 Armbian image - - name: Install QEMU user-static - run: sudo apt-get update -q && sudo apt-get install -y qemu-user-static - # ── Download Bitfocus Buttons package from mirror ────────────────────── - name: Download Buttons USB Relay package from mirror env: diff --git a/.github/workflows/raspios-builder.yaml b/.github/workflows/raspios-builder.yaml index 459e766..b1ff950 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,11 +57,6 @@ 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. - - name: Install QEMU user-static - run: sudo apt-get update -q && sudo apt-get install -y qemu-user-static - # ── Download Bitfocus Buttons package from mirror ────────────────────── - name: Download Buttons USB Relay package from mirror env: 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 From d3983335d1b0733a59cce8345ed5092f31cddf30 Mon Sep 17 00:00:00 2001 From: Joshua B Fleitell Date: Sat, 5 Sep 2026 14:08:21 -0400 Subject: [PATCH 09/15] fix: keep qemu-user-static install on native arm64 runners Removing it broke the build outright: packer-plugin-arm-image's Prepare() unconditionally resolves a qemu_binary path via exec.LookPath, regardless of whether the emulation is actually needed later -- only its separate Run()-time IsNative() check decides that. Without the binary present, Prepare() falls back to an "embedded qemu" feature that's amd64-only and fails outright on arm64: embedded qemu is not available - currently, embedded qemu is only available for linux amd64. please download qemu-user-static manually Confirmed via a live test build (33982012810) that failed exactly this way. Re-added the install -- it's a fast package install, not the slow part we're trying to avoid, and the plugin's own IsNative() check still skips actually invoking it during the chroot provisioning. Co-Authored-By: Claude Sonnet 5 --- .github/workflows/armbian-builder.yaml | 12 ++++++++++++ .github/workflows/raspios-builder.yaml | 6 ++++++ 2 files changed, 18 insertions(+) diff --git a/.github/workflows/armbian-builder.yaml b/.github/workflows/armbian-builder.yaml index 5889689..38e3a47 100644 --- a/.github/workflows/armbian-builder.yaml +++ b/.github/workflows/armbian-builder.yaml @@ -301,6 +301,18 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 + # 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 + # ── Download Bitfocus Buttons package from mirror ────────────────────── - name: Download Buttons USB Relay package from mirror env: diff --git a/.github/workflows/raspios-builder.yaml b/.github/workflows/raspios-builder.yaml index b1ff950..9e548cb 100644 --- a/.github/workflows/raspios-builder.yaml +++ b/.github/workflows/raspios-builder.yaml @@ -57,6 +57,12 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 + # 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 + # ── Download Bitfocus Buttons package from mirror ────────────────────── - name: Download Buttons USB Relay package from mirror env: From 78078cbd6fb3bc0ada716c7151bb3126933fd858 Mon Sep 17 00:00:00 2001 From: Joshua B Fleitell Date: Sun, 6 Sep 2026 13:06:06 -0400 Subject: [PATCH 10/15] fix: switch_mode() now does a real hidraw recovery, not just a nudge (#10) Root cause found via live SSH investigation on dpx-buttonode-2199 (rockpi-s), confirmed with three isolated tests: 1. udev_retrigger() alone (what switch_mode() actually called) does NOT recreate /dev/hidraw* once a libusb consumer (Buttons/Satellite/ deck-splash) has detached the kernel driver to claim the device -- verified by running it in isolation and confirming hidraw stayed missing. 2. The full unbind/bind (usb_power_cycle(), already built and already wired into the manual /power-cycle-deck button) DOES recreate it -- verified live. 3. With hidraw present, Companion picks up the Stream Deck cleanly at startup ("Surface panel ready: streamdeck:..."). Companion's surface module only scans once at startup and never retries, so if hidraw is missing at that exact moment, Companion silently finds nothing -- this is why the reported symptom looked intermittent/mode-switch- specific rather than a permissions problem. Fix: switch_mode() now calls the same usb_power_cycle() fallback /power-cycle-deck already used manually, instead of the gentle-only udev_retrigger(). usb_power_cycle() already tries the gentle retrigger first and only escalates to the disruptive unbind/bind if that alone wasn't enough, so this is a safe drop-in with no added cost in the common case. Verified end-to-end on real hardware: forced hidraw missing via deck-splash, ran the actual --apply-mode companion production path with the fix applied, hidraw came back automatically, Companion started and opened the surface panel successfully. Co-Authored-By: Claude Sonnet 5 --- src/dpx-buttonode-ui/dpx-buttonode-ui.py | 30 ++++++++++++++++-------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/src/dpx-buttonode-ui/dpx-buttonode-ui.py b/src/dpx-buttonode-ui/dpx-buttonode-ui.py index 11d3918..e3dd48e 100755 --- a/src/dpx-buttonode-ui/dpx-buttonode-ui.py +++ b/src/dpx-buttonode-ui/dpx-buttonode-ui.py @@ -1027,16 +1027,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}" From 27d97f8c2ccf2d50befc36a436c89ecfdc49f2b6 Mon Sep 17 00:00:00 2001 From: Joshua B Fleitell Date: Sun, 6 Sep 2026 16:25:51 -0400 Subject: [PATCH 11/15] feat: add Stop button to Mode tab -- stops current service, shows splash Requested live during #10 testing. Deliberately distinct from switch_mode(): stop_current_mode() doesn't touch /etc/dpx-mode or any enable/disable state, it just stops whichever mode service is currently running and starts dpx-deck-splash. Since dpx-mode-select.service only runs once at boot (see #11/#12's fix), a manual systemctl stop with nothing to bring splash back would otherwise leave the deck dark -- this starts it explicitly instead. Verified live: clicked it via the web UI, confirmed /etc/dpx-mode unchanged, the mode service stopped, and dpx-deck-splash came up. Co-Authored-By: Claude Sonnet 5 --- src/dpx-buttonode-ui/dpx-buttonode-ui.py | 38 +++++++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/src/dpx-buttonode-ui/dpx-buttonode-ui.py b/src/dpx-buttonode-ui/dpx-buttonode-ui.py index e3dd48e..9e9dcc6 100755 --- a/src/dpx-buttonode-ui/dpx-buttonode-ui.py +++ b/src/dpx-buttonode-ui/dpx-buttonode-ui.py @@ -1055,6 +1055,27 @@ 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" + + # ── SSH management ─────────────────────────────────────────────────────────── # # Ships with SSH DISABLED by default (see dpx-buttonode.pkr.hcl) — this is @@ -1607,6 +1628,13 @@ 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}

' @@ -1624,7 +1652,7 @@ 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}
@@ -2111,6 +2139,14 @@ 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", + )) + # ── /satellite-config ────────────────────────────────────────── elif path == "/satellite-config": host = params.get("host", "").strip() From f69925fc5510c72055cbbcd43ef0d06e12a4c971 Mon Sep 17 00:00:00 2001 From: Joshua B Fleitell Date: Sun, 6 Sep 2026 16:28:00 -0400 Subject: [PATCH 12/15] feat: wrap revealed SSH password into 4-char lines instead of shrinking to fit Requested live: the single-line render shrank a 10-char password down to ~9-10px to fit the key width, making similar characters (5 vs S, 0 vs O) genuinely hard to tell apart on the deck's small screen -- confirmed by directly causing a transcription error reading one off during this same session. render_password_key() stacks the password into 4-char lines instead, so each line only needs to fit 4 characters and the font can stay much larger (18px vs ~9-10px in this test). Verified visually: rendered the actual key image on real hardware and pulled it back to look at it, not just checked it doesn't crash. Co-Authored-By: Claude Sonnet 5 --- src/dpx-deck-splash/dpx-deck-splash.py | 35 +++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) 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 From 043e59ce7f8e9b36162e12761425092541ffc797 Mon Sep 17 00:00:00 2001 From: Joshua B Fleitell Date: Wed, 9 Sep 2026 11:08:49 -0400 Subject: [PATCH 13/15] fix: dpx-dashboard.service enabled under the wrong target, never actually autostarts (#12) Confirmed live on hardware: `systemctl is-enabled dpx-dashboard` says "enabled", but the actual symlink is /etc/systemd/system/graphical.target.wants/dpx-dashboard.service -- while this system's default target is multi-user.target (`systemctl get-default`), forced there deliberately by this same script (line 43) to work around an unrelated boot-hang bug where a stray xserver-xorg postinst flips the default target. graphical.target itself is confirmed `inactive`, i.e. never reached at boot, so Dashboard's enablement symlink lives somewhere that's never visited -- it stays correctly "enabled" (systemd's own persisted state is fine) but that enablement never actually triggers a start. This is why toggling Dashboard on via the web UI worked in the moment (systemctl enable --now starts it immediately regardless of target) but didn't survive a reboot, exactly as reported on #12: "restarting does go right back into Companion... [but Dashboard] doesn't remember the status." Fix: WantedBy=graphical.target -> WantedBy=multi-user.target, matching the target this system actually reaches. Also dropped the now-stale `graphical.target` from After= (harmless as an ordering constraint on a never-started target, but confusing to leave in). dpx-dashboard.service launches X directly via xinit on tty7 itself -- it never depended on graphical.target's own display-manager machinery to begin with, so this doesn't change how it starts, only when systemd is willing to pull it in. Co-Authored-By: Claude Sonnet 5 --- scripts/install-dashboard.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/install-dashboard.sh b/scripts/install-dashboard.sh index 439a66c..f997ed4 100755 --- a/scripts/install-dashboard.sh +++ b/scripts/install-dashboard.sh @@ -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 From 4479a23ddcf2ecfa0adfc2add1abe401efd36a74 Mon Sep 17 00:00:00 2001 From: Joshua B Fleitell Date: Wed, 9 Sep 2026 11:09:41 -0400 Subject: [PATCH 14/15] feat: Dashboard status card on the Status tab (#20) Only rendered when dashboard_installed() -- same gating as the Devices tab section. Verified live: correctly shows "inactive" while dpx-dashboard is crash-looping (activating/auto-restart, not active) on hardware without a real display attached. Co-Authored-By: Claude Sonnet 5 --- src/dpx-buttonode-ui/dpx-buttonode-ui.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/dpx-buttonode-ui/dpx-buttonode-ui.py b/src/dpx-buttonode-ui/dpx-buttonode-ui.py index 9e9dcc6..e6e8917 100755 --- a/src/dpx-buttonode-ui/dpx-buttonode-ui.py +++ b/src/dpx-buttonode-ui/dpx-buttonode-ui.py @@ -696,6 +696,13 @@ 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
@@ -713,6 +720,7 @@ def render_status(alert="", alert_cls="a-ok"):
{uptime}
RAM
{esc(ram_str)}
+{dashboard_card}

USB Devices

    From 5e20960273a0f3c506b9c23411cafa98789094c8 Mon Sep 17 00:00:00 2001 From: Joshua B Fleitell Date: Wed, 9 Sep 2026 17:08:30 -0400 Subject: [PATCH 15/15] feat: make boot-time mode autostart disable-able, default checked (#12) Requested directly: dpx-mode-select.service (from #11/#12's original fix) always starts the persisted mode at boot with no opt-out. Some setups want to land on the deck splash every boot instead and switch modes manually. Existence-based marker (/var/lib/dpx-mode-autostart-disabled), same convention as /var/lib/dpx-hostname-set -- absent by default, so a fresh image behaves exactly as before (checkbox defaults to checked). dpx-mode-select.sh checks it first and goes straight to splash if present, skipping the mode-service lookup entirely. Verified live: toggled off via the web UI, confirmed the marker file appears/disappears correctly, re-enabled to restore default state. Co-Authored-By: Claude Sonnet 5 --- scripts/install-deck-splash.sh | 7 ++++ src/dpx-buttonode-ui/dpx-buttonode-ui.py | 44 ++++++++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/scripts/install-deck-splash.sh b/scripts/install-deck-splash.sh index 0ef2767..c67e75e 100755 --- a/scripts/install-deck-splash.sh +++ b/scripts/install-deck-splash.sh @@ -146,6 +146,13 @@ echo "==> OnFailure=dpx-deck-splash.service drop-ins installed for all 3 mode se 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" ;; diff --git a/src/dpx-buttonode-ui/dpx-buttonode-ui.py b/src/dpx-buttonode-ui/dpx-buttonode-ui.py index e6e8917..65470db 100755 --- a/src/dpx-buttonode-ui/dpx-buttonode-ui.py +++ b/src/dpx-buttonode-ui/dpx-buttonode-ui.py @@ -1084,6 +1084,29 @@ def stop_current_mode(): 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 @@ -1649,6 +1672,16 @@ def mode_btn(target, label, active): 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' @@ -1662,6 +1695,7 @@ def mode_btn(target, label, active):
    /etc/dpx-mode = {esc(mode)}
    {btns}{stop_btn}
    {companion_link} + {autostart_toggle}
@@ -2155,6 +2189,16 @@ def _apply(): 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()