Repository navigation
feat: bring back WiFi captive-portal setup on Screenly-Labs/wifi-connect - #3399
Conversation
There was a problem hiding this comment.
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>
49332e3 to
bfc7362
Compare
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
…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>
2e67b8f to
595861c
Compare
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>
There was a problem hiding this comment.
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>
There was a problem hiding this comment.
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>
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
…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>
There was a problem hiding this comment.
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>
There was a problem hiding this comment.
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>
There was a problem hiding this comment.
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>
There was a problem hiding this comment.
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>
There was a problem hiding this comment.
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>
There was a problem hiding this comment.
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>
There was a problem hiding this comment.
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>
There was a problem hiding this comment.
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.
|
) 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
|
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 exercisedSeven 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 The portal, raised for real. Pi 4,
The passphrase reaching a person. Teardown. SIGTERM, the same signal 32-bit. This is the one I most wanted to check, since the old portal died of a missing ARMv6 binary. The manifest for
Two defects, both upstream
One thing for this repoNot 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 The rig is back to its prior state: test container removed, QR artefacts cleaned, radios re-blocked on both boards I touched. |



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/nmtuihas 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-connectindocker-compose.yml.tmpl, on upstream's multi-arch image pinned to an exact CalVer release. Nothing is built here.docker compose up -dcannot start it.restart: "no"governs restart-on-exit, not the initial start — without the profile, everyupgrade_containers.shrun 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.shcreates the container stopped, by name, so the unit has something to start.ansible/roles/network, gated onmanage_network) is the only way in:OnBootSec=30s, thenOnUnitInactiveSec=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.anthias-serverpublishes80:8080on the host, so binding there fails outright (upstream reports it asPortalUnavailable, 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.restart: alwaysis the schedule. The supervisor treats an exitedrestart: "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: alwaysis only affordable because the daemon waits before exiting as of2026.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.2026.10.11is 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, theio.balena.features.dbuslabel,resin-datafor the state file,restart: "no".Deciding whether a screen needs setup
As of
2026.10.11the daemon decides this itself, which is what makes it shippable on a platform with no init system. NetworkManager on every Anthias platform answersConnectivityCheckAvailable: false— built without it — so its verdict isUnknownforever, 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 against2026.10.10raises 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
ExecConditiongate, on three grounds — the union of what both predecessors checked, because each alone is wrong in a different direction:iwgetid -rwget --spiderThe portal's own access point is excluded by profile id: with the portal up,
nmclireally does reportwlan0: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
RuntimeDirectorywithPreserve=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,curlanddockerstubbed.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.
resin-datavolume;/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.Screenly-Labs/wifi-connect#34), so the two halves of one flow no longer use different words for one string.--log-passphraseis deliberately not set: a WPA passphrase in journald is worth avoiding when/hotspotis the path an operator actually uses.A distinct network name per screen
PORTAL_SSIDis a prefix as of 2026.10.6; the portal appends four random characters, givingAnthias 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_SUFFIXis deliberately left unset, and a test asserts it stays absent: setting it to0opts 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 installswifi-connect-brand.cssand 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=darkpins 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:WIFI:field splitting and unescaping, recovering strings the portal had in hand.Three signals now decide whether a setup screen shows, each covering the others' blind spot:
portal_activeThe heartbeat is there because of a hardware finding:
SIGKILLthe 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_TIMEOUTis 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.htmlby 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.1any more.--portal-gatewayand--portal-interfaceare the operator's to change, so the portal publishes what it actually used and Anthias reads it:portal_is_upis 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_gatewaygetsNonewhen no portal run is live, and then strips nothing. The address is only special while a portal owns it: a customer LAN can be192.168.42.0/24and 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_GATEWAYsurvives 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
upgrade_containers.shno longerdocker rm -fsanthias-wifi-connect— it is a live service again, and the legacy sweep would tear down a portal mid-setup.srly-ose-wifi-connectstays.collect_debug.shcollects the container's logs and redacts the passphrase line out of the bundle.SuccessExitStatusincludes 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 insystemctl --failedand a timeout does not.NOSONARof mine that read as a suppression directive in its own right.On
network_mode: hostThis 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 scratchbuild.Verified
Local gates:
ruff check+ruff format --checkclean; strictmypy .212 files 0 issues;ansible-lint ansible/at theproductionprofile;shellcheckclean;systemd-analyze verifyclean on the unit, on the board's own systemd; all compose templates render underenvsubstand parse viadocker compose config; SCSS compiles.pytest -m "not integration": 2504 passed, 3 skipped — 115 of them intests/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:
/brand.css,/brand-logoand/networksanswering, 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 inWIFI_CONNECT_QR_OUTPUTand Anthias's ownparse_qr_payloadreading 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.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 enableis refused. State cleared once the device is found online.Anthias Setup-G4VVandAnthias 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 bareAnthias Setup. The collision shown gone over the air, not inferred from the code.iw devreports 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; andSIGKILLleaves the AP up, the gateway held and the file behind — and the screen still goes away, because the beats stopped.ACTIVITY_TIMEOUT=60so 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.10.77.0.1/wlan0instead of the default. The state file reports it, the host holds it, the portal answers 200 there,portal_is_upagainst the old default returns false for that live portal while the declared address returns true, and the splash list drops10.77.0.1and keeps192.168.8.17. A device handed192.168.42.1by its own LAN with no portal running keeps that address.--force: exit 0, no access point, no state file, "already online; the portal is not needed". Against2026.10.10, an access point.start_server.shdoes: portal 200,/brand.cssistext/csscarrying#ffe11a,/brand-logoisimage/svg+xmland 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.The two upstream blockers noted earlier in this PR are both resolved:
ghcr.io/screenly-labs/wifi-connectnow 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:wlan0,/v1/devicewent from10.0.2.15to10.0.2.15 192.168.42.1within ~12s. It filters balena's own bridges (balena0,supervisor0,resin-dnsall 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.ConnectivityCheckAvailableisfalseon balenaOS, same as Debian — so theUnknownfallback is the path that runs there too, and2026.10.11's wired check is precisely what that platform needed. (Connectivityitself still answers4/FULL, optimistically, which is the trap.)supervisor0is a bridge NetworkManager reports asconnected (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.3("no WiFi device was found" — the VM has no radio) rather than5, which is what failing to reach NetworkManager looks like.Not testable that way: raising an actual access point, since the
qemux86-64kernel has nomac80211_hwsimand 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.shalready seedsanthias.conf. The host-side install is gone: three ansible tasks, the/etc/anthias/wifi-connectbind 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.12warns 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 —
fullwith a cable in,limitedwith it out — and2026.10.11believedlimited. A wired screen on an isolated LAN has no default route, so it was guessedlimitedand had an access point raised at it. Verdicts are now trusted only whenConnectivityCheckAvailablesays something measured them.