Add a read-only Linux Wireless Adapter doctor (#165)

* Add read-only Linux PC Wireless Adapter doctor

* Cross-reference coordinated SteamOS compatibility notes

---------

Co-authored-by: SAPHID <saphid@users.noreply.github.com>
This commit is contained in:
Alex SouthwellandSAPHID authored and GitHub committed 2026-10-24 12:30:08 +00:00
1 parent aada257325
commit 337ac99b3e
11 files changed
+858 -1

No files matched your search

+4
View File
@@ -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
+7
View File
@@ -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
+3
View File
@@ -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**<br>
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).
Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

+118
View File
@@ -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.
+70
View File
@@ -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(/<script>([\s\S]*)<\/script>/)[1],context);
assert.equal(requests.length,0,'opening the page must not probe');
assert.equal(elements.back.href,'/?key=test-key#devices');
const fixture = {
schema:1,supported:true,selected:null,
usb:[{id:'usb:1-2',vid:'1234',pid:'5678',product:'<img src=x onerror=alert(1)>'}],
interfaces:[{id:'net:wlan0',name:'wlan0',usb:'usb:1-2',driver:'synthetic'}],
checks:[{id:'presence',title:'Presence',status:'ok',suggestion:'Select hardware',evidence:['Synthetic evidence']}]
};
let promise=elements.inventory.onclick();
assert.equal(elements.device.disabled,true,'selection must stay fixed during a read');
assert.equal(requests[0].path,'/api/wireless-doctor');
assert.equal(requests[0].options.headers['X-Frame-UI'],'test-key');
pending({ok:true,json:async()=>fixture}); await promise;
assert.equal(elements.device.value,'','no automatic hardware choice');
assert.equal(elements.diagnose.disabled,true);
assert.equal(elements.device.options.length,3);
assert.match(elements.device.options[2].textContent,/<img src=x/,'hardware labels must stay plain text');
assert.equal(elements.device.options[2].children.length,0);
assert.equal(elements.reportSection.hidden,false);
assert.equal(elements.results.children.length,1);
elements.device.value='net:wlan0'; elements.device.onchange();
assert.equal(elements.diagnose.disabled,false);
assert.equal(elements.results.children.length,0,'changing hardware clears the previous result');
assert.equal(elements.reportSection.hidden,true,'cannot export an old report for a new selection');
promise=elements.diagnose.onclick();
assert.equal(requests[1].path,'/api/wireless-doctor?device=net%3Awlan0');
assert.equal(elements.device.disabled,true);
pending({ok:true,json:async()=>({...fixture,selected:'net:wlan0'})}); await promise;
assert.equal(elements.device.value,'net:wlan0');
assert.equal(elements.device.disabled,false);
await elements.copy.onclick();
assert.match(elements.message.textContent,/Clipboard unavailable/);
promise=elements.diagnose.onclick();
assert.equal(elements.results.children.length,0,'clear a healthy snapshot before retrying');
assert.equal(elements.reportSection.hidden,true);
pending({ok:false,json:async()=>({error:'Device is no longer present. Refresh and select an observed device.'})}); await promise;
assert.match(elements.message.textContent,/no longer present/);
assert.equal(elements.results.children.length,0);
assert.equal(elements.reportSection.hidden,true,'failed requests cannot export stale success');
assert.equal(elements.inventory.disabled,false,'can refresh after failure');
promise=elements.inventory.onclick();
pending({ok:true,json:async()=>({...fixture,supported:false,usb:[],interfaces:[]})}); await promise;
assert.equal(elements.selection.hidden,true);
assert.equal(elements.diagnose.disabled,true);
assert.match(elements.message.textContent,/only on a Linux PC/);
console.log('doctor UI: on-demand reads, explicit selection, safe labels, stale report clearing and failure recovery passed');
+287
View File
@@ -0,0 +1,287 @@
"""Synthetic sysfs fixtures and loopback HTTP; never require an adapter or a Frame."""
import sandbox # noqa: F401
import contextlib
import http.client
import io
import json
import os
import shutil
from pathlib import Path
import subprocess
import sys
import tempfile
import threading
import unittest
from unittest.mock import patch
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / 'ui'))
import frame_wireless_doctor as wd
class Parsers(unittest.TestCase):
def test_channel_without_leaking_identifiers(self):
for frequency, band, channel in [(2412, '2.4 GHz', 1), (5180, '5 GHz', 36), (5975, '6 GHz', 5)]:
text = f'Interface wlan0\n addr aa:bb:cc:dd:ee:ff\n ssid private\n channel {channel} ({frequency} MHz), width: 80 MHz'
self.assertEqual(wd.radio_info(text), dict(channel=channel, frequency_mhz=frequency, band=band))
self.assertIsNone(wd.radio_info('Interface wlan0\n type managed\n'))
self.assertEqual(wd.supported_frequencies('* 2412 MHz [1] (20.0 dBm)\n* 5975 MHz [5] (disabled)\n* 5180 MHz [36] (23.0 dBm)'), [2412, 5180])
def test_non_linux_and_headset_mode_do_not_read_or_run(self):
for kwargs in ({'system': 'darwin'}, {'system': 'win32'}, {'system': 'linux', 'local': True}):
with patch.object(wd, 'Reader', side_effect=AssertionError('must not probe')):
self.assertFalse(wd.diagnose(**kwargs)['supported'])
with patch.dict(os.environ, {'FRAME_LOCAL': '1'}), patch.object(wd, 'Reader') as reader:
self.assertFalse(wd.diagnose(system='linux')['supported'])
reader.assert_not_called()
def test_command_failure_is_not_empty_success(self):
reader = wd.Reader()
for error, status in [(FileNotFoundError(), 'missing'), (PermissionError(), 'unavailable'),
(subprocess.TimeoutExpired('iw', 3), 'timeout')]:
with patch.object(wd.subprocess, 'run', side_effect=error):
self.assertEqual(reader.command(['iw', 'dev', 'wlan0', 'info'])['status'], status)
with patch.object(wd.subprocess, 'run', return_value=subprocess.CompletedProcess([], 1, '', 'Operation not permitted')) as run:
result = reader.command(['iw', 'dev', 'wlan0', 'info'])
self.assertEqual(result['status'], 'denied')
self.assertEqual(result['text'], '')
self.assertEqual(run.call_args.kwargs['stdin'], subprocess.DEVNULL)
self.assertEqual(run.call_args.kwargs['timeout'], 3)
self.assertNotIn('shell', run.call_args.kwargs)
def test_read_failure_and_empty_inventory_remain_unknown(self):
with tempfile.TemporaryDirectory() as root:
reader = wd.Reader(root, root, root)
report = wd.diagnose(reader=reader, system='linux', local=False)
checks = {c['id']: c for c in report['checks']}
self.assertEqual(checks['presence']['status'], 'unknown')
self.assertIn('reads', checks)
with patch.object(Path, 'open', side_effect=PermissionError()):
self.assertIsNone(reader.read(Path('/synthetic/denied')))
self.assertIn('PermissionError', reader.gaps[-1])
class Page(unittest.TestCase):
@unittest.skipUnless(shutil.which('node'), 'Node is needed for real page script checks')
def test_on_demand_selection_and_failure_recovery(self):
result = subprocess.run(['node', str(Path(__file__).parent / 'page/wireless_doctor.mjs')],
capture_output=True, text=True, timeout=20)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
class FixtureReader(wd.Reader):
"""Invented USB 1234:5678; it is deliberately not a claimed Valve ID."""
def __init__(self, root):
super().__init__(root / 'sys', root / 'dev', root / 'proc')
self.commands = []
self.responses = {}
def command(self, argv):
self.commands.append(argv)
return self.responses.get(argv[0], {'status': 'missing', 'detail': argv[0] + ' is not installed', 'text': ''})
@unittest.skipUnless(sys.platform == 'linux', 'Linux sysfs symlink fixture')
class LinuxEvidence(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.root = Path(self.tmp.name)
self.r = FixtureReader(self.root)
self.usb = self.r.sysfs / 'devices/pci0000:00/usb1/1-2'
for name, content in {'idVendor': '1234', 'idProduct': '5678', 'product': 'Synthetic adapter', 'busnum': '1', 'devnum': '2'}.items():
self.write(self.usb / name, content)
bus = self.r.sysfs / 'bus/usb/devices'
bus.mkdir(parents=True)
(bus / '1-2').symlink_to(self.usb, target_is_directory=True)
(self.r.sysfs / 'class/net').mkdir(parents=True)
(self.r.sysfs / 'class/rfkill').mkdir(parents=True)
self.r.proc.mkdir(parents=True)
self.write(self.r.dev / 'bus/usb/001/002', '')
def write(self, path, text):
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text)
def nic(self, name='wlan0', index=0, driver=True):
device = self.usb / f'1-2:1.{index}'
device.mkdir(parents=True, exist_ok=True)
net = self.r.sysfs / 'class/net' / name
net.mkdir()
(net / 'device').symlink_to(device, target_is_directory=True)
(net / 'wireless').mkdir()
self.write(net / 'operstate', 'down')
phy = self.r.sysfs / f'devices/ieee80211/phy{index}'
phy.mkdir(parents=True)
(net / 'phy80211').symlink_to(phy, target_is_directory=True)
if driver:
binding = self.r.sysfs / 'bus/usb/drivers/synthetic_driver'
binding.mkdir(parents=True, exist_ok=True)
module = self.r.sysfs / 'module/synthetic_module'
module.mkdir(parents=True, exist_ok=True)
if not (binding / 'module').exists():
(binding / 'module').symlink_to(module, target_is_directory=True)
(device / 'driver').symlink_to(binding, target_is_directory=True)
return net
def report(self, device='usb:1-2'):
return wd.diagnose(device, reader=self.r, system='linux', local=False)
def checks(self, report):
return {c['id']: c for c in report['checks']}
def test_inventory_does_not_select_or_execute_commands(self):
self.nic()
report = self.report(None)
self.assertIsNone(report['selected'])
self.assertEqual(report['usb'][0]['interfaces'], ['net:wlan0'])
self.assertEqual(report['interfaces'][0]['usb'], 'usb:1-2')
self.assertIn('Unverified', report['identity'])
self.assertEqual(self.r.commands, [])
def test_nearest_usb_ancestor_wins_over_upstream_hub(self):
hub = self.usb.parent / '1-1'
hub.mkdir()
old = self.usb
self.usb = hub / old.name
old.rename(self.usb)
bus = self.r.sysfs / 'bus/usb/devices'
(bus / '1-2').unlink()
(bus / '1-2').symlink_to(self.usb, target_is_directory=True)
for name, content in {'idVendor': '4321', 'idProduct': '8765', 'busnum': '1', 'devnum': '1'}.items():
self.write(hub / name, content)
(bus / '1-1').symlink_to(hub, target_is_directory=True)
self.nic()
report = self.report(None)
self.assertEqual(report['interfaces'][0]['usb'], 'usb:1-2')
upstream_hub = next(d for d in report['usb'] if d['port'] == '1-1')
self.assertEqual(upstream_hub['interfaces'], [])
def test_unbound_usb_is_selectable_without_guessing_driver(self):
report = self.report()
checks = self.checks(report)
self.assertEqual(checks['binding']['status'], 'unknown')
self.assertIn('No wireless interface', checks['binding']['evidence'][0])
self.assertEqual(checks['channel']['status'], 'unknown')
self.assertNotIn('iw', [a[0] for a in self.r.commands])
self.assertIn('1234:5678', json.dumps(report))
def test_selected_radio_and_all_commands_are_read_only(self):
self.nic()
for key, value in {'type': 'wlan', 'name': 'phy0', 'soft': '1', 'hard': '0'}.items():
self.write(self.r.sysfs / 'class/rfkill/rfkill0' / key, value)
for pid, name in enumerate(('NetworkManager', 'iwd', 'wpa_supplicant'), 1):
self.write(self.r.proc / str(pid) / 'comm', name)
self.r.responses = {
'udevadm': dict(status='ok', detail='udevadm completed', text='ID_NET_DRIVER=synthetic_driver\nID_SERIAL=secret_serial\nTAGS=:uaccess:\n'),
'iw': dict(status='ok', detail='iw completed', text='channel 5 (5975 MHz)\naddr aa:bb:cc:dd:ee:ff\nssid private\n* 5975 MHz [5] (23.0 dBm)'),
'nmcli': dict(status='ok', detail='nmcli completed', text='30 (disconnected)\nyes\nsynthetic_driver\n')}
report = self.report('net:wlan0')
checks = self.checks(report)
self.assertEqual(checks['binding']['status'], 'ok')
self.assertIn('module=synthetic_module', checks['binding']['evidence'][0])
self.assertEqual(checks['rfkill']['status'], 'warning')
self.assertIn('6 GHz', json.dumps(checks['channel']))
self.assertIn('coexistence alone is not a conflict', checks['managers']['suggestion'])
self.assertIn('Both iwd and wpa_supplicant', checks['managers']['suggestion'])
for secret in ('secret_serial', 'aa:bb:cc:dd:ee:ff', 'ssid private'):
self.assertNotIn(secret, json.dumps(report))
self.assertEqual(self.r.commands, [
['udevadm', 'info', '--query=property', '--path=' + str(self.r.sysfs / 'class/net/wlan0')],
['iw', 'dev', 'wlan0', 'info'], ['iw', 'phy', 'phy0', 'info'],
['nmcli', '-g', 'GENERAL.STATE,GENERAL.NM-MANAGED,GENERAL.DRIVER', 'device', 'show', 'wlan0']])
def test_unrelated_rfkill_and_missing_tools_are_not_healthy(self):
self.nic(driver=False)
for key, value in {'type': 'wlan', 'name': 'phy7', 'soft': '1', 'hard': '1'}.items():
self.write(self.r.sysfs / 'class/rfkill/rfkill7' / key, value)
checks = self.checks(self.report())
self.assertEqual(checks['binding']['status'], 'unknown')
self.assertEqual(checks['rfkill']['status'], 'unknown')
self.assertEqual(checks['permissions']['status'], 'unknown')
self.assertEqual(checks['channel']['status'], 'unknown')
self.r.responses['iw'] = dict(status='denied', detail='iw exited 1 (permission denied)', text='')
self.assertIn('permission denied', json.dumps(self.checks(self.report())['channel']))
def test_unmanaged_interface_does_not_trigger_a_network_repair(self):
self.nic()
self.r.responses['nmcli'] = dict(status='ok', detail='nmcli completed', text='10 (unmanaged)\nno\nsynthetic_driver\n')
check = self.checks(self.report())['managers']
self.assertIn('NetworkManager managed: no', check['evidence'])
self.assertIn('unmanaged alone is not a fault', check['suggestion'])
self.assertIn('Do not force management on', check['suggestion'])
def test_multiple_interfaces_require_explicit_interface(self):
self.nic()
self.nic('wlan1', 1)
checks = self.checks(self.report())
self.assertIn('interface-selection', checks)
self.assertEqual(checks['channel']['status'], 'unknown')
self.assertNotIn('iw', [a[0] for a in self.r.commands])
self.assertNotIn('interface-selection', self.checks(self.report('net:wlan1')))
def test_disappeared_or_injected_selector_is_rejected_before_commands(self):
for selector in ('net:missing', 'usb:../../etc', 'net:;sudo reboot', '', ['wlan0']):
with self.assertRaises(ValueError):
self.report(selector)
self.assertEqual(self.r.commands, [])
def test_cli_json_and_exit_contract(self):
with patch.object(wd, 'diagnose', return_value=self.report(None)), contextlib.redirect_stdout(io.StringIO()) as out:
self.assertEqual(wd.main(['--json']), 0)
self.assertEqual(json.loads(out.getvalue())['schema'], 1)
with patch.object(wd, 'diagnose', return_value={'supported': False}), contextlib.redirect_stdout(io.StringIO()):
self.assertEqual(wd.main(['--json']), 2)
class HttpBoundary(unittest.TestCase):
@classmethod
def setUpClass(cls):
import server
cls.server_module = server
# Serve the real handler only: main() would start unrelated headset workers.
cls.httpd = server.LoopbackServer(('127.0.0.1', 0), server.Handler)
cls.thread = threading.Thread(target=cls.httpd.serve_forever, daemon=True)
cls.thread.start()
@classmethod
def tearDownClass(cls):
cls.httpd.shutdown()
cls.httpd.server_close()
cls.thread.join()
def request(self, path, key=True):
conn = http.client.HTTPConnection('127.0.0.1', self.httpd.server_address[1], timeout=10)
headers = {'X-Frame-UI': self.server_module.UI_KEY} if key else {}
conn.request('GET', path, headers=headers)
response = conn.getresponse()
result = response.status, response.read()
conn.close()
return result
def test_auth_and_headset_independence(self):
with patch.object(self.server_module, 'ssh', side_effect=AssertionError('no SSH')), \
patch.object(self.server_module, 'working', side_effect=AssertionError('no headset lock')), \
patch.object(wd, 'diagnose', return_value={'supported': True, 'checks': []}) as probe:
self.assertEqual(self.request('/api/wireless-doctor', key=False)[0], 403)
probe.assert_not_called()
code, data = self.request('/api/wireless-doctor?device=net%3Awlan0')
self.assertEqual(code, 200)
self.assertTrue(json.loads(data)['supported'])
probe.assert_called_once_with('net:wlan0', local=self.server_module.LOCAL)
def test_local_mode_refuses_before_collection(self):
with patch.object(self.server_module, 'LOCAL', True), patch.object(wd, 'Reader', side_effect=AssertionError('no probes')):
code, body = self.request('/api/wireless-doctor')
self.assertEqual(code, 200)
self.assertFalse(json.loads(body)['supported'])
def test_stale_selection_is_bad_request_and_page_is_packaged(self):
with patch.object(wd, 'diagnose', side_effect=ValueError('Device is no longer present')):
self.assertEqual(self.request('/api/wireless-doctor?device=net:gone')[0], 400)
code, page = self.request('/wireless-doctor')
self.assertEqual(code, 200)
self.assertIn(b'No checks have run', page)
package = json.loads((Path(__file__).resolve().parent.parent / 'app/package.json').read_text())
filters = next(r['filter'] for r in package['build']['extraResources'] if r['from'] == '../ui')
self.assertIn('*.py', filters)
self.assertIn('*.html', filters)
+269
View File
@@ -0,0 +1,269 @@
"""Read-only Linux PC Wireless Adapter doctor. No SSH, scans or repairs.
Run on the PC: python3 ui/frame_wireless_doctor.py [--device net:wlan0] [--json]
USB IDs are observations, never a claim that a device is Valve's adapter.
"""
import argparse
from datetime import datetime, timezone
import json
import os
from pathlib import Path
import platform
import re
import stat
import subprocess
import sys
class Reader:
"""Bounded local reads; failures remain evidence rather than healthy defaults."""
def __init__(self, sysfs=Path('/sys'), dev=Path('/dev'), proc=Path('/proc')):
self.sysfs, self.dev, self.proc = Path(sysfs), Path(dev), Path(proc)
self.gaps = []
def read(self, path):
try:
with path.open(errors='replace') as source:
return source.read(4096).strip()
except OSError as exc:
self.gaps.append(f'{path}: {type(exc).__name__}')
return None
def entries(self, path):
try:
return sorted(path.iterdir())
except OSError as exc:
self.gaps.append(f'{path}: {type(exc).__name__}')
return []
def link(self, path):
try:
return path.resolve(strict=True).name
except OSError as exc:
if not isinstance(exc, FileNotFoundError):
self.gaps.append(f'{path}: {type(exc).__name__}')
return None
def command(self, argv):
try:
result = subprocess.run(argv, stdin=subprocess.DEVNULL, capture_output=True,
text=True, errors='replace', timeout=3,
env={**os.environ, 'LC_ALL': 'C'})
except FileNotFoundError:
return {'status': 'missing', 'detail': f'{argv[0]} is not installed', 'text': ''}
except subprocess.TimeoutExpired:
return {'status': 'timeout', 'detail': f'{argv[0]} timed out', 'text': ''}
except OSError as exc:
return {'status': 'unavailable', 'detail': f'{argv[0]}: {type(exc).__name__}', 'text': ''}
if result.returncode:
# Do not export command output that may contain SSIDs, MACs or other identifiers.
denied = bool(re.search(r'permission|not permitted|access denied', result.stderr, re.I))
return {'status': 'denied' if denied else 'unavailable',
'detail': f'{argv[0]} exited {result.returncode}' + (' (permission denied)' if denied else ''),
'text': ''}
return {'status': 'ok', 'detail': f'{argv[0]} completed', 'text': result.stdout[:131072]}
def inventory(reader):
usb = []
for path in reader.entries(reader.sysfs / 'bus/usb/devices'):
# USB interface entries have no idVendor; inspect them under their parent below.
if ':' in path.name or not (path / 'idVendor').exists():
continue
vid, pid = reader.read(path / 'idVendor'), reader.read(path / 'idProduct')
bindings = []
for child in reader.entries(path):
if ':' in child.name:
bindings.append({'interface': child.name, 'driver': reader.link(child / 'driver'),
'module': reader.link(child / 'driver/module')})
usb.append({'id': 'usb:' + path.name, 'port': path.name, 'vid': vid, 'pid': pid,
'product': reader.read(path / 'product') if (path / 'product').exists() else None,
'bus': reader.read(path / 'busnum'), 'number': reader.read(path / 'devnum'),
'bindings': bindings, 'interfaces': []})
interfaces = []
for path in reader.entries(reader.sysfs / 'class/net'):
if not ((path / 'wireless').exists() or (path / 'phy80211').exists()):
continue
try:
device_path = (path / 'device').resolve(strict=True)
ancestors = [device_path, *device_path.parents]
except OSError:
ancestors = []
# Nearest USB ancestor: a downstream adapter must not be attributed to its hub.
by_port = {d['port']: d for d in usb}
parent = next((by_port[p.name] for p in ancestors if p.name in by_port), None)
item = {'id': 'net:' + path.name, 'name': path.name,
'usb': parent['id'] if parent else None, 'phy': reader.link(path / 'phy80211'),
'driver': reader.link(path / 'device/driver'),
'module': reader.link(path / 'device/driver/module'),
'state': reader.read(path / 'operstate')}
interfaces.append(item)
if parent:
parent['interfaces'].append(item['id'])
return usb, interfaces
def radio_info(text):
"""Parse only channel facts; omit interface MAC, SSID and link identifiers."""
match = re.search(r'channel (\d+) \((\d+) MHz\)', text)
if not match:
return None
frequency = int(match[2])
band = '2.4 GHz' if 2400 <= frequency < 2500 else '5 GHz' if 4900 <= frequency < 5925 else '6 GHz' if 5925 <= frequency <= 7125 else 'other'
return {'channel': int(match[1]), 'frequency_mhz': frequency, 'band': band}
def supported_frequencies(text):
return sorted(set(int(n) for n in re.findall(r'\* (\d+) MHz \[\d+\](?![^\n]*disabled)', text)))
def diagnose(device=None, *, reader=None, system=None, local=None):
"""Collect only on explicit request. The server calls this outside headset work."""
report = {'schema': 1, 'captured_at': datetime.now(timezone.utc).isoformat(), 'scope': 'Linux PC only; read-only', 'supported': False,
'identity': 'Unverified: select hardware by its observed USB port or network interface. '
'No trusted Steam Frame Wireless Adapter VID/PID is assumed.',
'selected': device, 'usb': [], 'interfaces': [], 'checks': []}
def check(key, status, title, evidence, suggestion):
report['checks'].append(dict(id=key, status=status, title=title,
evidence=evidence, suggestion=suggestion))
if (system or sys.platform) != 'linux' or (os.environ.get('FRAME_LOCAL') == '1' if local is None else local):
check('platform', 'unknown', 'Linux PC required', [],
'Run this doctor on the Linux PC, not on the Frame or the phone server.')
return report
report.update(supported=True, kernel=platform.release())
r = reader or Reader()
usb, interfaces = inventory(r)
report.update(usb=usb, interfaces=interfaces)
items = usb + interfaces
selected = next((d for d in items if d['id'] == device), None)
if device is not None and selected is None:
raise ValueError('Device is no longer present. Refresh and select an observed device.')
check('presence', 'ok' if items else 'unknown', 'USB and wireless interface presence',
[f'{len(usb)} USB devices; {len(interfaces)} wireless interfaces visible in sysfs'],
'Select the intended adapter. USB presence alone does not establish wireless support.' if items else
'No candidates are visible. Check the PC USB connection and whether a VM/container hides hardware; this does not prove the adapter is absent.')
if selected is None:
check('selection', 'unknown', 'Hardware identity needs selection', [],
'Compare the inventory before and after reconnecting the adapter when safe. Select its USB port even if no network interface exists.')
if r.gaps:
check('reads', 'unknown', 'Some local evidence is unavailable', r.gaps,
'Review unavailable paths locally; do not infer healthy hardware from missing evidence.')
return report
correlated = [d for d in interfaces if d['usb'] == device]
nic = selected if device.startswith('net:') else correlated[0] if len(correlated) == 1 else None
if len(correlated) > 1:
check('interface-selection', 'unknown', 'More than one wireless interface',
[d['id'] for d in correlated], 'Select a network interface for per-interface radio and ownership checks.')
parent = selected if device.startswith('usb:') else next((d for d in usb if d['id'] == selected['usb']), None)
names = [d['name'] for d in interfaces if parent and d['usb'] == parent['id']]
check('binding', 'ok' if nic and nic['driver'] else 'unknown', 'Driver and module binding',
([f"{nic['name']}: driver={nic['driver'] or 'unavailable'}, module={nic['module'] or 'unavailable (possibly built in)'}, state={nic['state'] or 'unknown'}"] if nic else ['Multiple wireless interfaces are correlated; select one explicitly.' if len(correlated) > 1 else 'No wireless interface is correlated with this USB device.']) +
([f"USB {parent['port']} observed ID {parent['vid']}:{parent['pid']}; wireless interfaces: {', '.join(names) or 'none'}"] +
[f"{b['interface']}: driver={b['driver'] or 'unbound'}, module={b['module'] or 'unavailable'}" for b in parent['bindings']] if parent else []),
'A bound driver does not prove streaming support. A missing module link can mean a built-in driver.' if nic and nic['driver'] else
'Check this observed device ID against your distribution/kernel and vendor documentation. No driver package or cause can be inferred; do not install a guessed driver or disable Secure Boot.')
path = r.sysfs / ('class/net/' + nic['name'] if nic else 'bus/usb/devices/' + parent['port'])
udev = r.command(['udevadm', 'info', '--query=property', '--path=' + str(path)])
properties = [line for line in udev['text'].splitlines() if line.split('=', 1)[0] in
('ID_NET_DRIVER', 'ID_USB_DRIVER', 'ID_VENDOR_ID', 'ID_MODEL_ID', 'TAGS', 'CURRENT_TAGS')]
access = []
if parent and (parent['bus'] or '').isdigit() and (parent['number'] or '').isdigit():
node = r.dev / f"bus/usb/{int(parent['bus']):03d}/{int(parent['number']):03d}"
try:
st = node.stat()
access.append(f'{node}: mode={stat.S_IMODE(st.st_mode):04o}, uid={st.st_uid}, gid={st.st_gid}, '
f'user read={os.access(node, os.R_OK)}, write={os.access(node, os.W_OK)}')
except OSError as exc:
access.append(f'{node}: {type(exc).__name__}')
check('permissions', 'info' if udev['status'] == 'ok' else 'unknown', 'Permissions and udev evidence',
[udev['detail'], *properties, *access],
'USB node access is only an observation; kernel networking may not require direct USB access. '
'If Steam reports access denied for this exact device, compare the vendor/distribution udev guidance. Do not use broad chmod rules or run Steam as root.')
blocks = []
for entry in r.entries(r.sysfs / 'class/rfkill'):
if r.read(entry / 'type') != 'wlan':
continue
phy = r.read(entry / 'name')
blocks.append({'name': phy, 'soft': r.read(entry / 'soft'), 'hard': r.read(entry / 'hard'),
'selected_phy': bool(nic and nic['phy'] and phy == nic['phy'])})
relevant = [b for b in blocks if b['selected_phy']]
blocked = any(b[k] == '1' for b in relevant for k in ('soft', 'hard'))
complete = relevant and all(b[k] in ('0', '1') for b in relevant for k in ('soft', 'hard'))
check('rfkill', 'warning' if blocked else 'ok' if complete else 'unknown', 'Radio blocks',
[f"{b['name']}: soft={b['soft']}, hard={b['hard']}, selected PHY={b['selected_phy']}" for b in blocks] or ['No readable WLAN rfkill entries.'],
'The selected PHY is blocked. Review the PC radio switch/airplane mode; a hard block may need a hardware switch.' if blocked else
'Only a matching PHY establishes this adapter’s rfkill state. Unrelated or unavailable entries do not rule out a radio block.')
if nic:
iw = r.command(['iw', 'dev', nic['name'], 'info'])
radio = radio_info(iw['text'])
frequencies = []
phy = nic['phy']
if phy and re.fullmatch(r'phy\d+', phy):
capabilities = r.command(['iw', 'phy', phy, 'info'])
frequencies = supported_frequencies(capabilities['text'])
else:
capabilities = {'detail': 'No PHY identifier available'}
check('channel', 'info' if radio else 'unknown', 'Current channel and band',
[iw['detail'], json.dumps(radio) if radio else 'No current channel reported.',
capabilities['detail'], f'Enabled frequencies reported by iw (MHz): {frequencies or "unavailable"}'],
'This is local radio evidence, not proof of the Steam streaming link or supported regions. '
'If no channel is reported the radio may be idle. Do not force a band, channel or regulatory country.')
nm = r.command(['nmcli', '-g', 'GENERAL.STATE,GENERAL.NM-MANAGED,GENERAL.DRIVER', 'device', 'show', nic['name']])
# These fields contain no SSIDs, addresses or credentials.
fields = nm['text'].strip().splitlines()[:3] if nm['status'] == 'ok' else []
nm_evidence = [f'{label}: {value}' for label, value in zip(
('NetworkManager state', 'NetworkManager managed', 'NetworkManager driver'), fields)]
unmanaged = len(fields) > 1 and fields[1].lower() == 'no'
else:
nm = {'detail': 'No correlated wireless interface; per-interface ownership unavailable'}
nm_evidence = []
unmanaged = False
check('channel', 'unknown', 'Current channel and band', [], 'Select one correlated network interface to check channel/band.' if len(correlated) > 1 else 'No correlated wireless interface exists; channel/band cannot be determined.')
processes = set()
proc_entries = r.entries(r.proc)
for entry in proc_entries:
if entry.name.isdigit():
comm = r.read(entry / 'comm')
if comm in ('NetworkManager', 'iwd', 'wpa_supplicant'):
processes.add(comm)
check('managers', 'info' if proc_entries else 'unknown', 'Network manager coexistence',
[f'Visible processes: {", ".join(sorted(processes)) or "none"}', nm['detail'], *nm_evidence],
'NetworkManager commonly uses iwd or wpa_supplicant as its Wi-Fi backend; coexistence alone is not a conflict. '
+ ('Both iwd and wpa_supplicant are visible. Check locally which service owns the selected interface before attributing a conflict; do not stop either automatically.' if {'iwd', 'wpa_supplicant'} <= processes else
'Per-interface ownership by iwd/wpa_supplicant is unverified. Process absence in a restricted /proc view is inconclusive.')
+ (' NetworkManager reports this interface unmanaged. Steam or another service may own it; unmanaged alone is not a fault. Do not force management on.' if unmanaged else ''))
if r.gaps:
check('reads', 'unknown', 'Some local evidence is unavailable', r.gaps[:40],
'Review unavailable paths locally. Permission failures or disappearing devices are not evidence of an adapter fault; do not elevate this doctor.')
check('limits', 'info', 'What this report cannot establish', [],
'No physical adapter or Frame link test runs here. Firmware, USB stability under load, Steam support and the reported Ubuntu/Kubuntu 26.04 cause remain unverified. Review the report before sharing it.')
return report
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument('--device', help='observed selector, e.g. net:wlan0 or usb:1-2; omitted lists candidates')
parser.add_argument('--json', action='store_true', help='print structured local evidence; review before sharing')
args = parser.parse_args(argv)
try:
data = diagnose(args.device)
except ValueError as exc:
parser.error(str(exc))
if args.json:
print(json.dumps(data, indent=2, ensure_ascii=False))
else:
print(data['scope'] + '\n' + data['identity'])
for d in data['usb']:
print(f" {d['id']} {d['vid']}:{d['pid']} {d['product'] or 'unnamed USB device'}")
for d in data['interfaces']:
print(f" {d['id']} USB={d['usb'] or 'not correlated'} driver={d['driver'] or 'unknown'}")
for c in data['checks']:
print(f"\n[{c['status'].upper()}] {c['title']}")
for line in c['evidence']:
print(' ' + line)
print(' ' + c['suggestion'])
return 0 if data['supported'] else 2
if __name__ == '__main__':
raise SystemExit(main())
+7 -1
View File
@@ -1826,7 +1826,7 @@
</div>
<div class="page" data-page="settings">
<div class="workspace-heading"><div><a href="#settings" id="settingsBack" class="workspace-link" hidden>← All settings</a><h1 id="settingsTitle">Settings</h1><p id="settingsSubtitle">Connection, headset controls and Frame Control preferences.</p></div></div>
<div id="settingsDirectory" class="quick-routes"><a href="#devices"><span>Devices &amp; connection</span><small>Connect a Frame or switch headsets</small></a><a href="#control"><span>Control Center</span><small>Live metrics and searchable controls</small></a><a href="#home"><span>Frame activity</span><small>Readiness, running apps and downloads</small></a><a href="#display"><span>App display &amp; reports</span><small>Resolution, scale and compatibility</small></a><a href="#artworkSettings"><span>Library artwork</span><small>Images and artwork sources</small></a><a href="#autoSync" id="syncLink"><span>Copy to this computer</span><small>New screenshots and clips, automatically</small></a><a href="#privacy"><span>Privacy &amp; updates</span><small>Sharing preferences and app updates</small></a><a href="#power"><span>Remote &amp; power</span><small>Remote access, sleep and restart</small></a></div>
<div id="settingsDirectory" class="quick-routes"><a id="wirelessDoctorSettings" href="/wireless-doctor" hidden><span>Linux Wireless Adapter doctor</span><small>Read-only checks on this PC</small></a><a href="#devices"><span>Devices &amp; connection</span><small>Connect a Frame or switch headsets</small></a><a href="#control"><span>Control Center</span><small>Live metrics and searchable controls</small></a><a href="#home"><span>Frame activity</span><small>Readiness, running apps and downloads</small></a><a href="#display"><span>App display &amp; reports</span><small>Resolution, scale and compatibility</small></a><a href="#artworkSettings"><span>Library artwork</span><small>Images and artwork sources</small></a><a href="#autoSync" id="syncLink"><span>Copy to this computer</span><small>New screenshots and clips, automatically</small></a><a href="#privacy"><span>Privacy &amp; updates</span><small>Sharing preferences and app updates</small></a><a href="#power"><span>Remote &amp; power</span><small>Remote access, sleep and restart</small></a></div>
<div id="settingsWorkspace" hidden></div>
</div>
@@ -1843,6 +1843,7 @@
<div class="list" id="devList"><div class="sub">Loading…</div></div>
<div class="hint">Frame Control talks to one headset at a time. Each can be reached at several addresses;
it tries them all at once and uses the best one that answers on the network you're on.</div>
<p id="wirelessDoctorLink" hidden><a href="/wireless-doctor">Linux Wireless Adapter doctor ↗</a><br><span class="sub">Read-only PC checks; works without a connected Frame.</span></p>
<h3 style="margin-top:22px">This computer's network</h3>
<div id="netNow" class="sub">Checking…</div>
<div class="row" style="margin-top:8px; flex-wrap:nowrap">
@@ -2151,6 +2152,11 @@ $("bottombar").onclick = () => {
const HOST = { computer: "computer" };
function applyHostWording(h) {
Object.assign(HOST, h);
for (const id of ["wirelessDoctorSettings", "wirelessDoctorLink"]) {
$(id).hidden = !!h.mobile || h.os !== "Linux";
const link = $(id).tagName === "A" ? $(id) : $(id).querySelector("a");
link.href = "/wireless-doctor?key=" + encodeURIComponent(UI_KEY);
}
window.frameControlCenter?.host(h);
$("desktopBatteryComfort").hidden = !!h.mobile || !window.frameApp?.notify;
// The iPhone app: the server runs on the Frame, and saving goes to the phone's Photos.
+8
View File
@@ -74,6 +74,7 @@ import frame_touch # noqa: E402
import frame_webinstall # noqa: E402
import frame_vr # noqa: E402
import frame_utilities # noqa: E402
import frame_wireless_doctor # noqa: E402
import frame_compat_db # noqa: E402
frame_host.trust_bundled_cas()
@@ -3493,6 +3494,13 @@ class Handler(BaseHTTPRequestHandler):
elif path == "/api/agent/approval":
token = (parse_qs(url.query).get("confirmation") or [""])[0]
self.send_json(frame_agent.approvals.inspect(token))
elif path == "/wireless-doctor":
self.send_bytes((HERE / "wireless-doctor.html").read_bytes(), "text/html; charset=utf-8")
elif path == "/api/wireless-doctor":
# PC-only and independent of LINK/working(): never start or wait for SSH.
args = parse_qs(url.query)
self.send_json(frame_wireless_doctor.diagnose(
(args.get("device") or [None])[0], local=LOCAL))
elif path == "/api/steam-pipewire":
self.send_json(steam_pipewire_state())
elif path == "/api/host":
+85
View File
@@ -0,0 +1,85 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Linux Wireless Adapter doctor · Frame Control</title>
<style>
:root{color-scheme:dark;--bg:#101820;--card:#1b2838;--text:#e4edf5;--sub:#acbccb;--line:#3a5065;--blue:#66c0f4;--warn:#f0c67a}
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);font:16px/1.55 system-ui,sans-serif}main{max-width:960px;margin:auto;padding:32px 24px 64px}a{color:var(--blue)}h1{font-size:30px;line-height:1.2;margin:20px 0 12px}h2{font-size:19px;margin:0 0 10px}p{margin:8px 0 16px}.sub{color:var(--sub)}.badge{display:inline-block;font-size:12px;letter-spacing:.05em;text-transform:uppercase;color:var(--blue);margin-bottom:8px}section{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:22px;margin:18px 0}.row{display:flex;gap:12px;align-items:center;flex-wrap:wrap}button,select{font:inherit;min-height:44px;border:1px solid var(--line);border-radius:5px;padding:9px 14px;background:#263f54;color:var(--text)}button{cursor:pointer}button.primary{background:#205b81;border-color:var(--blue)}button:disabled{opacity:.5;cursor:default}select{max-width:100%;width:100%}label{display:block;margin:16px 0 6px}pre{font:13px/1.6 ui-monospace,monospace;white-space:pre-wrap;overflow-wrap:anywhere;background:#111d29;padding:14px;border-radius:5px}.status{font-size:12px;font-weight:700;text-transform:uppercase;margin-right:10px;color:var(--blue)}.warning .status{color:var(--warn)}.unknown .status{color:var(--sub)}#message{min-height:24px}summary{cursor:pointer;min-height:40px;overflow-wrap:anywhere}:focus-visible{outline:2px solid var(--blue);outline-offset:4px}[hidden]{display:none!important}@media(max-width:540px){main{padding:20px 16px}h1{font-size:25px}section{padding:16px}.row button{flex:1}}
</style>
</head>
<body><main>
<a id="back" href="/#devices">← Devices &amp; connection</a>
<header><h1>Linux Wireless Adapter doctor</h1><span class="badge">This PC · Read-only · On demand</span>
<p>Find what Linux can see before choosing a fix. This doctor reads local USB, driver, radio and network manager evidence. It does not connect to the Frame or change your system.</p></header>
<section aria-labelledby="chooseTitle"><h2 id="chooseTitle">1. Find the adapter</h2>
<p class="sub">The Steam Frame adapter’s hardware ID is unverified here. Nothing is selected automatically. All USB devices are listed so you can select one even when no wireless interface appears.</p>
<button id="inventory" class="primary">Read PC device inventory</button>
<div id="selection" hidden><label for="device">Select the device you want to check</label><select id="device"><option value="">Choose an observed device…</option></select>
<p class="sub">Compare the list before and after reconnecting the adapter when safe. A USB name or ID alone does not confirm that it is Valve’s adapter.</p>
<button id="diagnose" class="primary" disabled>Check selected device</button></div>
<p id="message" role="status" aria-live="polite">No checks have run.</p></section>
<div id="results" aria-live="polite"></div>
<section id="reportSection" hidden aria-labelledby="reportTitle"><h2 id="reportTitle">Local evidence report</h2>
<p class="sub">Review before sharing. The report includes observed USB IDs and product labels, interface names, driver names and local permission metadata. It excludes Wi-Fi names, MAC/IP addresses, USB serial numbers, credentials and raw command output.</p>
<div class="row"><button id="download">Save JSON report</button><button id="copy">Copy JSON report</button></div>
<details><summary>Review JSON</summary><pre id="json"></pre></details></section>
<p class="sub">No repairs, root prompts, driver installs, security changes or network configuration changes. A clean report does not prove Steam streaming support or resolve the Ubuntu/Kubuntu 26.04 complaint.</p>
</main>
<script>
'use strict';
const $ = id => document.getElementById(id);
const key = new URLSearchParams(location.search).get('key') || '1';
$('back').href = '/?key=' + encodeURIComponent(key) + '#devices';
let report = null;
const make = (tag, text) => { const el = document.createElement(tag); el.textContent = text; return el; };
function render(data) {
report = data;
$('json').textContent = JSON.stringify(data, null, 2);
$('reportSection').hidden = false;
$('results').replaceChildren();
for (const c of data.checks) {
const box = document.createElement('section'); box.className = c.status;
const title = make('h2', c.title); const status = make('span', c.status + ' · '); status.className = 'status'; title.prepend(status);
box.append(title, make('p', c.suggestion));
if (c.evidence.length) { const d = document.createElement('details'); d.append(make('summary', 'Show evidence'), make('pre', c.evidence.join('\n'))); box.append(d); }
$('results').append(box);
}
}
async function run(device) {
$('inventory').disabled = $('diagnose').disabled = $('device').disabled = true;
$('message').textContent = device ? 'Reading selected device evidence…' : 'Reading PC inventory…';
// Clear previous results first: a removed device or failed read must not leave a healthy old report.
report = null; $('results').replaceChildren(); $('reportSection').hidden = true;
try {
const response = await fetch('/api/wireless-doctor' + (device ? '?device=' + encodeURIComponent(device) : ''), {headers:{'X-Frame-UI':key}});
const data = await response.json(); if (!response.ok) throw new Error(data.error || 'Could not read local evidence');
render(data);
const select = $('device'); select.replaceChildren(make('option', 'Choose an observed device…')); select.firstChild.value = '';
for (const d of [...data.interfaces, ...data.usb]) {
const text = d.name ? `${d.id} · driver ${d.driver || 'unknown'} · ${d.usb || 'USB not correlated'}` : `${d.id} · ${d.vid || '?'}:${d.pid || '?'} · ${d.product || 'unnamed USB device'}`;
const option = make('option', text); option.value = d.id; select.append(option);
}
select.value = device || ''; $('selection').hidden = !data.supported || select.options.length < 2;
$('message').textContent = !data.supported ? 'Available only on a Linux PC.' : device ? 'Checks finished. Review evidence and limits below.' : 'Inventory read. Select the intended device to continue.';
} catch (e) { $('message').textContent = e.message; }
finally { $('inventory').disabled = $('device').disabled = false; $('diagnose').disabled = !$('device').value; }
}
$('inventory').onclick = () => run();
$('device').onchange = () => {
report = null; $('results').replaceChildren(); $('reportSection').hidden = true;
$('diagnose').disabled = !$('device').value;
$('message').textContent = $('device').value ? 'Selection changed. Check this device for fresh evidence.' : 'Choose the intended device.';
};
$('diagnose').onclick = () => run($('device').value);
$('download').onclick = () => {
if (!report) return;
const url = URL.createObjectURL(new Blob([JSON.stringify(report, null, 2) + '\n'], {type:'application/json'}));
const a = make('a', ''); a.href = url; a.download = 'linux-wireless-doctor.json'; a.click(); setTimeout(() => URL.revokeObjectURL(url), 1000);
};
$('copy').onclick = async () => {
if (!report) return;
try { await navigator.clipboard.writeText(JSON.stringify(report, null, 2)); $('message').textContent = 'Report copied. Review it before sharing.'; }
catch { $('message').textContent = 'Clipboard unavailable. Use Save JSON report or select the JSON below.'; }
};
</script></body></html>