diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 539b850..794639d 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -41,6 +41,10 @@ jobs: python -m py_compile $(find frame/devkit-utils -type f ! -name '*.*' ! -name LICENSE) frame/devkit-utils/devkit_utils/*.py - name: Server tests run: python -m unittest discover -s tests -v + - name: Linux Wireless Adapter doctor (read-only PC smoke) + run: | + python ui/frame_wireless_doctor.py --json > "$RUNNER_TEMP/linux-wireless-doctor.json" + python -c 'import json, os; from pathlib import Path; d=json.loads((Path(os.environ["RUNNER_TEMP"]) / "linux-wireless-doctor.json").read_text()); assert d["supported"] and d["selected"] is None and d["schema"] == 1' - name: App syntax run: node --check app/main.js && node --check app/build/make-icon.js && node --check app/build/fetch-deps.js && node --check app/preload.js && node --check app/install-link.js && node --check app/updater.js - name: App tests diff --git a/CHANGELOG.md b/CHANGELOG.md index 91ffdd8..cd1cffd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ ## Unreleased +- Add a Linux PC Wireless Adapter doctor in Settings and Devices, with a source + CLI and local JSON report. Select observed hardware without assuming Valve + IDs; inspect USB/interface presence, driver/module binding, udev/permissions, + rfkill, channel/band and network manager evidence. Missing evidence stays + unknown; all checks are read-only and never contact the Frame. + [Usage and verification limits](docs/linux-wireless-adapter-doctor.md). + - Add Linux Tools → Steam desktop sharing: request `steam -pipewire` only after checking Steam is not running, or open KDE Application Permissions with instructions to revoke a verified Steam screen-sharing session manually. No diff --git a/README.md b/README.md index 8ed1a59..ec575b1 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,9 @@ Live video of what the lenses show, or of the app panel in use, flat and still h **🔋 Battery and status**
Charge, charging watts and time left, storage, memory, temperature, Wi-Fi, and what's running. +For PC-side USB, driver, radio and network manager evidence, use the +[Linux Wireless Adapter doctor](docs/linux-wireless-adapter-doctor.md). + For repeatable read-only battery, power-rail, temperature and fan measurements, use the [power and heat sampler](docs/power-and-heat.md). diff --git a/docs/img/linux-wireless-doctor.png b/docs/img/linux-wireless-doctor.png new file mode 100644 index 0000000..af5ea9f Binary files /dev/null and b/docs/img/linux-wireless-doctor.png differ diff --git a/docs/linux-wireless-adapter-doctor.md b/docs/linux-wireless-adapter-doctor.md new file mode 100644 index 0000000..0db807b --- /dev/null +++ b/docs/linux-wireless-adapter-doctor.md @@ -0,0 +1,118 @@ +# Linux Wireless Adapter doctor + +![Linux PC doctor before any checks run, with explicit hardware selection and read-only limits](img/linux-wireless-doctor.png) + +On the **Linux PC**, open **Settings → Linux Wireless Adapter doctor**, or +**Devices & connection → Linux Wireless Adapter doctor**. It works without a +connected headset. Choose **Read PC device inventory**, select the intended +USB device or wireless interface, then **Check selected device**. Nothing runs +until requested. Re-read the inventory after reconnecting a device. + +From a source checkout, the same collector is available without starting the +app or its headset connection workers: + +```sh +python3 ui/frame_wireless_doctor.py +python3 ui/frame_wireless_doctor.py --device net:wlan0 +python3 ui/frame_wireless_doctor.py --device usb:1-2 --json > adapter-report.json +``` + +Use a selector actually listed on your PC. No device is selected automatically. +All USB devices are included, including hubs and devices without a network +interface. A product label, observed VID/PID, or a single available interface +**does not identify Valve's adapter**. There is no trusted adapter ID table in +this feature. Compare inventory before and after reconnecting the intended +adapter when safe. For a USB device exposing multiple wireless interfaces, +select the specific interface for radio/manager evidence. + +## Evidence and limits + +- **Presence and binding:** `/sys/bus/usb/devices` and `/sys/class/net` correlate + USB ancestry, wireless interfaces, driver links and module links. An absent + interface does not establish which driver should be installed. A missing + module link may mean a built-in driver. A bound driver does not prove Steam + streaming support. +- **Permissions and udev:** `udevadm info --query=property` supplies a small + allowlist of driver, ID and tag fields. If available, the report includes USB + node mode, owner/group IDs and the current user's read/write access. Direct + USB access is not necessarily required by kernel networking; a restrictive + node alone is not diagnosed as the cause. No udev rules are installed or run. +- **Radio blocks:** read-only WLAN rfkill sysfs entries are matched to the + selected interface's PHY name. An unrelated block cannot establish that + this adapter is blocked. Unavailable or unmatched entries remain unknown. +- **Channel/band:** `iw dev IFACE info` provides the current channel and + frequency where reported; `iw phy PHY info` supplies enabled frequencies. + An idle interface may have no current channel. These observations do not + establish the channel used by a Steam streaming session, supported regions, + or the correct regulatory setting. +- **Network managers:** visible `/proc/*/comm` process names show + NetworkManager, iwd and wpa_supplicant. `nmcli` reads only the selected + interface's general state, managed flag and driver. NetworkManager can use + iwd or wpa_supplicant as its backend, so coexistence is not itself a conflict. + If both backends are visible, the report suggests checking who owns this + exact interface. It does not claim a confirmed conflict or stop services. + Restricted process visibility and unverified backend ownership are explicit. + +Every command uses fixed read-only arguments, no shell, closed stdin and a +three-second timeout. Optional tools are not installed automatically. Missing +tools, denied reads, disappearing devices, and restricted VM/container hardware +visibility remain unknown. A successful report is a snapshot, not a hardware +health certificate. CLI exit 0 means collection is supported, not that the +adapter works; unsupported platforms/headset-local mode return 2, as does an +invalid/stale selector. JSON has schema version 1. + +The doctor never uses SSH, scans nearby Wi-Fi networks, opens a connection to +the Frame, escalates privileges, loads drivers, changes security settings, +changes channels, unblocks radios, changes network settings, or restarts +services. Run it as your ordinary user, never with sudo. The app hides the +entry on macOS, Windows and phone sessions; its API refuses collection in +headset-local (`FRAME_LOCAL=1`) mode. The source CLI is intended only for the PC. + +Reports stay local unless you explicitly save, copy or share them. Exported +fields include observed USB IDs/product labels, interface names, driver names +and permission metadata. USB serials, Wi-Fi names, MAC/IP address fields, +credentials, process arguments and raw command output are not exported. Review +the report before posting it: hardware labels and local paths can identify +parts of your setup. + +## Motivation and shipped comparison + +[The 23 October 2026 complaint](https://x.com/ExpressGinga81/status/2113457683143741720) +says the Wi-Fi dongle does not work on Ubuntu/Kubuntu 26.04 while SteamVR works +under Wayland. It supplies **no adapter VID/PID, driver, logs or established +cause**. This feature helps gather that missing evidence; it does not claim to +fix that complaint or certify Ubuntu/Kubuntu 26.04 support. + +Shipped **v0.4.18** has local network/gateway discovery in `frame_network.py` +and headset connection management, but no local USB/driver/rfkill adapter +report or adapter diagnostic UI. The existing `frame-doctor.sh` diagnoses the +headset through SSH and is a different tool. This doctor never calls it. +General SteamOS 3.9.5 / Frame OS 0.5.6 compatibility and OS backend transition +notes are covered separately in +[`docs/steamos-2026-10-compat.md`](https://github.com/saphid/frame-control/blob/9c7110c1f48d160b0fb3c3b9f658d4937bd2032d/docs/steamos-2026-10-compat.md) +([PR #164](https://github.com/saphid/frame-control/pull/164)). Existing PC +route/SSID diagnostics use NetworkManager's `nmcli` with an `iwgetid` SSID +fallback, independently of whether NetworkManager uses wpa_supplicant (SteamOS +3.9.5 new installs) or iwd (upgrades). This doctor reads actual PC evidence and +does not infer a conflict, hardware identity or fix from those OS defaults. +There is no adapter-specific device observation in that compatibility work. + +## Verification + +```sh +python3 -m unittest discover -s tests -p test_wireless_doctor.py -v +python3 -m unittest discover -s tests +``` + +Tests use an explicitly synthetic USB `1234:5678`, sysfs trees, command replies +and loopback HTTP. They cover an unbound USB device, module binding, multiple +interfaces, PHY-matched and unrelated rfkill entries, channel parsing, manager +coexistence, missing/denied commands, stale/injected selectors, export field +filtering, authentication and refusal to probe in headset-local mode. The +HTTP tests instantiate the handler without starting headset workers. + +Linux host collection was exercised on kernel `6.8.0-139-generic`: an existing +PC interface was visible with its `iwlwifi` binding and unblocked PHY; `iw` and +`nmcli` were absent and reported as unavailable. This is **not a physical Steam +Frame Wireless Adapter test**, an Ubuntu/Kubuntu 26.04 test, or a headset test. +No Frame access is part of this work. diff --git a/tests/page/wireless_doctor.mjs b/tests/page/wireless_doctor.mjs new file mode 100644 index 0000000..607bdf5 --- /dev/null +++ b/tests/page/wireless_doctor.mjs @@ -0,0 +1,70 @@ +// Run the real doctor page script with a minimal DOM; no browser or hardware I/O. +import assert from 'node:assert/strict'; +import {readFileSync} from 'node:fs'; +import vm from 'node:vm'; +class Element { + constructor(tag) { this.tagName = tag.toUpperCase(); this.children = []; this.value = ''; this.hidden = false; this.disabled = false; this.textContent = ''; } + append(...nodes) { this.children.push(...nodes); } + prepend(...nodes) { this.children.unshift(...nodes); } + replaceChildren(...nodes) { this.children = nodes; } + get firstChild() { return this.children[0]; } + get options() { return this.children; } +} +const ids = ['back','inventory','diagnose','device','message','results','json','reportSection','selection','download','copy']; +const elements = Object.fromEntries(ids.map(id => [id,new Element(id === 'device' ? 'select' : 'div')])); +const requests = []; +let pending; +const context = vm.createContext({ + document:{getElementById:id=>elements[id],createElement:tag=>new Element(tag)}, + URLSearchParams,location:{search:'?key=test-key'}, + fetch:(path,options)=>{ requests.push({path,options}); return new Promise(resolve=>{pending=resolve;}); }, + navigator:{clipboard:{writeText:async()=>{throw Error('clipboard denied');}}}, +}); +const source = readFileSync(new URL('../../ui/wireless-doctor.html',import.meta.url),'utf8'); +vm.runInContext(source.match(/