Skip to content

feat: bring back WiFi captive-portal setup on Screenly-Labs/wifi-connect - #3399

Merged
vpetersson merged 23 commits into
masterfrom
agent/anthias-claude/5acb2d44e14d
Oct 22, 2026
Merged

vpetersson merged 23 commits into
masterfrom
agent/anthias-claude/5acb2d44e14d

Conversation

@vpetersson-bot

@vpetersson-bot vpetersson-bot commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

Anthias dropped its captive portal in 2026.04 ("chore: drop wifi-connect service end-to-end"): it was pinned to balena-os/wifi-connect v4.11.1, which had silently shipped no ARMv6 binary since v4.4.6, and the host wrapper's set -e-vs-$? bug made the portal branch unreachable for three years. nmcli / nmtui has been the only path since — fine for an operator with SSH, no help at all for a screen that has never been on a network.

Screenly-Labs/wifi-connect is the ground-up replacement, pinned here at v2026.10.13. This wires it in end-to-end, including the two parts the old integration never had: deciding when a screen should be offered setup at all, and getting the access point's passphrase to the person standing in front of it.

How it runs

  • anthias-wifi-connect in docker-compose.yml.tmpl, on upstream's multi-arch image pinned to an exact CalVer release. Nothing is built here.
  • Behind a compose profile, so docker compose up -d cannot start it. restart: "no" governs restart-on-exit, not the initial start — without the profile, every upgrade_containers.sh run would start the portal directly and walk straight past the gate, taking a WiFi-connected screen off its network for a 15-minute setup session on every upgrade. upgrade_containers.sh creates the container stopped, by name, so the unit has something to start.
  • A systemd service + timer (ansible/roles/network, gated on manage_network) is the only way in: OnBootSec=30s, then OnUnitInactiveSec=5min — inactive-based, so a live 15-minute portal session is never interrupted by the next tick. The service has no [Install] section deliberately; a skipped check must be retried, not be the end of it.
  • The portal listens on 8000, not 80 — anthias-server publishes 80:8080 on the host, so binding there fails outright (upstream reports it as PortalUnavailable, and names Anthias in the comment). Phones reach it through DHCP option 114 (RFC 8910, honoured by iOS 14+, Android 11+, macOS, Windows 11) and through the address printed on the setup screen.
  • On balena, restart: always is the schedule. The supervisor treats an exited restart: "no" container as finished — it never starts again, not on reboot, only on a new release apply — so a screen installed while online would never offer setup after being moved. restart: always is only affordable because the daemon waits before exiting as of 2026.10.13: five minutes between checks on a healthy screen, six hours after a setup page nobody used. Measured on a wired Pi 5 — without the wait that policy is 4,680 container starts and 19,000 log lines per device per day; with it, 288 starts.
  • No gate in front of it on balena. The supervisor starts every service on every boot and release apply, and there is no systemd to hang a condition on — so the daemon decides for itself. That is what 2026.10.11 is for (below); the pin and the absence of a gate there are one decision, and a test stops the balena templates ever pinning below it. Host networking, the io.balena.features.dbus label, resin-data for the state file, restart: "no".

Deciding whether a screen needs setup

As of 2026.10.11 the daemon decides this itself, which is what makes it shippable on a platform with no init system. NetworkManager on every Anthias platform answers ConnectivityCheckAvailable: false — built without it — so its verdict is Unknown forever, and upstream's fallback was "is the radio an associated station". A wired screen never associates, so the honest answer on a healthy wired device was "this needs setup". Upstream now also counts another interface being up (wired, a second radio, a modem), with an allowlist of NetworkManager device types so container veths and bridges cannot be mistaken for a network.

Verified on wired hardware with no gate and no --force, exactly how balena's supervisor starts it: a Pi 5 and a Pi 3 B+ both exit 0 in ~20s with "already online; the portal is not needed", no AP, no state file. The same test against 2026.10.10 raises an access point.

The host-side gate below remains on the Debian path, where it adds the retry cadence and the backoff systemd can own and a short-lived daemon cannot.

The host-side gate on Debian

wifi-connect asks NetworkManager whether the device is online. On every platform Anthias supports, NM answers ConnectivityCheckAvailable: false — it is built without connectivity-check support — so the portal's own gate says "offline" on a device with perfectly good Ethernet. Reproduced two runs out of two on the Pi 5.

So Anthias decides, in an ExecCondition gate, on three grounds — the union of what both predecessors checked, because each alone is wrong in a different direction:

Check Why Whose
WiFi already associated otherwise we tear down working WiFi in order to offer to configure WiFi balena's iwgetid -r
A wired network is connected — —
The internet is reachable otherwise an Ethernet-only screen never associates and is offered setup forever Anthias's original wget --spider

The portal's own access point is excluded by profile id: with the portal up, nmcli really does report wlan0:wifi:connected:wifi-connect-portal, which without the exclusion reads as "WiFi is configured".

And a bound on how often it may try. On a site with nothing to join, the gate says "raise" on every tick — 15 minutes of setup page, 5 minutes of content, forever. A screen that played content fine before the upgrade would never play anything again. So: three attempts at the timer's cadence (a first boot is someone standing there with a phone, and three goes covers them walking off to find the password), then one attempt every six hours. Not "give up" — a network can appear with nobody there to notice, and four quiet attempts a day costs a minute in twenty-four of screen time. The count lives in the unit's RuntimeDirectory with Preserve=yes, so a reboot resets it, because power-cycling is what an operator does when they want another go.

The gate's full decision table — including the backoff, its expiry, a corrupt state file, a missing container, and the settle-vs-wait timing — is covered by tests that shell out to the real script with nmcli, curl and docker stubbed.

How the passphrase reaches a user

The access point is WPA2/WPA3 with a passphrase generated per run — a real improvement over the old open portal, but it means the passphrase has to be displayed: it is what gets you onto the network and therefore cannot be served over it.

  • wifi-connect writes its join QR to the shared resin-data volume; /hotspot/ renders it full-screen with the SSID, the passphrase and the portal address. Unauth'd like /splash-page (the viewer has no credentials), and it withholds the credentials entirely unless the portal is actually broadcasting.
  • The viewer shows that page for as long as setup is running, ahead of the playlist, and leaves on its own when the portal comes down.
  • It says passphrase, not password — 802.11's word, and the one printed on the back of every router, which is where someone standing in front of a sign is reading it from. Upstream's portal says the same (Screenly-Labs/wifi-connect#34), so the two halves of one flow no longer use different words for one string.
  • --log-passphrase is deliberately not set: a WPA passphrase in journald is worth avoiding when /hotspot is the path an operator actually uses.

A distinct network name per screen

PORTAL_SSID is a prefix as of 2026.10.6; the portal appends four random characters, giving Anthias Setup-G4VV. Unbox five screens in one room with a fixed name and a phone shows one entry for all of them, joins whichever is loudest, and the installer configures a device at random while reading the credentials off another — and since each portal generates its own passphrase, four of the five refuse the passphrase on the screen in front of them. It presents as "the password on the screen doesn't work".

PORTAL_SSID_SUFFIX is deliberately left unset, and a test asserts it stays absent: setting it to 0 opts back into the collision.

The splash label says SSID rather than Network, because the screen sits beside a phone showing a list of SSIDs and the label's job is to tell someone which row to tap.

Branding

The page on the device's display was always Anthias — it is built on the splash design system. The page the user opens on their phone was upstream's blue. Upstream had documented a white-labelling seam for exactly this but never implemented it, so it was implemented upstream (Screenly-Labs/wifi-connect#3) and is consumed here: ansible installs wifi-connect-brand.css and the Anthias mark to /etc/anthias/wifi-connect/, mounted read-only into the portal container.

The stylesheet maps role tokens only — plum-800 canvas under a 4%-white card, fields at paper-6, sun-400 on aubergine for the one button that matters, the splash gradient. Upstream is explicit that the --c-* primitives and the markup are not a public surface, and a test enforces that every custom property in the file is a role and no rule names a component class. WIFI_CONNECT_DEFAULT_THEME=dark pins the scheme, which also removes the portal's theme switcher: the plum canvas is the brand, and which scheme a two-minute setup page uses is not a question to put to someone trying to get a screen online.

Knowing the portal is up

wifi-connect publishes a state file — one atomic JSON write on the volume Anthias already mounts, carrying the SSID actually on the air, the per-run passphrase, the join payload and the portal's address. Anthias reads that; it does not work any of it out.

That contract was added upstream for this (Screenly-Labs/wifi-connect#36, #37), replacing two things this branch used to do:

  • Re-parsing credentials out of the QR payload — 70 lines of WIFI: field splitting and unescaping, recovering strings the portal had in hand.
  • Probing the portal's HTTP listener — which cannot work from anthias-server. The listener is bound to the gateway scoped to the wireless device so a setup portal can't answer over a customer's wired LAN, and a packet from the Docker bridge is refused: measured again on the Pi 5 at 000 from the container, 200 from the host. It only ever worked because host_agent ran the probe out on the host and published the answer through Redis. The probe, its Redis key and the plumbing are gone.

Three signals now decide whether a setup screen shows, each covering the others' blind spot:

Signal Catches
The file exists and says portal_active written once a run commits to raising the AP, removed when it ends — unlike the QR files, which are written before the portal's own online check and so exist on healthy devices too
Its heartbeat is fresh a portal killed too hard to clean up
The gateway is still on the host a clock that jumps, and a stale file generally

The heartbeat is there because of a hardware finding: SIGKILL the portal and it removes neither its state file nor its access point — NetworkManager keeps holding the gateway and the radio keeps broadcasting, so every signal outside the file reads as a healthy portal. The grace window is three beats at the interval the file declares, not a constant copied from upstream.

The WiFi-setup poll also stopped waking host_agent on healthy devices: the cheap local read comes first, so the host is only disturbed once something on disk says a portal run is live.

When the portal stands down

ACTIVITY_TIMEOUT is an idle timeout as of 2026.10.10, and was a hard session cap before it — the deadline was computed once and nothing fed it. An installer working through five screens reaches the last one thirteen minutes into its window, joins, starts typing a passphrase, and the access point vanishes. The recovery is worse than the interruption: the next run mints a new passphrase and a new SSID suffix, so their phone will not reconnect and the screen shows credentials that are not the ones they wrote down. On this branch it compounds — every timed-out run counts against the backoff, so three interruptions put the device into its six-hour cool-off with a human standing in front of it.

Captive-portal probe endpoints deliberately do not reset it. A phone that joins the setup network and goes back in a pocket polls /hotspot-detect.html by itself, and counting that would hold a screen in setup mode until someone walked away with the phone — quietly disabling the protection the backoff is built on.

Where the setup address comes from

Nothing here hardcodes 192.168.42.1 any more. --portal-gateway and --portal-interface are the operator's to change, so the portal publishes what it actually used and Anthias reads it:

  • portal_is_up is told which address to look for. Under the old constant, a deployment that moved the portal was never detected at all — not a cosmetic problem, a screen that never shows its passphrase.
  • strip_portal_gateway gets None when no portal run is live, and then strips nothing. The address is only special while a portal owns it: a customer LAN can be 192.168.42.0/24 and hand a screen .1, and the splash page was dropping that screen's only real address on a network where nothing was wrong.
  • DEFAULT_PORTAL_GATEWAY survives only to describe a portal whose state file has not been read. A test parses the module and asserts neither decider references it or the literal.

Also

  • The gateway address is stripped from the splash page's "open this on your phone" list. It is a real address on the host while setup runs, but not one anyone should be told to manage the device on.
  • upgrade_containers.sh no longer docker rm -fs anthias-wifi-connect — it is a live service again, and the legacy sweep would tear down a portal mid-setup. srly-ose-wifi-connect stays.
  • collect_debug.sh collects the container's logs and redacts the passphrase line out of the bundle.
  • SuccessExitStatus includes upstream's 6 (TimedOut) — the ordinary end of an unattended session — while a portal that could not bind is now a distinct exit 7 (Screenly-Labs/wifi-connect#24), so a port conflict shows up in systemctl --failed and a timeout does not.
  • New docs page, a QA-checklist section, and an updated FAQ answer.
  • The 11 SonarCloud findings this branch raised are cleared, including a NOSONAR of mine that read as a suppression directive in its own right.

On network_mode: host

This is the documented exception to CLAUDE.md's rule, and the only one. That rule exists so host-only facts (IPs, MACs, interfaces) are routed through host_agent → Redis instead of punching through bridge isolation to read them, and every Anthias service still obeys it — including, now, this feature's own liveness probe.

This service is not reading a host fact: it is serving DHCP, an L2 broadcast protocol that does not survive Docker's DNAT, and the interface pin that stops it answering leases on the customer's ethernet LAN has nothing to bind to in a bridge namespace. The exception is bounded — nothing Anthias wrote runs in that namespace, and the image is upstream's FROM scratch build.

Verified

Local gates: ruff check + ruff format --check clean; strict mypy . 212 files 0 issues; ansible-lint ansible/ at the production profile; shellcheck clean; systemd-analyze verify clean on the unit, on the board's own systemd; all compose templates render under envsubst and parse via docker compose config; SCSS compiles. pytest -m "not integration": 2504 passed, 3 skipped — 115 of them in tests/test_wifi_connect.py, including tests that pin the compose template to the Python constants, because the port, the QR directory, the version pin, the restart policy and the activity timeout have no compiler between them and a drift there is silent.

On the QA rig, against released images rather than local builds:

  • Pi 5 — the full flow: portal up, /brand.css, /brand-logo and /networks answering, CSP and frame/sniffing headers present and the branding surviving them, three APs with two radios each rendering as three rows, a failed join (SsidNotFound) returning 202 → branded connecting page → AP back within ~30s → human-readable error with no profile left behind, QR artifacts landing in WIFI_CONNECT_QR_OUTPUT and Anthias's own parse_qr_payload reading them back, both the PSK ("Passphrase", 8–63 hint) and 802.1X ("Password") arms of the credential field, and the theme pin holding with a phone set to light and to dark.
  • Gate behaviour on hardware — online and wired: Skipped due to 'exec-condition' in 0s with the probe never reached. Airgapped wired LAN: same. Container absent: skip with a reason, no failed unit. Portal up: the association check correctly reports "not configured" rather than mistaking the portal's own AP for a configured network. Starting the service while a portal runs does not spawn a second. systemctl enable is refused. State cleared once the device is found online.
  • Distinct SSIDs, over the air — a Pi 5 and a Pi 3 B+ raised portals simultaneously from the same prefix and got Anthias Setup-G4VV and Anthias Setup-CMMM, with each board's NetworkManager profile, on-air SSID (iw dev) and QR payload all carrying the same name. The x86 box has a radio and no portal, so it scanned: two separate entries at signal 64 and 40, and zero bare Anthias Setup. The collision shown gone over the air, not inferred from the code.
  • The state-file contract, on the Pi 5 — the file wifi-connect writes is 0640 root:root and its SSID matches what iw dev reports on the air; Anthias's own module, copied unmodified into the running anthias-server container, reads the SSID, passphrase, portal URL and QR data URI straight out of it; a graceful stop removes the file and the reader then reports no portal; and SIGKILL leaves the AP up, the gateway held and the file behind — and the screen still goes away, because the beats stopped.
  • The idle timeout, on the Pi 5 at ACTIVITY_TIMEOUT=60 so both arms are observable: untouched, still up at 32s and exited 6 (TimedOut) by 87s — the same duration as the old hard cap, which is the point; polled every 25s, still running at 153s; probe traffic only, exited 6 by 88s.
  • A relocated portal, on the Pi 5 — run deliberately on 10.77.0.1/wlan0 instead of the default. The state file reports it, the host holds it, the portal answers 200 there, portal_is_up against the old default returns false for that live portal while the declared address returns true, and the splash list drops 10.77.0.1 and keeps 192.168.8.17. A device handed 192.168.42.1 by its own LAN with no portal running keeps that address.
  • Unconditional start on wired boards (the balena case) — Pi 5 and Pi 3 B+, no gate, no --force: exit 0, no access point, no state file, "already online; the portal is not needed". Against 2026.10.10, an access point.
  • Seeded branding — staged exactly as start_server.sh does: portal 200, /brand.css is text/css carrying #ffe11a, /brand-logo is image/svg+xml and byte-identical to the splash logo, title "Anthias setup". With the branding deleted, the container stays up and still answers 200, logging "could not read --brand-css; serving the portal unbranded" — the race is survivable.
  • Every run torn down clean: no access point, no stale NetworkManager profile, stacks back up, rig locks released.

The two upstream blockers noted earlier in this PR are both resolved: ghcr.io/screenly-labs/wifi-connect now pulls anonymously, and the pinned release is complete with its assets published.

🤖 Generated with Claude Code


Verified on balenaOS, in QEMU. There is no balenaOS board on the QA rig, so balenaOS 2.83.18 (qemux86-64, NetworkManager 1.28.0, supervisor 12.10.3) was booted under QEMU/KVM to settle the platform-specific unknowns:

  • The supervisor reports the setup AP's gateway. With the address on an interface named wlan0, /v1/device went from 10.0.2.15 to 10.0.2.15 192.168.42.1 within ~12s. It filters balena's own bridges (balena0, supervisor0, resin-dns all hold addresses, none appear), and it is a lagging cache — it kept reporting the address after the interface was deleted, which is exactly why the display side treats it as corroboration and the state file as the authority.
  • ConnectivityCheckAvailable is false on balenaOS, same as Debian — so the Unknown fallback is the path that runs there too, and 2026.10.11's wired check is precisely what that platform needed. (Connectivity itself still answers 4/FULL, optimistically, which is the trap.)
  • supervisor0 is a bridge NetworkManager reports as connected (externally) — concrete proof that upstream's ethernet/wifi/modem allowlist was the right call: a rule counting any connected device would read every balena device as permanently online and never raise a portal at all.
  • D-Bus reaches the container with exactly the env and mount from the balena service block: the pinned image exits 3 ("no WiFi device was found" — the VM has no radio) rather than 5, which is what failing to reach NetworkManager looks like.

Not testable that way: raising an actual access point, since the qemux86-64 kernel has no mac80211_hwsim and QEMU emulates no radio. That path is the same code proven on three physical boards; what was balena-specific was the gate and the address plumbing, and both are settled.

Branding now matches on both platforms. The stylesheet and logo were installed host-side by ansible, which does not run on balena — so the portal was Anthias on one platform and upstream's blue on the other. The server container already has both files and already shares a volume with the portal, so it stages them there at startup, the same way start_server.sh already seeds anthias.conf. The host-side install is gone: three ansible tasks, the /etc/anthias/wifi-connect bind mount, and a duplicated logo that a test had to keep byte-identical to the splash page's own file.

The ordering hazard that creates is handled upstream, not papered over here: a portal starting before the server has staged the files used to exit 2, which on balena means no portal until someone reboots. 2026.10.12 warns and serves unbranded instead, so losing that race costs colours, not the portal.

That release also carries a defect found by a colleague running this branch on balenaOS in QEMU: balenaOS has no connectivity check configured, so NetworkManager guesses from the routing table — full with a cable in, limited with it out — and 2026.10.11 believed limited. A wired screen on an isolated LAN has no default route, so it was guessed limited and had an access point raised at it. Verdicts are now trusted only when ConnectivityCheckAvailable says something measured them.

@vpetersson-bot
vpetersson-bot requested a review from a team as a code owner October 19, 2026 17:19
Copilot AI lite review requested due to automatic review settings October 19, 2026 17:19

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error: Your billing is not configured or you have Copilot licenses from multiple standalone organizations or enterprises. To use premium requests, select a billing entity via the GitHub site, under Settings > Copilot > Features.

Anthias dropped its captive portal in 2026.04: it was pinned to
balena-os/wifi-connect v4.11.1, which had silently shipped no ARMv6
binary since v4.4.6, and the host wrapper's `set -e`-vs-`$?` bug made
the portal branch unreachable for three years. nmcli/nmtui has been
the only path since, which is fine for an operator with SSH and no
help at all for a screen that has never been on a network.

Screenly-Labs/wifi-connect v2026.10.0 is the ground-up replacement.
This wires it in end-to-end, including the part the old integration
never had: getting the access point's passphrase to the person
standing in front of the screen.

How it runs
- `anthias-wifi-connect` in all three compose templates, on upstream's
  multi-arch image pinned to an exact CalVer release. Nothing is built
  here.
- `restart: "no"`, plus a host unit (ansible network role, gated on
  manage_network) that starts one run per boot. NOT a restart loop:
  wifi-connect exits 0 the moment it finds the device online, so a
  policy of `always` both churns a container start a minute forever and
  — on a device whose NetworkManager connectivity check is blocked by a
  filtered LAN — re-raises the access point every ACTIVITY_TIMEOUT,
  which is an outage that never ends. `ACTIVITY_TIMEOUT=900` is the
  second half of that safety valve: a false offline reading costs one
  16-minute window per boot instead of the device's uptime.
- The portal listens on 8000, not 80: anthias-server publishes 80:8080
  on the host, so binding there fails outright. Phones reach it through
  DHCP option 114 (RFC 8910) and through the address on the setup
  screen.

How the passphrase reaches a user
The access point is WPA2/WPA3 with a passphrase generated per run, so
it has to be displayed — it is what gets you onto the network and
therefore cannot be served over it.

- wifi-connect writes its join QR to the shared resin-data volume.
- `/hotspot/` renders it full-screen with the SSID, the passphrase and
  the portal address. Unauth'd, like /splash-page, and it withholds the
  credentials entirely unless the portal is actually broadcasting.
- The viewer shows that page for as long as setup is running, ahead of
  the playlist, and leaves on its own when the portal comes down.

Knowing the portal is up
NetworkManager holds 192.168.42.1 on the WiFi interface for exactly as
long as the access point is up. anthias-server already resolves the
host's addresses for the splash page — host_agent on bare metal, the
supervisor on balena — so detection is that resolver plus a token
comparison, with no new transport and no timeout to tune. The QR files
alone would not do: they are written at the start of every run,
including the ones that exit a second later because the device turned
out to be online.

That poll runs for the life of an offline device, so it asks host_agent
for a new no-probe refresh rather than the splash's. The existing one
spends ~10s inside a retrying outbound request when the device is
offline, and host_agent handles reboot/shutdown on that same single
thread — reusing it would have queued reboots behind an HTTP retry loop
most of the time, and put an outbound request on every Anthias device
every 15 seconds, permanently.

Also
- The gateway address is stripped from the splash page's "open this on
  your phone" list; it is not somewhere to manage the device from.
- upgrade_containers.sh no longer `docker rm -f`s anthias-wifi-connect
  — it is a live service again, and the sweep would tear down a portal
  mid-setup.
- collect_debug.sh collects the container's logs and redacts the
  passphrase line out of the bundle.

network_mode: host
This is the documented exception to CLAUDE.md's rule, and the only one.
That rule exists so host-only facts are routed through host_agent ->
Redis instead of punching through bridge isolation to read them, and
every Anthias service still obeys it. This service is not reading a
host fact: it is serving DHCP, an L2 broadcast protocol that does not
survive Docker's DNAT, and the interface pin that stops it answering
leases on the customer's ethernet LAN has nothing to bind to in a
bridge namespace. Anthias's own detection of the portal still goes the
ordinary way.

Verified
- `ruff check` + `ruff format --check`: clean.
- Strict `mypy .`: 212 files, 0 issues.
- `ansible-lint ansible/`: passes at the production profile.
- `systemd-analyze verify` on the unit: clean.
- All three compose templates render under envsubst and parse via
  `docker compose config`.
- `pytest -m "not integration"`: 2446 passed, 3 skipped. 57 of those
  are new, including tests pinning the compose templates to the Python
  constants — the port, the QR directory, the version pin, the restart
  policy and the activity timeout have no compiler between them and a
  drift there is silent.

NOT verified on hardware, and it cannot be yet: the GHCR package
ghcr.io/screenly-labs/wifi-connect is private, so no device can pull
the image. The v2026.10.0 release run also failed in its snap jobs,
so the `release` job never ran and there is no GitHub Release or
tarball either. Both are upstream-side.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
@vpetersson-bot
vpetersson-bot force-pushed the agent/anthias-claude/5acb2d44e14d branch from 49332e3 to bfc7362 Compare October 19, 2026 17:57
Copilot AI lite review requested due to automatic review settings October 19, 2026 18:05

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error: Your billing is not configured or you have Copilot licenses from multiple standalone organizations or enterprises. To use premium requests, select a billing entity via the GitHub site, under Settings > Copilot > Features.

@vpetersson-bot
vpetersson-bot requested a lite review from Copilot October 19, 2026 18:05

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error: Your billing is not configured or you have Copilot licenses from multiple standalone organizations or enterprises. To use premium requests, select a billing entity via the GitHub site, under Settings > Copilot > Features.

Copilot AI lite review requested due to automatic review settings October 19, 2026 20:59

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error: Your billing is not configured or you have Copilot licenses from multiple standalone organizations or enterprises. To use premium requests, select a billing entity via the GitHub site, under Settings > Copilot > Features.

vpetersson-bot and others added 2 commits October 19, 2026 21:00
…ositives

The setup page inlined the wifi-connect QR SVG with |safe. It now
renders it as an <img> data: URI: still a single request on a device
with no working network, but nothing in an SVG read off a shared volume
can run as page markup.

Also annotates genuine SonarCloud false positives (the 0644 systemd
unit, wifi-connect's fixed setup-AP address) and moves a NOSONAR out of
a prose comment, where Sonar parsed it as a malformed suppression.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
Two defects, both found running the integration end-to-end on the Pi 5
testbed rather than in a unit test.

1. The portal came up on a device with working Ethernet
---------------------------------------------------------
Reproduced twice out of two runs. `nmcli networking connectivity`
reported `full`, a forced re-check reported `full`, and wifi-connect
raised the access point anyway.

Root cause is not a bug in wifi-connect. It asks NetworkManager, and
NM answers over D-Bus:

    ConnectivityCheckAvailable: false
    ConnectivityCheckEnabled:   false
    ConnectivityCheckUri:       ""

NM is built WITHOUT connectivity-check support, so nmrs raises
ConnectivityCheckDisabled, which wifi-connect maps to `Unknown` and
treats as a reason to show the portal. That is a reasonable default
for a tool that assumes NM can answer. On Anthias it is fatal: every
board in the QA fleet reports `Available: false` — Pi 5, Pi 4,
Pi 3 B+, Rock Pi 4 and x86 — so the portal would come up on every
boot of every device. On a device online over WiFi, raising the AP
takes the radio out of station mode and drops the network it was
using. No config fixes it; the capability is absent from the build,
not merely unconfigured.

So Anthias makes the call itself, in an ExecCondition on the unit:
bin/wifi_connect_needed.sh probes the internet (the same target
host_agent already uses, honouring a configured proxy) and exits
non-zero when the device is online, which systemd treats as "skip"
rather than "fail" — an online device shows `inactive (dead)`, not a
red unit on every boot. It retries across ~30s so a boot that outruns
DHCP doesn't raise an access point for no reason.

Measured on the Pi 5: online, the unit logs "Skipped due to
'exec-condition'" and no AP is raised. In a network namespace with no
route, the gate correctly reports the portal is needed.

2. A stale address cache pinned the setup screen on the display
---------------------------------------------------------------
`portal_is_up` reads the address list host_agent publishes into Redis.
That is a cache, and when host_agent stopped, the list froze with
192.168.42.1 still in it: the endpoint kept answering `active: true`
and the viewer held /hotspot full-screen for a portal that had been
down for minutes. Anything that interrupts the refresh does this.

The address list is now the cheap gate and wifi-connect's own RFC 8908
endpoint is the authority — it exists only while the portal is
serving, on an address that exists only while the AP is up, so it
cannot go stale. The probe is gated behind a positive address match,
so a device that is plainly online never sends a packet at
192.168.42.1.

Verified against exactly the state that produced the bug: cache
stale-positive, AP down, portal unreachable. Endpoint flipped to
`active: false`, /hotspot rendered "Nothing to set up", and the viewer
logged "WiFi setup portal is down; resuming normal playback". The
positive path still reports active with a live portal.

Also
- The module docstring claimed the address was "the one liveness
  signal that cannot go stale". It is a cache; it says so now.
- docs/wifi-setup.md described the NetworkManager gate that does not
  work here, and told operators to debug the wrong thing.

What the hardware run confirmed otherwise
- The now-public upstream image pulls and runs on a real arm64 device.
- QR -> volume -> /hotspot: the passphrase on the page matched the one
  in the portal's log and in the QR payload, byte for byte.
- The viewer fetched /hotspot/ itself (server log, from the viewer
  container's IP), so the display pipeline really does switch.
- The splash page's address list correctly excluded 192.168.42.1.
- The portal served on :8000 and answered RFC 8908.
- Stopping the container tore the AP down cleanly and left no stale
  NetworkManager profile behind.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
@vpetersson-bot
vpetersson-bot force-pushed the agent/anthias-claude/5acb2d44e14d branch from 2e67b8f to 595861c Compare October 19, 2026 21:00
Re-read Anthias's own pre-2026.04 implementation and balena's, and my
gate was half of each.

What they actually do
---------------------
Anthias, bin/start_wifi_connect.sh: an internet probe
(`wget --spider 1.1.1.1`), in a `while true; ... sleep
$CHECK_CONN_FREQ` loop, exiting once the device came online. Compose
set the interval to 10s.

balena, scripts/start.sh: one check per container start, gated on
`iwgetid -r` — is there an active WiFi connection. Their script ships
a menu of four candidate conditions and that is the one they chose;
`nmcli -t g | grep full` is in the list, commented out.

Both check something they own rather than trusting NetworkManager's
verdict. That is the part I had right, and balena having explicitly
passed over the nmcli option is a good sign it was right for the same
reason: NM is built without connectivity-check support on every
platform Anthias supports.

Two things I had wrong
----------------------
**The condition was too broad.** I gated on an internet probe alone.
A screen whose WAN is down but whose WiFi is perfectly associated
fails that probe, and "fixing" it by raising an access point tears
down the working WiFi in order to offer to configure WiFi. The outage
becomes ours. balena's association check is the guard against exactly
this, and I had skipped it.

The gate is now the union: skip if WiFi is already associated (balena),
skip if a wired network is connected, skip if the internet is
reachable (Anthias's original). An association check alone would be
too narrow in the other direction — an Ethernet-only screen never
associates, so it would be offered WiFi setup forever — which is why
it is all three and not a swap.

The portal's own access point is excluded by profile id. Verified on
hardware that this matters: with the portal up, nmcli really does
report `wlan0:wifi:connected:wifi-connect-portal`, so without the
exclusion a running portal reads as "WiFi is configured".

**It only ran once per boot.** Both predecessors re-checked; I had
reduced it to a single shot. A screen nobody reaches inside one
15-minute window, or whose router is replaced later, had no way back
short of a reboot — on a wall-mounted display that is a site visit.

anthias-wifi-connect.timer restores the cadence: OnBootSec=30s, then
OnUnitInactiveSec=5min. A timer rather than a shell loop so systemd
owns the schedule and journald records each decision, and
OnUnitInactiveSec rather than OnCalendar so a 15-minute portal session
is never interrupted by the next tick. The service lost its [Install]
section — the timer is the only way in, so a skipped check is always
retried rather than being the end of it.

This is only safe because the condition is now conservative. My
earlier reason for refusing a loop was that it would re-raise the AP
every ACTIVITY_TIMEOUT on a device NM called "unknown"; with Anthias
deciding, and deciding on three grounds, that objection is gone.

Verified on the Pi 5 testbed
- Online and wired: `Skipped due to 'exec-condition'`, unit
  inactive/success, no AP raised.
- Portal up: the association check correctly reports "not configured"
  rather than mistaking the portal's own AP for a configured network.
- Starting the service while a portal is already running does not
  spawn a second one.
- `systemctl enable anthias-wifi-connect.service` is refused: "The
  unit files have no installation config".
- Timer schedules the next check 5 minutes after the last one
  finished.
- No route at all: the gate correctly reports the portal is needed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
Copilot AI lite review requested due to automatic review settings October 19, 2026 21:13

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error: Your billing is not configured or you have Copilot licenses from multiple standalone organizations or enterprises. To use premium requests, select a billing entity via the GitHub site, under Settings > Copilot > Features.

…gate

Code review of the whole branch, plus the hardware run it prompted.
Nine findings; the two that matter most are the ones that made the
feature either dangerous or inert.

The portal never started at all
-------------------------------
The unit ran `docker start --attach anthias-wifi-connect`. Compose
names the container `anthias-anthias-wifi-connect-1`, so that is
"No such container" and the portal never comes up. It survived three
rounds of hardware testing because every one of them ended at the
ExecCondition skip — the device under test was online each time, so
`docker start` was never reached. The one case that matters, a device
that is genuinely offline, was the one case never exercised.

Pinned `container_name: anthias-wifi-connect` (also the pre-rebrand
name, so journald tags and `docker logs anthias-wifi-connect` in the
docs line up), and added a test that the unit, the compose file and
upgrade_containers.sh all name the same container.

Verified on the Pi 5: `docker start --attach` now brings the portal
up (HTTP 200 on the RFC 8908 endpoint) and `docker stop --timeout 20`
takes it down with no stale NetworkManager profile.

...and when it did, it was ungated
----------------------------------
`restart: "no"` governs restart-on-exit, not the initial start, so
`docker compose up -d` in upgrade_containers.sh started the container
directly and walked straight past the ExecCondition. On the premise
this whole branch rests on — NM's connectivity check is unavailable,
so wifi-connect's own gate always says "show the portal" — that is a
WiFi-connected screen losing its network for a 15-minute portal
session on every single upgrade.

The service now sits behind a compose profile. Verified on hardware
that a profiled service is not started, not created, and not reaped
by `--remove-orphans` once it exists; upgrade_containers.sh creates
it stopped by name so the unit still has something to start.

balena is no longer shipped
---------------------------
Same hole, no lid: the supervisor starts every service in the file on
every boot and release apply, and there is no systemd to hang the
condition on. Whether balenaOS even has the NM problem is untested —
different OS, possibly fine — and that is the point. Removed from
both balena templates with the rationale and the one-command check to
re-enable it, rather than enabling it fleet-wide on an assumption.

Smaller things
--------------
- SuccessExitStatus omitted 6, which is upstream's Outcome::TimedOut
  — the ORDINARY end of an unattended portal session with
  ACTIVITY_TIMEOUT set. The unit went `failed` every time, which is
  the fastest way to teach an operator to ignore it. (6 is also
  PortalUnavailable; noted in the unit that this masks it there and
  the container log distinguishes them.)
- `_poll_wifi_setup_once` caught RequestException/ValueError, but a
  200 carrying a JSON list parses fine and then raises AttributeError
  on `.get`, escaping the handler and leaving the display stranded on
  the setup screen — the one thing the function's own comment says
  must not happen.
- The gate's retry window is 25s, not the "~30s" claimed: the sleep
  falls between attempts, so six attempts means five sleeps.
- QA checklist step 1 asked for journal output the ExecCondition skip
  makes impossible, contradicting step 9.
- Narrowed the compose/constants parity tests to the template that
  actually ships the service.

Reviewed and found sound: the WIFI: payload parser, portal_is_up /
strip_portal_gateway, the data-URI QR path and its size cap, the
_publish_refresh parameterisation and its existing callers, no
circular import between app/views.py and api/views/v2.py, the /data
volume sharing, the passphrase redaction regex against upstream's
actual log line, and that upstream writes the QR before preflight.

Known and not fixed: the host_agent `set_ip_addresses_fast` command
has no fallback, so a new server container running against an old
host_agent sees no cache refresh and the setup screen does not
appear. The ansible path updates host_agent before the containers, so
this is an upgrade-window transient rather than a steady state.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
Copilot AI lite review requested due to automatic review settings October 20, 2026 11:15

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error: Your billing is not configured or you have Copilot licenses from multiple standalone organizations or enterprises. To use premium requests, select a billing entity via the GitHub site, under Settings > Copilot > Features.

The setup page on the device's own display was already Anthias — it is
built on the splash design system. The page the user actually opens on
their phone was not: it said "Anthias" and was upstream's blue.

Upstream documented a white-labelling seam for exactly this (palette.css
calls the role/primitive split "the white-labelling seam" and points at
`--brand-css`; ui/dist/brand.example.css describes itself as served at
/brand.css) but had never implemented it — no flag, no route, and the
CSS inlined at build time with nothing to override.

So it was implemented upstream and released as 2026.10.1
(Screenly-Labs/wifi-connect#3), and this consumes it:

- ansible installs wifi-connect-brand.css to
  /etc/anthias/wifi-connect/, mounted read-only at /brand in the
  portal container.
- WIFI_CONNECT_BRAND_CSS points at it, and the image pin moves to
  2026.10.1. Earlier images ignore the variable silently, so the pin
  and the branding are one change, with a test tying them together.

The stylesheet overrides ROLE TOKENS ONLY — four --color-* properties
and nothing else. Upstream is explicit that the --c-* primitives and
the markup are not a public surface: a rebrand that reaches into them
comes adrift the next time the portal's layout changes, whereas one
that only renames roles keeps working. A test enforces that every
declaration in the file is a --color-* role.

Values are Anthias's own (#ffe11a, the --color-sun-400 accent, on the
#1f002a accent-text aubergine), copied rather than imported because the
portal is a separate container with no access to Anthias's static files
and is served to a phone over an access point with no internet.

Verified on the Pi 5 with the actual released image
- The portal answers 200 and the page links /brand.css exactly once.
- No {{BRAND_CSS}} token leaked into the markup.
- /brand.css is served as text/css; charset=utf-8 carrying
  --color-accent: #ffe11a.
- The page title reads "Anthias setup".
- Teardown left no access point and no stale NetworkManager profile.

2466 tests pass; ruff, mypy and ansible-lint clean; the rendered
compose file parses.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
Copilot AI lite review requested due to automatic review settings October 20, 2026 17:21

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error: Your billing is not configured or you have Copilot licenses from multiple standalone organizations or enterprises. To use premium requests, select a billing entity via the GitHub site, under Settings > Copilot > Features.

Copilot AI lite review requested due to automatic review settings October 21, 2026 06:01

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error: Your billing is not configured or you have Copilot licenses from multiple standalone organizations or enterprises. To use premium requests, select a billing entity via the GitHub site, under Settings > Copilot > Features.

Copilot AI lite review requested due to automatic review settings October 21, 2026 10:17

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error: Your billing is not configured or you have Copilot licenses from multiple standalone organizations or enterprises. To use premium requests, select a billing entity via the GitHub site, under Settings > Copilot > Features.

vpetersson-bot and others added 2 commits October 21, 2026 10:19
…or Sonar

The portal container reads the CSS through a read-only bind mount, so
the directory has to stay traversable. Matches the NOSONAR the file
and unit tasks beside it already carry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
The first pass at this set four accent tokens and called the portal
branded. It was not: the page was a generic card UI with a yellow button
that the portal's own compatibility fallback was overriding anyway, and
it looked nothing like the setup screen on the display in front of the
user. That fallback is fixed upstream in 2026.10.2, along with three UI
defects and a --default-theme that had never been read. This consumes
all of it.

The stylesheet now maps the full role set to the Anthias design system
rather than the accent alone: plum-800 canvas under a 4%-white card,
white text at paper-65 for secondary, fields at paper-6 with a paper-18
border, sun-400 on aubergine for the one button that matters, and the
splash gradient. Values come from palette.css and tailwind.css, copied
rather than imported because the portal is a separate container with no
access to Anthias's static files and no internet behind the hotspot.

Still roles only, with one deliberate exception: the gradient is keyed
on `body`, because there is no role for a background image and `body` is
one of the two selectors a portal layout change cannot rename. A test
pins that — every custom property must be a role, and no rule may name a
component class.

Also:

- WIFI_CONNECT_BRAND_LOGO puts the Anthias mark in the header, installed
  by ansible from the same SVG the splash page uses. A test asserts the
  two files stay byte-identical rather than drifting into lookalikes.
- WIFI_CONNECT_DEFAULT_THEME=dark pins the scheme, which also removes
  the portal's "Switch theme" control. The plum canvas is the brand;
  which scheme a two-minute setup page uses is not a question to put to
  someone trying to get a screen online.

Verified on the Pi 5 against the released 2026.10.2 image
- The page renders the Anthias mark, plum canvas and yellow Connect
  (#ffe11a on #1f002a) with a phone set to light AND to dark: the pin
  holds either way.
- Three access points with two radios each produce three rows, not six.
- No theme switcher in the markup.
- /brand-logo serves image/svg+xml and the image actually loads.
- Teardown left no access point and no stale NetworkManager profile.

2469 tests pass; ruff, mypy and ansible-lint clean; the rendered compose
file parses.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error: Your billing is not configured or you have Copilot licenses from multiple standalone organizations or enterprises. To use premium requests, select a billing entity via the GitHub site, under Settings > Copilot > Features.

…es activity

Scenario-walking the installation flow turned up that
--activity-timeout was a hard session cap: the deadline was computed
once before the portal loop and nothing in the HTTP layer fed it.

The case it breaks is the one this feature exists for. An installer
unboxing five screens reaches the last one thirteen minutes after its
portal came up, joins, scans, starts typing a passphrase -- and the
access point goes away at fifteen. The recovery is worse than the
interruption: the next run generates a new passphrase AND a new SSID
suffix, so the phone does not reconnect and the screen is showing
credentials that are not the ones they wrote down.

On Anthias specifically it compounds, which is why it matters more
here than upstream. Every timed-out run counts against the backoff in
bin/wifi_connect_needed.sh, so three interrupted sessions -- roughly
an hour -- put the device into its six-hour cool-off with a human
standing in front of it, and nothing on the screen says so.

Fixed upstream in 2026.10.9 (Screenly-Labs/wifi-connect#38): the
deadline now runs from the last page load, network list or join
submission. Captive-portal probes deliberately do not count, so a
phone left associated in someone's pocket cannot hold a screen in
setup mode -- that would have quietly disabled the protection the
backoff depends on.

Verified on the Pi 5 with ACTIVITY_TIMEOUT=60 so both arms are
observable:

- Untouched: still up at 32s, exited 6 (TimedOut) by 87s -- the same
  behaviour as before, which is the point. A change of meaning, not
  of duration.
- Polled every 25s: still running at 153s, 2.5x the timeout.
- Probe traffic only (/hotspot-detect.html every 20s): exited 6 by
  88s, so an idle phone does not keep it alive.

2494 tests pass; ruff and mypy clean; the rendered compose parses.

Co-authored-by: multica-agent <github@multica.ai>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings October 21, 2026 21:39

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error: Your billing is not configured or you have Copilot licenses from multiple standalone organizations or enterprises. To use premium requests, select a billing entity via the GitHub site, under Settings > Copilot > Features.

…onstant

The splash page hid a legitimate address. It stripped 192.168.42.1
from the "open this on your phone" list unconditionally, because that
is the portal's gateway -- but only while a portal owns it. A customer
LAN really can be 192.168.42.0/24 and really can hand a screen .1, and
that screen then hid its only address from the one page an operator
reads to find it, on a network where nothing was wrong.

Matching by number was the mistake, and so was having the number here
at all: --portal-gateway and --portal-interface are the operator's to
change, so a constant in this repo is a guess about someone else's
deployment.

wifi-connect 2026.10.10 publishes both in its state file
(Screenly-Labs/wifi-connect#39) -- gateway, and the interface it is
actually on, resolved rather than taken from the flag. The pair is what
identifies the setup address among the host's own.

So:

- read_portal_state returns gateway and interface.
- portal_is_up takes the address the portal declared. A deployment that
  moved the portal is now detected, where before it never would have
  been: the screen would simply never show its passphrase.
- strip_portal_gateway takes None when no portal run is live, and then
  strips nothing. The address is only special while a portal owns it.
- PORTAL_GATEWAY became DEFAULT_PORTAL_GATEWAY and is now only used to
  describe a portal whose state file has not been read. A test parses
  the module and asserts neither decider references it or the literal.

ARP was the wrong table for this, for the record: it lists neighbours,
not which of this host's own addresses belongs to the portal. The local
facts that answer it are the portal's own declaration and per-interface
address assignment.

Verified on the Pi 5, with the portal deliberately moved off the
default to 10.77.0.1 on wlan0

- The state file reports gateway 10.77.0.1, interface wlan0, portal_url
  http://10.77.0.1:8000/; the host holds 10.77.0.1; the portal answers
  200 there.
- portal_is_up against the old hardcoded default: FALSE -- a live portal
  that Anthias would have missed entirely.
- portal_is_up against the declared address: true.
- The splash list drops 10.77.0.1 and keeps 192.168.8.17.
- And the reported case: a device handed 192.168.42.1 by its own LAN,
  with no portal running, keeps that address in the list.

2498 tests pass; ruff, mypy clean; the rendered compose parses.

Co-authored-by: multica-agent <github@multica.ai>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings October 22, 2026 08:56

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error: Your billing is not configured or you have Copilot licenses from multiple standalone organizations or enterprises. To use premium requests, select a billing entity via the GitHub site, under Settings > Copilot > Features.

balena was the one platform this did not reach, and the reason was
never really balena: the gate that decides whether a screen needs a
setup network lived in a systemd unit, and balena has no systemd. A
compose file cannot gate a service either -- the supervisor starts
everything in it on every boot and every release apply -- so shipping
the container there meant an ungated access point across a fleet.

The fix was upstream, where the decision belongs. Before 2026.10.11 a
device online over ethernet alone did not count as online:
NetworkManager answers "unknown" wherever its connectivity check is
unavailable, which is everywhere Anthias runs, and only an associated
radio counted. A wired screen never associates. So the daemon's own
answer on a perfectly healthy wired device was "this needs setup", and
the 100 lines of host-side gate in bin/wifi_connect_needed.sh existed
to overrule it.

2026.10.11 (Screenly-Labs/wifi-connect#40) counts another interface
being up -- wired, a second radio, a modem -- as online, so the daemon
is safe to start unconditionally. That is exactly what balena needs,
and it is why the pin and the absence of a gate on balena are one
decision rather than two. A test asserts the balena templates never
pin below it.

So both balena templates now ship anthias-wifi-connect: host
networking for DHCP, the dbus feature label for NetworkManager,
resin-data for the state file the display side reads, and
restart: "no" because it exits 0 the moment it finds the device
online, which on a healthy screen is the first thing it does.

Verified on wired hardware, which is the case that decides it

- Pi 5 and Pi 3 B+, no host gate and no --force, exactly how the
  supervisor starts it: 2026.10.11 exits 0 in about twenty seconds
  with "already online; the portal is not needed", raises no access
  point and writes no state file.
- The same test on the same boards against 2026.10.10 raises an
  access point. That is the before and after.

What is NOT verified, and needs a balenaOS device: whether the
supervisor's address list carries the setup AP's gateway, which is
what the display side corroborates liveness with. If it does not, a
balena device raises a working portal and the screen shows no
credentials. There is no balenaOS board on the QA rig.

Also not on balena: the branded portal stylesheet and logo, which
ansible installs to /etc/anthias/wifi-connect and ansible does not run
there. The portal is still named Anthias and still pinned to the dark
theme through environment variables; it just wears upstream's colours.
Seeding those two files into the shared volume from the server
container would fix it on both paths at once.

Retry cadence differs by design and the docs now say so: a timer on
Debian, once per boot on balena. Power-cycling asks again on either.

2501 tests pass; ruff, mypy and ansible-lint clean; all three compose
templates render under envsubst and parse.

Co-authored-by: multica-agent <github@multica.ai>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings October 22, 2026 09:43

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error: Your billing is not configured or you have Copilot licenses from multiple standalone organizations or enterprises. To use premium requests, select a billing entity via the GitHub site, under Settings > Copilot > Features.

The balena support shipped with one thing taken on trust: whether the
supervisor's address list reports the setup access point's gateway.
That is what the display side corroborates portal liveness with, so if
it did not, a balena device would raise a working portal and the screen
would show no credentials -- indistinguishable from broken, for the
person standing in front of it.

There is no balenaOS board on the QA rig, so balenaOS 2.83.18
(qemux86-64, NetworkManager 1.28.0, supervisor 12.10.3) went into QEMU
instead. Four things came out of it, three of which are now recorded
next to the code that depends on them.

The supervisor DOES report it. With the address on an interface named
wlan0, /v1/device went from "10.0.2.15" to "10.0.2.15 192.168.42.1"
within about twelve seconds. It filters balena's own bridges --
balena0, supervisor0 and resin-dns all hold addresses and none appear
-- and it is a lagging cache: it kept reporting the address after the
interface was deleted. That is exactly the shape the display side
already assumes, which is why the address list only corroborates and
the state file is the authority.

ConnectivityCheckAvailable is FALSE on balenaOS, which corrects what I
wrote two days ago from reading meta-balena's bbappend. That bbappend
does add `concheck` to PACKAGECONFIG on master, but this released build
predates it. So the "unknown" fallback is the path that runs on balena
too -- the same one Debian takes -- and 2026.10.11's wired check is
precisely what that platform needed. `Connectivity` itself still
answers 4/FULL, which is the trap it has always been: optimistic when
no probe ran.

supervisor0 is a BRIDGE that NetworkManager reports as `connected
(externally)`. Upstream's online check counts ethernet, wifi and modem
devices only, and this is the concrete reason that allowlist was the
right call: a rule that counted any connected device would read every
balena device as permanently online and never raise a portal at all.

D-Bus reaches the container. The pinned image, run with exactly the
env and mount from the balena service block, exits 3 -- "no WiFi device
was found", the VM having no radio -- rather than 5, which is what a
failure to reach NetworkManager looks like. The image also pulls
cleanly under balena-engine.

Still not testable this way: raising an actual access point. The
qemux86-64 kernel has no mac80211_hwsim and QEMU emulates no radio, so
there is nothing for NetworkManager to put into AP mode. That path is
the same code proven on three physical boards; what was balena-specific
was the gate and the address plumbing, and both are now settled.

2501 tests pass; ruff and mypy clean; both balena templates parse.

Co-authored-by: multica-agent <github@multica.ai>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings October 22, 2026 10:12

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error: Your billing is not configured or you have Copilot licenses from multiple standalone organizations or enterprises. To use premium requests, select a billing entity via the GitHub site, under Settings > Copilot > Features.

The portal was Anthias on Debian and upstream's blue on balena,
because the stylesheet and logo were installed on the host by ansible
and ansible does not run on balena. That is not a difference to
explain to a customer unboxing two devices.

The server container already has both files in its image and already
shares a volume with the portal, so it stages them there at startup --
the same thing bin/start_server.sh already does for anthias.conf and
the default assets. Both platforms now point WIFI_CONNECT_BRAND_CSS
and _LOGO at the staged copies, and the host-side install is gone:
three ansible tasks, the /etc/anthias/wifi-connect bind mount, and the
duplicated logo that a test had to keep byte-identical to the splash
page's own file. There is one logo now and nothing to drift.

`cp -f` rather than `-n`, unlike the config files beside it: these are
ours, not the operator's, so an upgrade that restyles the portal
should restyle it.

The ordering hazard this creates is real and is handled upstream
rather than here. A portal that starts before the server has staged
the files -- a first boot where the server runs database migrations
first, or balena's supervisor starting every service at once -- used
to exit 2, which on balena means no portal until someone reboots the
device. wifi-connect 2026.10.12 warns and serves unbranded instead
(Screenly-Labs/wifi-connect#42), so losing that race costs the portal
its colours and not its existence. The pin moves with it, and both
balena templates say why.

That release also carries a fix found by big-claude running this
branch on balenaOS in QEMU: balenaOS has no connectivity check
configured, so NetworkManager guesses from the routing table -- `full`
with a cable in, `limited` with it out -- and 2026.10.11 believed
`limited`. A wired screen on an isolated LAN has no default route, so
it was guessed `limited` and had an access point raised at it.
Verdicts are now trusted only when something measured them.

Verified on the Pi 5 against the released image

- Seeded exactly as start_server.sh does: the portal answers 200,
  /brand.css is text/css carrying #ffe11a, /brand-logo is
  image/svg+xml and byte-identical to the splash logo, and the page
  title is "Anthias setup".
- Branding deleted, same run: the container stays up and the portal
  still answers 200, logging "could not read --brand-css; serving the
  portal unbranded". That is the race, and it is survivable.

2502 tests pass; ruff, mypy, ansible-lint and shellcheck clean; all
three compose templates parse.

Co-authored-by: multica-agent <github@multica.ai>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings October 22, 2026 11:37

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error: Your billing is not configured or you have Copilot licenses from multiple standalone organizations or enterprises. To use premium requests, select a billing entity via the GitHub site, under Settings > Copilot > Features.

…runs the portal

big-claude found the blocker on balenaOS 9.0.3: with restart: "no" the
supervisor treats the exited container as finished and never starts it
again -- not on reboot, only on a new release apply. A screen installed
while online therefore never offers setup after it is moved or after
the WiFi password changes, and power-cycling, which is what our own
docs tell people to do, does nothing.

The obvious fix is restart: always, and the obvious objection is that
the daemon exits almost immediately on a healthy screen. Measured on a
wired Pi 5 rather than argued: 13 container starts in 240 seconds, or
4,680 per device per day, with 19,000 log lines to match -- which on
balena ship to the cloud.

So the exit stays clean and simply happens later. wifi-connect
2026.10.13 waits before exiting
(Screenly-Labs/wifi-connect#43), which turns a restart policy into a
schedule without a resident mode or a second code path. Balena sets
five minutes between checks on a screen that is online, and six hours
after a setup page nobody used -- that session just held the display
for a quarter of an hour, and retrying it every five minutes is how a
site with nothing to join never shows content again. It works out at
roughly a minute in twenty-four of setup screen, which is the budget
the systemd timer and its backoff already give Debian.

Measured again with the cadence in place, same board, same policy:
1,080 starts per day at a 60-second interval, so 288 at the 300 we
ship. Exit code 0. `docker stop` returned in 0 seconds with a wait in
progress, because SIGTERM cuts it short -- a release apply never sits
out a six-hour window.

The Debian path is deliberately untouched: no intervals, restart: "no",
and systemd still owns the schedule. Two schedulers would fight, since
the timer's next tick would land while the last run was still waiting.
A test pins that separation, and another pins that the two balena
intervals differ.

2504 tests pass; ruff and mypy clean; all three templates parse.

Co-authored-by: multica-agent <github@multica.ai>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings October 22, 2026 12:53

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error: Your billing is not configured or you have Copilot licenses from multiple standalone organizations or enterprises. To use premium requests, select a billing entity via the GitHub site, under Settings > Copilot > Features.

The note said the supervisor treats an exited restart: "no" container
as finished and cited the reboot measurement. That is the symptom. The
rule behind it is worth having in the file, because 'surely a reboot
starts it' is the obvious thing to assume and the measurement alone
does not explain why it is wrong.

From the supervisor's own source (compose/app.ts): 'Only start a
Service if we have never started it before and the service matches
target! This is so the engine can handle the restart policy configured
for the container.' An exited service is neither Installing nor
Installed, so it is left alone -- deliberately, with the engine's
restart policy delegated the job. The single exception is a container
the Engine failed to start because a host interface was not ready,
matched by its error message.

And balena-engine, like Docker, only starts containers at daemon start
when their policy is always or unless-stopped. So restart: "no" never
means 'once per boot' on either layer. It is right on Debian because
systemd starts the container there; on balena nothing volunteers.

Also recorded: reboot-only would not be sufficient even if it worked.
The common failure is a screen that stays powered on while the WiFi
password changes around it, and telling an operator to power-cycle a
wall-mounted display is the advice this feature exists to replace.

Comment only. 2504 tests pass, both templates parse.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
Copilot AI lite review requested due to automatic review settings October 22, 2026 13:01

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error: Your billing is not configured or you have Copilot licenses from multiple standalone organizations or enterprises. To use premium requests, select a billing entity via the GitHub site, under Settings > Copilot > Features.

@sonarqubecloud

Copy link
Copy Markdown

@vpetersson
vpetersson merged commit bc001d2 into master Oct 22, 2026
11 checks passed
a10kiloham pushed a commit to a10kiloham/Anthias that referenced this pull request Oct 27, 2026
)

47 upstream commits: viewer tick-start wait anchoring (Screenly#3328), WiFi
captive-portal return (Screenly#3399) + cosign image verification (Screenly#3401),
celery/pipeline/webview reliability fixes, core-dump ulimits, fd-table
bound, WAL/startup ordering fixes, dep bumps, website content (inert).

Fork-side resolutions:
- loops x anchoring unified: replay k ends at tick_start + duration*(k+1),
  so a slot takes exactly duration*loops and co-located players stay in
  step; view_video keeps its skip return under the new deadline signature
- cosign: fork images sign/verify under this repo's docker-build identity
  on Docker Hub (hard-fail; no GHCR pass), gated on PUSH_IMAGES
- wifi-connect pinned to ghcr.io/a10kiloham/wifi-connect — a cosign-copy
  mirror (signatures included) so devices verify upstream's release
  identity while pulling from fork-controlled infra; mirror workflow
  extended accordingly
- expiration stays removed (_parse_local_datetime not resurrected);
  compose keeps robkanthias/* images + takes no-core-dumps ulimits;
  balena workflow/templates stay deleted; fork version metadata adopts
  upstream's 2026.10.0
- both sides' new tests kept (loops tests re-pinned to anchored timeouts)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KncEQmFAq9eGUjf7WCGiEw
@vpetersson-bot

Copy link
Copy Markdown
Contributor Author

Put this through the QA rig end-to-end on real hardware. Summary: the flow works as described, including the two parts that have no precedent in the old integration. Two defects found, neither in this PR's own logic — both are upstream and filed.

What was exercised

Seven boards, all wired, all on Trixie: Pi 5, Pi 4, Pi 3 B+, Pi 3 A+ (32-bit), Pi 2, Pi 1 (armv6), x86.

The gate. Correct on every board that has the unit. Two weeks of uninterrupted history on the Pi 5 and ten days on the Pi 4: every tick skipped with a wired network is connected; skipping the setup portal, no access point ever raised, unit inactive (dead) rather than failed. That is the behaviour the ExecCondition design was for, and it held across ~4,000 activations without a single false positive.

The portal, raised for real. Pi 4, --force, eth0 left up so the box stayed reachable:

"wrote the join QR code"                    path=/data/hotspot/wifi-connect-qr.svg
"found a WiFi device"                       interface=wlan0 driver=brcmfmac
"wrote the portal state file"               path=/data/hotspot/state.json
"scan complete"                             count=3
"access point raised"                       ssid="Anthias Setup-T6XL"
"access point is up; portal is live"
  • iw dev wlan0 info → type AP, channel 6. The unique suffix works; Anthias Setup-T6XL, not a bare fixed name.
  • Portal answers 200 at http://192.168.42.1:8000/, titled Anthias setup, /networks returning the 3 nearby SSIDs.
  • Bound to 192.168.42.1%wlan0:8000 only — not reachable from the wired LAN. Worth stating explicitly since it is a security property nobody has to take on trust.
  • eth0 stayed up throughout; no interruption to the device's real network.

The passphrase reaching a person. /hotspot/ rendered the whole thing: "Join the WiFi network below from a phone or laptop… then open http://192.168.42.1:8000/", with SSID, passphrase and QR. The viewer switched to it on its own — WiFi setup portal is up; showing the setup screen until it comes down.

Teardown. SIGTERM, the same signal ExecStop sends: clean exit 0 in 2.4s, AP down, wlan0 back to managed, the wifi-connect-portal profile gone, state.json removed. /hotspot/ immediately went back to "WiFi setup is not running" and withheld the credentials. The viewer resumed playback, then showed the splash with the device address for 60s. Nothing left behind.

32-bit. This is the one I most wanted to check, since the old portal died of a missing ARMv6 binary. The manifest for 2026.10.13 genuinely carries linux/arm/v6 and linux/arm/v7 alongside amd64 and arm64, and both 32-bit boards pulled arm/v7 and ran it:

  • Pi 2 (no radio at all): clean no WiFi device was found, exit 0.
  • Pi 3 A+: found the device and self-gated correctly — already online; the portal is not needed. The 2026.10.11 wired-detection claim holds on a 32-bit board too.

Two defects, both upstream

  1. A soft-blocked WiFi radio is switched on to look at the device, and left on when the portal turns out not to be needed Screenly-Labs/wifi-connect#50 — find_device enables a soft-blocked radio and nothing turns it back off when the run then decides the portal is not needed. Reproduced 2/2 on the Pi 3 A+: radio rfkill-blocked before, enabled and unblocked after, with no AP ever raised. Low impact on the Debian path because the gate usually skips first; on balena, where this PR deliberately runs the daemon unconditionally every 5 minutes, it means an operator who disables WiFi cannot make it stick. Fix in fix(portal): put a radio we switched on back when the portal is not needed Screenly-Labs/wifi-connect#51 with tests.

  2. The setup passphrase stays on disk after the portal comes down, while state.json is removed Screenly-Labs/wifi-connect#52 — the session's passphrase stays on disk in /data/hotspot/wifi-connect-qr.txt after teardown, while state.json is correctly removed. Minor (0640, root-owned, and the AP is gone) but inconsistent with the deliberate choice not to log it.

One thing for this repo

Not a defect, but it shows up in the measurements: the gate logs four lines per tick, 282 ticks a day, ~8% of all journal volume on a healthy wired screen in perpetuity. Given the journal here is volatile and /api/v2/system/logs (#3423) now reads it, that competes with the history someone needs after a fault. Measurements and a suggested shape in #3268, which already tracks this class of problem.

The rig is back to its prior state: test container removed, QR artefacts cleaned, radios re-blocked on both boards I touched.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants