"""Version information for the rXg WebSocket client."""

# 1.0.1: Boot Info
# 1.0.2: Cooperative thread shutdown, re-validation for RRM jobs
# 1.0.3: hapd_deauth.py for wired+wireless client deauth via hostapd ctrl socket,
#         GRE interface auto-UP fix,
#         gateway.json hardening (overwrite /certificates/ copy, stop native ucentral),
#         client version sync moved to subscription confirmation
# 1.0.4: GRE SDAN tunnel management, bridge VLAN filtering, MPSK multi-VLAN support,
#         non-GRE 802.1Q VLAN trunking, rrmd lifecycle management via ieee80211k_enabled,
#         disconnect monitor (syslog reason codes), hostapd event hardening
# 1.0.5: DFS radar-detected event forwarding to rXg,
#         remove health.uc periodic execution (unused, error-prone)
# 1.0.6: Firmware upgrade sha256sum diagnostics and hardening,
#         EAP111 LED sysfs auto-detection and UCI trigger management,
#         AnyCable/telemetry log noise reduction (info → debug)
# 1.0.7: rxg_sdan kernel module integration (ipq53xx/ipq60xx/mediatek), VXLAN support,
#         wired 802.1X via ieee8021x.py, protocol-agnostic tunnel_type/tunnel_gateway_ip columns,
#         per-SSID tunnel discrimination (non-tunneled/GRE/VXLAN on same AP),
#         sdan plain VLAN mode for non-tunneled SSIDs
# 1.0.8: Client experience ping monitoring via network namespaces for sdan-enabled APs,
#         sdan-aware tunnel detection in gre_tests.py,
#         remove wifi-client-present requirement for sdan monitoring (legacy path retains it)
# 1.0.9: rg_wss_client version sync — AP self-restarts miniloader when rXg state
#         carries a different wss_client_intended_version, enabling automatic client
#         upgrades after an rXg update without manual AP reboot
# 1.0.10: Fix per-SSID tunnel discrimination — _cache_ssid_for_object was calling
#         the non-existent ubus method 'status' instead of 'get_status', causing
#         iface_ssid_map to never populate and every SSID to fall back to the
#         legacy global tunnel; surface ubus stderr in the warning so similar
#         method-name bugs are caught immediately
# 1.0.11: RADIUS-driven SDAN tunnel selection — wireless via hostapd
#         radius_attrs (MR 73), wired via Python RADIUS client
#         (wired_radius_client.py); decodes RGNets PEN 38131 VSAs
#         (SDAN-Tunnel-Type, SDAN-Tunnel-Endpoint) and overrides per-SSID
#         / global tunnel defaults at sta-authorized time. Hostapd-on-
#         wired removed for MAB-only deployments (hospitality/MDU); rides
#         kernel's last-writer-wins-per-VLAN semantic for natural endpoint
#         failover. Wired Accounting + CoA deferred to follow-up MRs.
# 1.0.12: rxg_endpoints state format converted from flat array to
#         {load_balancing, failover} dict. Load-balancing list is the
#         existing master + use_for_wss_management-flagged standbys;
#         failover list is the other configured controllers from
#         bootstrap.yml in priority order, so an orphaned AP whose
#         current controller died can find a promoted standby that
#         was not pre-flagged for WSS load. AP normalizes both the new
#         dict format and the legacy flat-array format on the way in,
#         persists in canonical dict shape, walks LB hosts first
#         (random-shuffled) then failover hosts (priority order).
#         Persistence is idempotent — _store_rxg_endpoints compares the
#         incoming normalized dict against the on-disk file (also
#         normalized, so a legacy flat-array file compares equal to a
#         dict carrying the same endpoints) and skips the write when
#         they're equal, avoiding unnecessary flash writes and keeping
#         steady-state logs quiet without changing semantics.
# 1.0.13: Pstore uploader (issue rxg#3854 Tier 1) — at startup, ship any
#         non-empty /tmp/{crashlog,consolelog,pstore} files harvested by
#         ucentral-pstore from the previous boot to the rXg via the existing
#         batch_ingest path; in-memory (kind, sha256) set dedupes across WSS
#         reconnects within a process while a fresh rg_wss_client process
#         always re-attempts delivery (closes the "receipt written but message
#         dropped by send_batch_updates" hole); rXg-side EXTRACT_CRASHLOG /
#         EXTRACT_CONSOLELOG / EXTRACT_PMSG ingestors enrich DeviceRebootEvent
#         with reason classification + raw payload on CommandExecution.
#         Upload is queued from the subscription-confirmed handler in
#         websocket_client.py rather than from main() so the messages aren't
#         silently dropped by the stale-batch drain at subscription confirm
#         (commit c4ea0c38bd).
# 1.0.14: GatewayMonitor stability — stop+disable ucentral-event and
#         ucentral-state at startup (hoisted to a module-level
#         disable_conflicting_services() called from main() immediately
#         after create_gateway_json(), before the long-blocking
#         _load_sdan_kmod / ensure_certificate_ready steps, so the race
#         window in which ucentral-state's self_healing() can fire
#         /etc/init.d/network restart is minimized); also call
#         _stop_ucentral_state() after each ucentral.uc config-apply,
#         because ucentral.uc unconditionally fires
#         `/etc/init.d/ucentral-state restart` and that explicit restart
#         bypasses procd's `enabled=no` — without re-killing it
#         post-apply, ucentral-state's self_healing() comes back and
#         fires /etc/init.d/network restart, sending DHCPRELEASE on
#         up0v0 and causing Nokia PON OLTs to drop the upstream
#         anti-spoof filter, leaving APs unreachable;
#         raise MAX_MISSED_PINGS 2→12 (60s tolerance) so DHCP renewals
#         and brief blips don't trip wpad shutdown; restore stop_wpad()
#         on sustained loss now that the cascade is broken; split route
#         handling into a new STATE_CHECK_ROUTE peer of CHECK_PING /
#         CHECK_HOSTAPD that re-reads the default route every pass,
#         seeds a route_lost_at timer on loss, and flips reachable=False
#         after ROUTE_LOST_THRESHOLD_SECS (60s) of sustained loss — fixes
#         a gap where AP-side link loss (or any fast route teardown)
#         removed the route before 12 missed pings could accumulate, so
#         wpad stayed up over a black-hole AP; run gateway and
#         optimization checks via run_in_executor so blocking subprocess
#         calls don't stall the asyncio event loop; keep the post-start
#         wifi(1) call in start_wpad() — without it, netifd's failed
#         hostapd recovery (HOSTAPD_START_FAILED → retry=0) leaves the
#         SSIDs offline after wpad restart, since the wpad init script
#         alone only starts the global hostapd daemon with no per-VAP
#         config. The wifi(1) reload's udhcpc renew on up0v0 is not a
#         DHCPRELEASE and does not trigger the Nokia PON cascade — that
#         cascade is broken at the ucentral-state layer above.
# 1.0.15: PLPMTUD-driven uplink MTU discovery (RFC 4821 pattern). Sidesteps
#         the PMTUD blackhole where an upstream L2 switch silently drops
#         oversized frames without emitting ICMP "fragmentation needed":
#         classical PMTUD never sees the bottleneck because it's a layer-2
#         drop, so we probe path reachability directly with DF-set ICMP
#         Echo and treat reply-timeout as the "too big" signal.
#
#         Probe model: ascending 4-anchor (1500 / 1518 / 1542 / 9000) covers
#         standard Ethernet, single-VLAN-tagged Ethernet, PON/operator-encap,
#         and jumbo backbones. Anchors clipped to per-platform hardware caps
#         (HARDWARE_MTU_EXCEPTIONS — e.g. MT7981 / EAP111 cap at 1518, so
#         the 1542/9000 probes are skipped). Per-probe just-in-time iface
#         raise (vs hold-high): the iface only exceeds the applied MTU for
#         a single ICMP round-trip; `_campaign_lock` serializes whole
#         campaigns so a refresh tick and an operator `mtu_discover` can't
#         interleave their iface raise/restore.
#
#         Discovery destinations: CP (RADIUS auth/acct servers from
#         `bridge.current_state` — includes top-level wired_radius servers
#         that don't reach the on-disk ucentral.active file) and DP (sdan
#         tunnel remotes from `rxg_sdanctl -j tunnels`). MP (the rXg's own
#         management IP) is intentionally NOT probed: CP and DP destinations
#         already exercise the binding underlay constraint and adding MP
#         doesn't provide additional information.
#
#         Per-host MTU caches (`_cp_mtus`, `_dp_mtus`) with applied uplink =
#         min(all known plane MTUs), persisted to /tmp/rxg_mtu_state.json
#         along with `_applied_uplink_mtu`. Crash recovery: if the process
#         dies mid-campaign with the iface raised, the next start lowers
#         it back to the persisted applied MTU. Restart restores cached
#         plane MTUs immediately, avoiding ~60s of FLOOR_MTU regression
#         while the refresh loop catches up.
#
#         Lifecycle: `apply_initial_mtu()` runs synchronously pre-asyncio
#         (no probing, no DNS) and snaps the uplink stack to the persisted
#         applied MTU if available, else FLOOR_MTU = 1500. The asyncio
#         refresh loop ticks every 60s, calling `refresh_cp` / `refresh_dp`
#         which probe only on host-set delta. WSS state pushes
#         (`UbusBridge.set_current_state`) nudge `refresh_cp` immediately
#         so a probe fires as soon as we know what RADIUS server to test
#         against, instead of waiting up to 60s. Periodic re-confirmation
#         every 6h ± 30min re-probes all cached hosts and surfaces
#         path-improved (INFO) / path-degraded (WARN) transitions.
#
#         `mtu_manager` is the single source of truth for the uplink MTU:
#         `optimizations.sync_interface_mtu` skips `eth0/up/up0v0`
#         unconditionally so ucentral-driven config writes can't clobber
#         the probe-derived value. The drift check in
#         `_apply_uplink_mtu_locked` peeks at the live iface MTU and
#         re-applies on detected drift — catches the class of stuck-at-1500
#         bugs where ucentral.uc / netifd / hostapd reset the iface MTU
#         out from under us. After each apply, the value is also pushed to
#         the rxg_sdan kernel module via `rxg_sdanctl set-tunnel-mtu` so
#         sdan-emitted GRE/VXLAN packets stay within the same on-wire
#         ceiling; on rxg_sdanctl failure `_applied_uplink_mtu` is NOT
#         updated, so the next apply retries instead of short-circuiting.
#
#         Failover (`websocket_client.py`): on a different rXg becoming
#         the subscription target, invalidates CP/DP caches and calls
#         `apply_initial_mtu` against the new underlay; the refresh loop
#         picks up the new plane state on its next 60s tick.
#
#         Operator diagnostic: `ubus call rg_wss_client mtu_discover`
#         invalidates the caches and re-probes all known CP/DP hosts at
#         every anchor, returning per-anchor per-attempt detail (rtt_ms,
#         result=ok/timeout/local_reject) plus before/after iface MTUs.
#         DP host enumeration shells out to `rxg_sdanctl -j tunnels`
#         synchronously (the async `RxgSdan.get_tunnels` can't be awaited
#         from the ubus thread).
#
#         Probe robustness: parse IPv4 IHL when reading echo reply (IP
#         options ok), native-endian /proc/net/route parsing (works on BE
#         arches), catch async ICMP errors via recvfrom OSError, re-set
#         per-recvfrom timeout to the remaining deadline (stray traffic
#         can't stretch the probe), tristate return (True/False/None) so
#         EMSGSIZE local rejection short-circuits the retry loop, source-
#         address-filtered replies (defends against stray ICMP from local-
#         segment hosts).
#
#         Cache-update and apply (which shells out to rxg_sdanctl) are
#         dispatched via `asyncio.to_thread` to keep the event loop
#         responsive. Iface-iteration direction is computed from the live
#         iface stack via `_direction_from_live_iface`, so a strict-VLAN-
#         parent kernel ordering can't ERANGE on raise/lower.
#
#         Logging is tiered for steady-state quietness: INFO for state
#         changes only (handful of lines per AP per day); DEBUG for per-
#         probe / per-sysfs machinery; WARNING for path degradation,
#         resolver failures, sysfs write failures, crash-recovery
#         activation. Test coverage: `test_mtu_manager.py` with unittest
#         cases over `_extract_radius_hosts`, `_hw_clipped_anchors`,
#         `_current_uplink_mtu_locked`, `_read_sdan_tunnel_remotes`, and
#         pmtud_probe's IHL/checksum logic.
#
#         Late-iteration review fixes: dropped the per-state-push
#         `invalidate_mtu_cache` trigger from `update_smart_monitor_config`
#         (vestigial after `rxg_gre_mtu` removal and the move of RADIUS-
#         host extraction to `bridge.current_state` — disk-file mtime no
#         longer affects any MTU-relevant input, so the invalidate was
#         pure noise that threw away valid measurements); added a
#         generation counter so remaining invalidate paths (failover hook,
#         operator diagnostic) cleanly discard in-flight probe results
#         instead of letting them re-populate the post-invalidate cache;
#         `_apply_with_lock` and `apply_initial_mtu` now acquire
#         `_campaign_lock` to serialize with in-flight probes;
#         `NetworkOptimizer._effective_eth0_mtu` cache removed so derived
#         (GRE / wifi-VLAN) iface MTUs always track the live uplink
#         decision rather than the boot-time FLOOR_MTU snapshot.
# 1.0.16: Proactive wired-port RADIUS reconcile — DownstreamPortManager runs a
#         background reconcile_loop (adaptive 5s while converging / 30s steady) so
#         wired MAB / 802.1X ports get initialized and recover without depending on
#         an rXg state push. Previously wired-port setup ran only inside
#         set_current_state -> _start_client_experience_monitor -> update_from_state,
#         so a missed push left ports uninitialized and no RADIUS was ever sent for
#         plugged-in wired clients. update_from_state is now serialized via a lock and
#         logs at INFO only when the config changes, keeping the periodic reconcile quiet.
# 1.0.17: Fix native-bridged (non-RADIUS, non-tunneled) SSID clients getting
#         no DHCP/connectivity on sdan-kernel APs. The SDAN wlan-ap kernel is
#         built with CONFIG_BRIDGE_NETFILTER=y, whose bridge-nf-call-* sysctls
#         default to 1 — pushing bridged client frames through OpenWrt fw4,
#         whose forward policy is REJECT and whose raw bridge ports belong to
#         no firewall zone. Result: clients associate and complete the WPA
#         handshake, but DHCP and all forwarded traffic are rejected, so they
#         never get an IP and are dropped for inactivity. At startup we now
#         set bridge-nf-call-{iptables,ip6tables,arptables} to 0 so bridged
#         traffic bypasses fw4 (the rXg is the security boundary; the AP is an
#         L2 bridge). Done unconditionally and before the sdan probe — the
#         breakage exists whenever the kernel has br_netfilter, regardless of
#         whether the sdan module loads; sdan itself doesn't need it (it hooks
#         via NF_NETDEV_INGRESS, before the bridge). Writes are no-ops on
#         kernels without br_netfilter (sysctl files absent).
# 1.0.18: HostapdManager — a parallel reconciler (peer of DownstreamPortManager,
#         kept separate from the listen-only hostapd event subscriber) that keeps
#         hostapd per-BSS runtime settings aligned with the pushed config by
#         re-asserting them via the hostapd control socket (AF_UNIX SOCK_DGRAM,
#         same as hapd_deauth.py — no hostapd_cli/ubus dependency, no subprocess;
#         immediate, no restart).
#         First managed setting: ap_isolate, workaround for a netifd bug (rXg
#         #3967). netifd (pinned commit 8587c074, unchanged across firmware
#         4.2.1–4.2.4) emits isolate=true to hostapd for an SSID even when UCI
#         isolate='0' / isolate-clients=false, so ap_isolate=1 blocks intra-BSS
#         client-to-client traffic (mDNS/Bonjour, AirPlay) regardless of the
#         operator setting. netifd re-stamps the bad value on every hostapd
#         config regeneration (config apply, reboot, wpad recovery, RRM
#         channel/tx-power, DFS, netifd hostapd recovery), so a one-shot fix at
#         apply time leaks. Correction is real-time via two nudges — the hostapd
#         event subscriber sets shared_state.hostapd_changed_event on a hostapd
#         (re)start / new BSS, and set_current_state calls reconcile_once() on
#         config apply — backed by a 5s steady-state safety-net loop (the loop
#         waits on that event with the interval as timeout). Remove the
#         ap_isolate reconcile once the netifd bug is fixed at the firmware level
#         (see wlan-ap issue).
# 1.0.19: Upgrade-guard self-heal for staggered/batched client rollouts
#         (issue rxg#3973). The /tmp guard written before a miniloader
#         restart was only ever removed on a launch failure, so it lingered
#         after a successful upgrade — and, more importantly, a *failed*
#         upgrade to a given version left a guard matching that version that
#         permanently suppressed every later restart attempt for the same
#         target until the next reboot cleared /tmp. In a controlled,
#         small-batch rollout (the remediation the 2026-06-03 Axia incident
#         drove into a supported default) that reads as an AP silently
#         refusing to converge. Now, when a state push confirms the AP is
#         already running the intended version, any stale guard is removed,
#         bounding the guard's lifetime to exactly the upgrade-in-flight
#         window while still preventing tight restart loops mid-upgrade.
#
#         Also: population-adaptive jitter for the client-version upgrade
#         (issues rxg#3973 / rxg#3971). The upgrade-triggered miniloader
#         restart used to fire immediately, so a cohort told to upgrade — a hot
#         rollout, or a whole site reconnecting after an OS-reboot upgrade —
#         restarted + re-subscribed in lockstep and could storm the rXg
#         AnyCable RPC pool. The restart is now deferred by a deterministic,
#         per-MAC delay drawn from a window that scales with the managed-AP
#         population (deployed_access_point_count from the rXg state push):
#         window = clamp(population / 3, 0, 1800s), so the per-second
#         re-subscribe rate stays ~3/s at any fleet size (cohorts < 20 APs are
#         immediate). This offloads herd-pacing to the AP so the controller no
#         longer needs a fragile fixed batch size. Because we already jitter
#         here, we also write /tmp/rgminiloader_skip_jitter so rgminiloader can
#         skip its own cold-boot jitter on this coordinated restart and avoid
#         double-jittering (rgminiloader honoring the marker is a follow-up
#         wlan-ap change; until then its benign 1-60s just adds on top).
#
#         Post-reboot reconnect pacing (issue rxg#3971). A controller OS-update
#         reboot drops every AP's WebSocket at once and they reconnect in
#         lockstep — there's no push to pace it. After CONTROLLER_DOWN_THRESHOLD
#         consecutive failed connection attempts (the controller is unreachable,
#         i.e. a mass reboot, not a single-AP blip), websocket_client switches
#         its retry wait from fast exponential backoff to a deterministic
#         per-MAC delay over a population-scaled window (calculate_reconnect_delay,
#         ~population/10 capped at 900s), so the fleet's re-subscribes spread
#         out instead of saturating the AnyCable RPC pool. A successful
#         subscription resets the streak, so isolated blips keep fast recovery.
#
#         Closed-loop admission gate (issue rxg#3971). Before (re)opening the
#         WebSocket the AP polls a cheap HTTP endpoint
#         (/api/access_points/wss_admission, off the AnyCable RPC pool so it
#         stays answerable during a storm); the controller answers admit/wait
#         from the current connection_state="Connecting" backlog. Honors a "wait"
#         for at most ADMISSION_MAX_WAITS rechecks (jittered) and treats any
#         error as admit — fail-open on both ends, so the gate can never be the
#         reason a connectable AP doesn't connect. Complements the open-loop
#         reconnect jitter above: the AP self-spreads, and the rXg can still say
#         "not yet" when admission is genuinely saturated.

# 1.0.20: Recover from a dropped/deferred initial state push. The initial push is
#         deferred through the WSS processor (MR 5124) and can be lost; after
#         subscribing, the AP now re-requests state from the rXg every 60s until it
#         arrives (request_state action) rather than staying unconfigured. Tasks
#         that need state (connectivity, GRE, LED monitors) keep waiting, and the
#         LED shows a slow blue blink ("subscribed, awaiting configuration") so the
#         failure mode is visible on the unit.
# 1.0.21: rxg_sdan is now the MANDATORY dataplane for openwifi_wss APs. Removed the
#         entire legacy non-sdan bridge/VLAN/GRE dataplane — there is no fallback:
#         the gutted station-tunnel manager is a thin sdan refcounter, the legacy
#         bridge-VLAN branches in ieee8021x / downstream_port_manager are gone, and
#         connectivity_tester + ConnectivityTestSuite + vlan_interface_manager +
#         arping_responder + the gre4t-gre bring-up (ensure_gre_interface_up) are
#         deleted. On sdan-init failure the AP hard-fails (CRITICAL log → rXg syslog,
#         no client dataplane) but stays WSS-connected for management rather than
#         falling back. swconfig hardware-switch programming (SwconfigIEEE8021XManager)
#         is retained — it runs alongside sdan, not as a fallback. The client-
#         experience monitor and tunnel-health LED check are likewise sdan-only and
#         encap-agnostic (VXLAN/softGRE); the probe's isolated veth+namespace is now
#         stood up via the kernel module's own `rxg_sdanctl create-diag <vlan>` (with
#         destroy-diag teardown) instead of a hand-rolled veth/netns, accepting its
#         deterministic kernel-derived MAC — if the bundled rxg_sdanctl lacks
#         create-diag, client-experience monitoring is disabled on that AP. Renamed
#         the pervasive GRE-era naming now that the dataplane is sdan:
#         GREConfigurationAnalyzer → SdanTunnelAnalyzer, has_gre_configuration →
#         has_tunnel_configuration, telemetry field gre_peer → tunnel_peer, test_type
#         client_perspective_gre_ping → client_perspective_tunnel_ping; modules
#         gre_tunnel_manager → station_tunnel_manager (GRETunnelManager →
#         StationTunnelManager), gre_monitor → tunnel_monitor, gre_tests → sdan_tunnel.
#         Also renamed the rXg→AP state field gre_tunnel_peer → tunnel_peer (it carries
#         any encap's peer, not just GRE) across the producer + Python/Go consumers, and
#         removed the now-dead gre4t MTU code from optimizations.py (the sdan kernel
#         module creates no gre4t* interfaces) — wifi-VLAN sub-ifaces now track the full
#         effective uplink MTU. Genuine GRE protocol refs (encap types, gre4t kernel
#         names, the gre_endpoint AP field) are unchanged.

# 1.0.22: Fix cert_sync never reaching the rXg. The cert_sync batch message was
#         queued in main() before the WSS subscription was up, so the
#         subscription-confirm stale-clear drained it every time — no AP ever
#         delivered a cert_sync and access_points.csr/.certificate stayed NULL
#         whenever the one-time adoption write didn't persist. Moved cert_sync
#         into the subscription-confirmed handler (next to client_version_sync,
#         past the stale-clear) so it fires on every (re)subscribe and self-heals;
#         the rXg ingestor is idempotent and only writes on an actual change.
# 1.0.23: Version-scope rootfs overrides. sync_rootfs_for_model() now recognizes
#         `\d+\.x` (e.g. 4.x, 5.x) top-level subdirs under rootfs/<model>/ and
#         syncs the one matching the AP's rgnets-era major version (matching
#         subtree flattened against /); unversioned content still syncs on
#         every version. Moved cig_wf189's hand-curated /lib/upgrade/{nand,
#         platform}.sh under rootfs/cig_wf189/4.x/ so they only land on 4.x
#         APs — on 5.x firmware QCA ships newer ipq53xx scripts (ubi_sysfs_root
#         portability helper for /sys/class/ubi vs /sys/devices/virtual/ubi,
#         plus per-model downgrade protection) that our 4.x-era override would
#         silently clobber. Rebasing the rgnets reclaimed-space + wifi_fw
#         cleanup behaviors onto a 5.x override is follow-up work pending lab
#         validation; this bump just stops the unconditional overwrite.
#
#         Version detection is two-tier and follows the wlan-ap repo's coupling
#         of rgnets-era major to OpenWrt baseline (rgnets 4.x sits on 23.05 /
#         24.10; rgnets 5.x sits on 25.12). First read DISTRIB_TIP_VERSION —
#         released firmware reports 'X.Y.Z' from which we extract the major.
#         Dev / snapshot builds report 'devel'; fall back to DISTRIB_RELEASE
#         and map baseline to era (23.05/24.10 -> 4, 25.12+ -> 5). Truly
#         unmatched values return None, which the sync skips with a WARN so
#         the failure mode is visible in logread.
# 1.0.24: Fix cold-boot sdan HARD-FAIL on RTC-less APs (e.g. WF-189). The rxg_sdan
#         kmod + rxg_sdanctl fetch ran before ensure_certificate_ready()'s
#         wait_for_sane_clock() gate, so on a cold boot with a bogus pre-NTP clock the
#         fetch's TLS server-cert validation failed, nothing installed, and — because
#         sdan availability is probed once and latched (no runtime re-probe) — the AP
#         stayed WSS-connected for management but dataplane-dead (clients pass no
#         traffic) until a manual reboot happened to win the NTP-vs-fetch race. Moved
#         the fetch to AFTER ensure_certificate_ready (clock sane, network settled, and
#         /etc/ucentral/cert.pem present so the client cert can be offered), and added a
#         bounded retry (3 attempts / 3s backoff) around the fetch so a transient
#         rXg/network unreadiness during mass onboarding self-recovers instead of
#         stranding the dataplane.

# 1.0.25: OpenWiFi sensor mode — wireless client sensor (VRF-based radio isolation
#         for client-experience ping/speed tests), ethernet uplink sensor, sensor
#         profile propagation, band-aware PHY grouping, hardware isolation fallbacks
#         for BCM43455/brcmfmac (Pi), deterministic rg_wss_client bundle packaging.
#         Adds Speedtest.net support: speed_test_executor.run_speedtest_net() runs
#         the bundled speedtest-cli (speedtest.py, vendored into the tarball) for
#         test_type='speedtest', VRF/DNS-aware and sharing the global speed-test
#         serialization lock, returning the iperf3 result schema; dispatched on
#         test_type, which the rXg now sends in the sensor speed_tests config.

# 1.0.26: Unify the sensor test-result contract across the wireless and uplink
#         sensors. The wireless client sensor now reports the test-config id as
#         'test_id' (was per-type target_id/dhcp_test_id/dns_test_id/speed_test_id)
#         and test_type='speed' (was 'speed_test'), matching the uplink sensor.
#         The rXg ingestor reads the id via ByCommand.sensor_test_id, which still
#         accepts the legacy per-type keys so APs on an older bundle keep working.
#         Also ships ap_speedtest per-arch (arm64/armv7): the AP detects its
#         machine arch and requests the matching binary via
#         get_ap_speedtest?arch=<arch> (served from the ap_speedtest/ folder on
#         the rXg), installs it to /tmp (tmpfs, re-fetched each boot), and skips
#         the download when the arch is unsupported or /tmp lacks the free space
#         to stage it (MIN_SPEEDTEST_TMP_MB in speed_test_executor.py). The same
#         arch+space gate feeds the speed_test capability check as a
#         'speedtest_net' requirement; the mode now requires BOTH iperf3 and
#         speedtest.net to be usable.
#         Wireless sensor ping is now windowed: each target is pinged
#         continuously across PING_SUMMARY_WINDOW_S at its configured interval
#         (as the -i spacing) and reports ONE aggregate per window instead of one
#         result per ping — dense sampling, far less WSS/ingestion volume.
#         The connection BSSID/channel cache is refreshed on every periodic
#         health check (not just once at connect) so it tracks wpa_supplicant
#         roams AND recovers slow-associating radios (2.4 GHz) whose initial
#         connect-time cache missed; a transient iw read failure keeps the prior
#         value instead of nulling it. This fixes 2.4 GHz reporting zero metrics:
#         the rXg ingestor drops any SensorMetric with no BSSID, so a stuck-null
#         cache silently discarded every 2.4 GHz sample. The batch queue is now
#         drained on WSS disconnect so a reconnect never flushes a stale backlog
#         (the cause of oversized 1009 batch messages); the per-batch "sent"
#         log dropped to debug.
#         The sensor wpa_supplicant config is written to a deterministic per-band
#         path (wpa_sensor_<band>.conf) rather than one built from the SSID, so an
#         operator-controlled SSID containing '/' or '..' can't become a path
#         component.
#         ap_speedtest resource gating is hardened: the pre-download check now
#         also requires MemAvailable headroom (not just free /tmp) since the
#         binary stages into and runs from tmpfs (RAM); the install hardlinks the
#         cached binary into place instead of copying it (no second RAM-resident
#         copy); and a size cap (MAX_SPEEDTEST_BIN_MB) refuses an oversized/stale
#         binary rather than filling tmpfs.
#         The speed_test capability now reports speedtest_net based on whether the
#         ap_speedtest binary is actually installed and executable (ground truth),
#         not on the pre-download arch/space gate — so it is fail-safe and never
#         advertises speedtest.net when the binary was skipped or refused (the
#         arch/resource probes only build the reason string).
#         The ath12k MLO split/re-merge reboot markers now live under /etc (they
#         must survive the reboot they trigger; /tmp is tmpfs and is wiped on
#         reboot), a surviving marker is treated as "already rebooted, parameter
#         didn't take effect" so the AP no longer reboot-loops on drivers that
#         ignore mlo_capable, and the marker is cleared once the target state is
#         reached.

# 1.0.27: WSS failover hardening. Two changes:
#         (1) Last-resort DHCP-224 fallback in websocket_client. Previously the
#         option-224 / --host value was only used when the persisted
#         rxg_endpoints file was empty; if the file HAD endpoints but every one
#         was unreachable, the AP cycled the dead hosts forever and never fell
#         back. Now, once consecutive connection failures reach
#         max(len(endpoints), CONTROLLER_DOWN_THRESHOLD) — the whole configured
#         set is down, not a single blip — the current DHCP option 224 host is
#         re-read fresh and appended as a last-resort candidate, so an AP
#         orphaned by all its endpoints (e.g. after the rXg moved and handed out
#         a new 224) can find its way home. It drops back out of the pool once a
#         connection succeeds and resets the failure streak. Deepest tiers, keyed
#         off DHCP-224-host failure count: at DHCP_FALLBACK_RENEW_THRESHOLD (3)
#         the AP forces a udhcpc lease renew (SIGUSR1 to /var/run/udhcpc-*.pid) so
#         a changed option 224 the still-valid lease hasn't picked up gets
#         rewritten to /tmp/dhcp-option-224 — a RENEW, not a release/restart, so
#         it avoids the /etc/init.d/network DHCPRELEASE cascade that drops the
#         uplink on Nokia PON OLTs; if the host still fails at
#         DHCP_FALLBACK_RESTART_THRESHOLD (6), it restarts rgminiloader to
#         re-bootstrap from scratch (re-pull config/certs, redo DHCP discovery),
#         waits a 30s grace period, then re-enters the connect loop.
#         DHCP-224 discovery now reads /tmp/dhcp-option-224 (the file current
#         firmware's udhcpc writes and rgminiloader reads), falling back to the
#         legacy /tmp/option224 that older firmware wrote; it no longer consults
#         TIP's /tmp/cloud.json, which plays no role in our flow. The connect
#         loop reads this fresh each round as the source of truth rather than
#         trusting the frozen --host arg, which is now only a defensive default.
#         (2) rXg side (device_option.rb): a node now includes ITSELF in the
#         endpoints it hands its APs — the failover tier no longer excludes
#         this_node, so a standby not opted into use_for_wss_management (absent
#         from the load-balancing tier) still appears as a valid reconnect
#         target. The failover list is de-duped against load-balancing by
#         domain_name so self / the master aren't sent twice. The AP client
#         also de-dupes cross-section duplicates when building its try/log list
#         in get_hosts_to_try, so an older rXg (or a legacy persisted file) that
#         still carries an endpoint in both sections is tried and logged once.

# 1.0.28: Cleaner shutdown + crash-safe persistent writes. (1) The ubus thread is
#         now daemon=True, so a normal exit / SIGINT no longer hangs in
#         threading._shutdown joining a wedged ubus.loop() (the libubox uloop
#         grabs SIGTERM); ubusd reaps the dropped socket, so the skipped
#         ubus.disconnect() is cosmetic. (2) Persistent files a SIGKILL mid-write
#         could corrupt now go through an atomic temp-file + fsync + os.replace
#         helper (atomic_io.atomic_write): /root/rxg_endpoints.json and the EST
#         cert/key/CA PEMs under /certificates and /etc/ucentral. Prevents a torn
#         write from breaking failover-endpoint parsing or cert auth on next boot.
#         (3) SIGTERM now actually terminates the process. The ubus thread blocks
#         SIGTERM/SIGINT and, once uloop is up, the main thread reclaims those
#         signals via asyncio add_signal_handler (installed last so it wins
#         uloop's sigaction); main() awaits a shutdown event, runs best-effort LED
#         cleanup, then os._exit(0). The hard exit is required because asyncio's
#         own teardown (task cancel + default-executor join) wedges on an
#         in-flight to_thread call — a live test showed the handler firing and
#         main() returning while the process stayed alive until procd SIGKILL.
#         Now procd's SIGTERM stop succeeds directly. Safe: persistent writes are
#         atomic + idempotent and the sdan dataplane is kernel-side.

# 1.0.29: Speedtest.net (Ookla) server override. When a 'speedtest' test has a
#         target (FQDN/IP) set on the rXg, the AP now pins the Ookla test to it
#         via speedtest-rs --server instead of auto-selecting the nearest public
#         server — the license-clean, self-hosted path for commercial
#         deployments. The value rides the existing per-test 'server' field
#         (= the test's target); a bare host gets the conventional Ookla port
#         (8080) appended, since speedtest-rs --server requires host:port. Blank
#         => unchanged auto-select behavior. Applies to both the interface
#         (uplink) and wireless-client sensor speed-test loops.


# 1.0.30: Program swconfig for untunneled direct VLAN mode on switch APs
#         (e.g. cig_wf186h, cig_wf189h). Dynamically detects swconfig switch
#         devices, CPU ports, and WAN ports from capabilities.json and swconfig
#         help, automatically adding switch_vlan hardware entries when
#         add_tunnel is called with encap='vlan'.
# 1.0.31: Reclaim SIGCHLD for Python asyncio in rg_wss_client — uloop (ubus_bridge)
#         installs a process-wide SIGCHLD handler that reaps child processes
#         (waitpid(-1)), causing asyncio subprocesses (uci, swconfig) to fail
#         with [Errno 3] No such process (ECHILD). Reclaiming SIGCHLD alongside
#         SIGTERM and SIGINT in _reclaim_signals_for_asyncio() restores Python
#         asyncio child process management.
# 1.0.32: OpenWiFi qualification (Step 1, AP self-test). run_qualification.py
#         captures the AP's identifying artifacts — /etc/ucentral/capabilities.json,
#         /etc/board.json, /etc/openwrt_release, the serial (uci
#         ucentral.config.serial), and the running client version — and reports
#         them to the rXg via ubus batch_ingest (command 'qualification_result'),
#         which the rXg persists as a DeviceQualification. Adds a get_version
#         ubus method so a caller can read the running client version directly.
#         Triggered off-band by the console/tools/qualify_ap_self lab tool (no
#         operator UI). Stub checks for now; the result schema accommodates real
#         pass/fail checks without rXg changes.

# 1.0.33: RADIUS CoA (RFC 5176) support for openwifi_wss. A single Dynamic
#         Authorization Server listener (coa_das_listener.py) serves both wired
#         and wireless clients, making the AP a standards-compliant CoA/Disconnect
#         target (the capability SoW/RFP specs require). CoaManager starts/stops it
#         from a top-level `coa` state block the rXg emits only when the
#         controller's protocol is 'ssh_coa', and installs a source-scoped
#         firewall rule so ONLY the rXg cluster nodes can reach the DAS port. On
#         the rXg, CommandInfrastructureDevices no longer diverts ssh_coa
#         openwifi_wss disconnects to the WSS-deauth API — it sends real RADIUS
#         CoA to the AP. The SSH/API default ('ssh') is unchanged. Hardening:
#         the DAS listener keeps strong refs to in-flight handler tasks, honors
#         the RADIUS Length field, time-bounds the deauth subprocess, and
#         suppresses duplicate requests (RFC 5176 §2.3); the firewall opening is
#         a managed fw4 rule (a UCI `config rule` scoped to the rXg-facing zone,
#         up0v0) rather than an ad-hoc nft rule, so it survives fw4 reloads and
#         is (re)written only when the port/allow-list changes. Fails closed on
#         an empty/all-invalid allow-list: CoaManager refuses to enable the DAS
#         and the listener drops all packets rather than opening the port to the
#         whole zone.
#
# 1.0.34: Add dump_pending_batch_queue ubus method to ubus_bridge.py.
#         Allows callers (e.g. lab qualification tools or debugging scripts)
#         to inspect pending batch ingest queue items synchronously via ubus
#         (`ubus call rg_wss_client dump_pending_batch_queue`) without draining
#         the queue.

# 1.0.35: Fix static downstream port initialization on swconfig APs. Extracted
#         the isolation sub-interface creation (eth1.4085) and hardware switch
#         programming from the RADIUS-only path to run for static ports as well,
#         resolving "port interface not found" on platforms like 186h and 189h.

# 1.0.36: Probe 1550 as an MTU anchor. A card serving VXLAN sizes its LAN port
#         at exactly 1550: 1500 of inner frame plus the outer IP, UDP and VXLAN
#         headers and the inner Ethernet one. The anchor list went 1542 -> 9000,
#         so the highest an AP could verify was 1542 and every full-size
#         subscriber frame left the AP as two IP fragments; hardware decap does
#         not match fragments, so they fell through to the DPU kernel and were
#         dropped as "udp port 4789 unreachable". Small packets (DHCP, DNS, TCP
#         and TLS handshakes) fit and worked, so the SSID connected with no
#         internet. With 1550 verified the AP carries a 1500-byte client frame
#         in one datagram.

# 1.0.37: A configuration ucentral.uc refuses is a failure, not a wifi reload.
#         ucentral.uc exits non-zero and leaves ucentral.active on the previous
#         configuration, so the AP keeps serving it; rg_wss_client read any
#         output as success, ran `wifi reload` (every client deauthenticated,
#         sdan tunnels torn down) and, since the diff against ucentral.active
#         never closed, did it again on every reconnect. Now the exit code is
#         checked, nothing runs after a refusal, the same configuration is not
#         re-applied until the rXg sends a different one, and every apply is
#         reported as a config_apply_result item; the rXg raises a health notice
#         on the AP for a refusal and cures it on the next successful apply.

# 1.0.38: SDAN reliability and switch corruption fixes.
#         1. Fix SDAN switch1 UCI corruption: Restrict board.json WAN
#            fallback to primary switches to prevent cross-contaminating
#            secondary chips.
#         2. Fix STP link flapping: Bypass redundant swconfig network loads.
#         3. Fix UCI array shifting: Reverse-sort anonymous UCI section
#            deletions.
#         4. Improve WebSocket stability: Add keepalive heartbeats to batch
#            updates.
#         5. Fix WSS subscriber race condition: Respect
#            configuration_in_progress lock.
#         6. Fallback VLAN resolution: Query rxg_sdanctl -j macs instead of
#            Linux FDB.
#         7. Fix LED monitor crash loop on platforms with no RGB LED. The
#            solid colour paths invoked custom_led.sh directly, so on models
#            shipping no such script (cig_wf188n) FileNotFoundError escaped
#            to the loop's catch-all every pass: the state never latched,
#            and the 5s error sleep replaced the 0.5s cadence, stretching
#            the counter-driven RADIUS and EST probes from 60s to 10
#            minutes. All call sites now route through a guarded
#            led_set_color() helper, and the state machine latches whether
#            or not the LED could be driven.
#         8. Fix WF186H LED channel names. Its custom_led.sh was cloned from
#            the WF189 and kept status:<colour>, but the WF186H exposes
#            <colour>:status, so every LED call exited non-zero. The script
#            now probes sysfs for either layout.
#         9. Never release a VLAN's sdan tunnel; keep it resident once
#            applied. A client joining a quiet AP had to wait out the whole
#            userland setup chain -- sta-authorized, RADIUS VLAN resolution,
#            add-tunnel, genl, retroactive apply -- before anything
#            forwarded. sdan's netdev notifier already hooks a dynamic
#            <bss>-v<vlan> interface the moment hostapd creates it, but only
#            when a tunnel for that VLAN exists; otherwise it logs
#            "unconfigured, traffic will be dropped" and the client waits.
#            Keeping the tunnel moves setup onto that notifier, where it
#            costs nothing. It also removes the hazard that made teardown
#            dangerous: del_tunnel keys on VLAN alone, so a release fired at
#            the instantaneous zero a band steer or roam produces unapplied
#            the config from every interface on that VLAN, including the BSS
#            the client had just joined. Residency is bounded by VLAN space
#            rather than by clients or uptime -- tunnels key on a 12-bit id,
#            and one with no clients is inert because hostapd destroys the
#            VLAN interface and the ingress hook goes with it.
#            flush_stale_state at process start is the reclaim point; RADIUS
#            and config changes still re-point a resident tunnel, since
#            ensure_tunnel compares encap, remote, key and port. BSS ports
#            for static/PSK SSIDs are kept for the same reason, 'assoc' is
#            dropped from the hostapd subscription now that no branch acts
#            on it, and tunnel_monitor's "nothing to check" gate moves from
#            tunnel count to learned client MACs -- it had been using the
#            former as a proxy for the latter, which residency breaks, and
#            would otherwise have settled every empty AP's LED to idle.
#        10. Do not assume "untunneled" before the rXg state push. The
#            ucentral config restored from disk at boot cannot distinguish a
#            tunneled WLAN from a plain-VLAN one, so a station authorizing
#            in the window between the miniloader applying config and the
#            first state push was programmed as plain 802.1Q — converting
#            the whole VLAN, since the kernel keys tunnels on VLAN alone.
#            That window is now deferred rather than guessed: the VLAN is
#            left unconfigured and the station is retried when the state
#            arrives.
#        11. Seed the per-SSID tunnel map from disk at startup. The rXg now
#            mirrors wlan_tunnel_map into the uCentral config's third-party
#            section, which is the part of the state that gets persisted to
#            /etc/ucentral and replayed by the miniloader at boot. Reading
#            it before any client event is processed means a station that
#            authorized while the client was still downloading gets tunneled
#            instead of deferred. The state push still supersedes it.
#        12. Scope the existing-client VLAN lookup to the BSS being scanned.
#            The rescan resolved VLANs from rxg_sdanctl but accepted any
#            MAC-table row for an authorized client, and that table is keyed
#            on MAC alone and ages entries over five minutes -- the same
#            period the rescan runs on. A client that had roamed away could
#            therefore hand back its old VLAN long after leaving, and
#            because tunnels key on VLAN alone a stale answer could re-point
#            the whole VLAN. Rows are now kept only when learned on the BSS
#            being scanned, matching its base netdev or a per-VLAN VIF
#            beneath it; filtering on the VLAN id would not have helped,
#            because ids are not BSS-scoped. Untagged rows are skipped
#            rather than dispatched as VLAN 0, and the conflict-detection
#            pass is gone because the table replaces an entry on device
#            change, so a MAC cannot appear twice.
#        13. Guard the persisted tunnel-map read at startup. The third-party
#            section lookup sat outside its try, so valid JSON that was not
#            an object raised during startup instead of falling back,
#            contrary to the best-effort behaviour the function documents.
#        14. Handle every hostapd departure notification. WATCHED_EVENTS
#            gates notifications before the dispatch branches on them, and
#            it listed disassoc but not deauth while the dispatch handled
#            both -- so the deauth arm was unreachable. hostapd raises the
#            two from separate frame handlers and most clients send deauth
#            when they leave, which meant the only caller of
#            handle_client_remove never fired for the common departure, so
#            those clients stayed on our books as associated long after they
#            had gone. local-deauth (we kicked the station, as the rXg
#            disconnect API does) and inactive-deauth (aged out) went
#            unnoticed the same way. Acting on those two is correct rather
#            than premature, because hostapd calls ap_free_sta immediately
#            after raising them, so the station is already destroyed and
#            must re-associate. All four names now live in one
#            DEPARTURE_EVENTS set that the dispatch branches on and that
#            WATCHED_EVENTS is built from, so the gate cannot omit something
#            the dispatch acts on. The test pins that set against hostapd's
#            own notification call sites rather than against the dispatch,
#            so ignoring a departure it raises fails instead of passing by
#            agreeing with ourselves.
#        15. Survive a switch that rejects a VLAN, and retry an unreachable
#            artifact download. _ensure_swconfig_vlan returned on the first
#            switch that failed and the caller rolls the tunnel back on
#            False, so one switch rejecting a VLAN destroyed the dataplane
#            for a station that stayed associated: it completed the
#            handshake, opened a RADIUS accounting session and passed
#            nothing, with nothing retrying until it reassociated. Observed
#            on a dual-switch AP whose 3-port secondary rejected a port
#            number that exists only on the 8-port primary. Outcomes are now
#            tracked per switch and the VLAN survives as long as one switch
#            carries it; False is returned only when every switch actually
#            attempted failed. A rejected section is also discarded rather
#            than left committed, because the "already programmed" check
#            counted it as success on the next attempt, so the switch
#            appeared configured while its hardware never was -- which is
#            why a retry looked like it had worked. Deletion is by section
#            handle, not by @switch_vlan index, since anonymous indexes
#            shift on delete. Separately, a missing sdan artifact latched
#            the AP into HARD-FAIL for the life of the process, since the
#            probe runs once before any task spawns and is never repeated:
#            online and manageable while its clients passed no traffic, a
#            state the rXg cannot see because auto-recovery only reboots APs
#            it considers offline. The fetch now reports why it failed, and
#            an artifact that was merely unreachable exits for a respawn --
#            miniloader execs this process so procd supervises it directly,
#            and miniloader's own startup jitter paces the retries. A 404
#            does not exit: no build for this platform is not something
#            respawning can fix, and the AP is more useful reachable.
#        16. Stop refreshing hostapd subscriptions because of silence, and
#            reconcile against hostapd instead. The liveness check read "no
#            events for 90s while clients are authorized" as a dead
#            subscription, but hostapd sends no heartbeat -- it speaks only
#            when a station joins or leaves -- so an idle-but-associated
#            client produced silence that was mistaken for staleness. The
#            refresh then reset the very timer that triggered it, repeating
#            every 90s indefinitely: measured on the lab at 115 refreshes
#            against 1 real client event, with hostapd's authorized set
#            identical across every sample, and 137 subscribe calls where a
#            client-free AP needed 20. Each pass also killed all six
#            subscriber subprocesses and re-applied every tracked client's
#            tunnel, which is how a failing switch-programming path kept
#            being re-entered and tearing down working dataplanes. It is now
#            a comparison: a client we track that hostapd no longer lists on
#            its BSS for two consecutive passes has its tunnel reference
#            released, confirmed by a fresh read taken immediately before
#            acting. That also repairs a missed departure, which no refresh
#            could ever fix, because rediscovery only dispatches
#            sta-authorized and so could recover a lost arrival but never a
#            lost departure. Only this direction is compared -- the reverse
#            diverges permanently by design, since a natively bridged client
#            stays listed and is never tracked. Releasing requires positive
#            evidence throughout: a timed-out or unparseable read, or one
#            missing a clients key, counts as no answer rather than "all
#            clients left", and station membership is the test rather than
#            the authorized flag so a client mid-handshake keeps its tunnel.
#            The trade is that a missed *arrival* on a subscription that is
#            dead while appearing alive now waits for the 300s refresh
#            instead of 90s -- accepted, since the old rule only achieved
#            that by firing constantly when nothing was wrong.
#        17. Retry a corrupted artifact download instead of giving up on the
#            first bad copy. Damage in transit arrives two ways: when the
#            framing reveals the shortfall it raises IncompleteRead, which
#            the fetch already retried as the transient failure it is, but
#            when the bytes arrive at full length and wrong, only the digest
#            catches it -- and that path returned immediately, abandoning
#            the two remaining attempts. Identical damage, opposite
#            handling, decided by whether the transport happened to notice.
#            It mattered because a digest mismatch is deliberately not
#            respawn-retryable, an rXg answering wrongly being something a
#            restart cannot fix, so a single corrupted transfer latched the
#            AP into dataplane HARD-FAIL until someone restarted the client
#            by hand. A mismatch now consumes the same three attempts as any
#            transport error and only reports failure once they are
#            exhausted, and a previously verified cache still wins over no
#            dataplane. A missing X-Sha256 header continues to fail
#            immediately, since an rXg too old to send one never will.
#        18. Detect a hostapd restart from the PIDs that served our
#            subscriptions rather than from the previous cycle's list.
#            Watching only the first PID pidof printed was order-dependent;
#            sorting the whole list fixed that but made the signature
#            sensitive to membership, and pidof wpad matches short-lived
#            instances too -- a background scan spinning one up cost two
#            full refreshes, one when the extra PID appeared and another
#            when it left, each tearing down and rebuilding all six
#            subscriptions and re-applying every tracked client's tunnel.
#            Seen four times in 36 minutes on a live AP. The comparison is
#            now against the set captured when we last subscribed: a process
#            that arrived afterwards never served a subscription, so neither
#            its arrival nor its exit is evidence of anything. Testing only
#            for disappearance would not have been enough on its own, since
#            the transient's own exit is a disappearance. A real restart is
#            still caught, its old PID being absent from the current set,
#            and an addition needs no action because a genuinely new BSS is
#            already picked up by subscribing to any ubus object not already
#            subscribed.
#        19. Exit for a respawn only when every missing sdan artifact could
#            succeed on another attempt. The check fired if *any* reason was
#            'unreachable', so a mixed pair -- one file 404 for this
#            platform, the other merely unreachable -- exited on the
#            strength of the retryable one and respawned forever: the 404
#            cannot become available, so the loader restarted every few
#            seconds for the life of the AP without ever assembling a
#            dataplane, forfeiting the manageability staying up would have
#            kept. A test asserted the old behaviour as correct and so was
#            defending the bug; it now pins both orders and both permanent
#            reasons.
#        20. Recover existing clients on a BSS with no per-VLAN
#            sub-interface. The post-refresh rescan returned early when it
#            found no -v<vlan> interfaces, which on a static or PSK BSS is
#            permanent -- such a BSS never grows one. Every client there
#            stayed untracked after a refresh and passed no traffic until it
#            happened to reassociate, visible in the field as repeated
#            "Scanning N existing client(s) ... vlans=[]" with nothing
#            dispatched. Absent dynamic VIFs there is no RADIUS-assigned
#            VLAN to discover, so the SSID's configured VLAN is
#            authoritative and these clients now dispatch without one,
#            running the same fallback the live path uses. Where dynamic
#            VIFs do exist, an unresolved client is still skipped rather
#            than guessed at: applying an SSID default to a RADIUS-assigned
#            client would put it on the wrong VLAN, and tunnels key on VLAN
#            alone, so that re-points every interface on it.
#        21. Snap the all-client radio spread window to an hour boundary
#            too, in the display zone, matching the per-client report. It
#            was the remaining half of the same mismatch: an unsnapped
#            1.day.ago opens mid-hour, so the oldest bucket holds only the
#            minutes after the cutoff while being counted like a whole one.
#        22. Keep the tunnel when switch VLAN programming cannot be
#            completed. Rolling it back was strictly worse than a
#            half-programmed switch: add_tunnel failed,
#            handle_sta_authorized returned before it recorded any refcount,
#            and the station was left with neither a tunnel nor an entry to
#            retry from -- associated, authorized and permanently unserved
#            until it reassociated from scratch. The switch VLAN table
#            governs wired egress only; a wireless client's traffic rides
#            the sdan tunnel regardless, so serving it on a switch we could
#            not fully program is the lesser failure.
#        23. Flush a station's stale switch-chip ARL entry when it joins, on
#            QCA Wifi-6 silicon. On ipq60xx boards (WF-188n, WF-660a) the
#            HPPE switch mislearns a station onto the uplink port: nss-dp
#            injects CPU-originated frames with an explicit port bitmap and
#            so bypasses source-MAC learning, leaving the only sighting of
#            that MAC the one arriving from upstream. A unicast reply then
#            resolves to its own ingress port and is discarded in silicon,
#            with no skb created, so it is invisible even to tcpdump on
#            every interface -- while broadcast still floods and therefore
#            still works. The symptom was a DHCP DISCOVER reaching the
#            server every time and the OFFER never reaching the client,
#            coming and going with ARL aging. Writing the one MAC to
#            qca_ssdk's debugfs hook removes exactly that row and nothing
#            else, which is what makes this safe to do while other clients
#            hold leases on the same VLAN. Upstream ucentral-event does the
#            same thing in its sta-authorized handler, but we stop that
#            daemon at startup, so both of our authorize paths now do it
#            themselves -- and association does too, because that is the
#            earlier trigger: the flush needs only the MAC, whereas
#            sta-authorized waits on the RADIUS reply carrying the mPSK and
#            VLAN, and on a slow round trip the client starts DHCP first.
#            Measured on a WF-188n pair over an identical 60-trial roam
#            matrix run three times: without the association flush, every
#            roam slower than 1.5s lost its lease (11/11, then 12/12 on a
#            revert) and every faster one kept it; with it, 0/66.
#        24. Collect telemetry natively on boards that cannot run
#            telemetry.uc, instead of collecting nothing. telemetry.uc was
#            blacklisted on the WF186H because state.uc issues nl80211
#            survey and per-VAP tx-power dumps, both WMI round-trips taken
#            while cfg80211 holds the per-wiphy mutex, and on IPQ5018 the
#            5 GHz QCN6122 shares the Q6 as a secondary protection domain:
#            when its firmware stops answering stats requests the waits pile
#            up until hostapd and netifd time out and the radios go dark.
#            Blacklisting stopped the lockups and also stopped all
#            reporting, which is quieter than it sounds -- the AP stays
#            Connected and online while uptime, client count and channel are
#            all nil on the rXg, the subscribe handler clears the channel
#            fields on every reconnect so they never refill, and RRM gates
#            on a recent successful telemetry command execution and so can
#            never run there at all. telemetry_native rebuilds the fields
#            the rXg actually parses from /proc, /sys, ubus and hostapd,
#            none of which enter the WMI/stats path, and reports under the
#            same command label so the existing ingestor handles it
#            unchanged. It also runs once immediately after subscription,
#            because that is where the uptime-drop reboot detector gets its
#            baseline.

# 1.0.38.1: Hotfix of the 1.0.38 field build (07beb38ec8) with the
#         switch-VLAN fixes from 1.0.42 only. On swconfig APs (WF-186h)
#         per-client plain-VLAN VLANs and wired isolation VLANs are written
#         straight to switch hardware instead of /etc/config/network, and
#         re-asserted after every uCentral apply, wired switch reload, every
#         60 s and on every authorization, so a config push no longer leaves
#         units or LAN ports dark. Wired detection no longer reloads the
#         switch (dropping the 186h uplink) when VLAN mode is already on, and
#         static wired ports leave the switch alone while sdan is down.
#         Switch programming survives failed probes and reads, the 60 s
#         check reads the switch without holding the sdan lock, and a LAN
#         port's old setting is torn down before its new one is set up.

# Version of the rg_wss_client
# This version is reported to the rXg to track which client version is running on each AP
VERSION = "1.0.38.1"
