mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-24 17:01:40 +02:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e9b3ae813f | ||
|
|
427b66f8bc | ||
|
|
b87e915c89 |
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
@@ -0,0 +1,118 @@
|
||||
# Linux Wireless Adapter doctor
|
||||
|
||||

|
||||
|
||||
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.
|
||||
@@ -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');
|
||||
@@ -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)
|
||||
@@ -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
@@ -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 & 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 & 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 & updates</span><small>Sharing preferences and app updates</small></a><a href="#power"><span>Remote & 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 & 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 & 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 & updates</span><small>Sharing preferences and app updates</small></a><a href="#power"><span>Remote & 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.
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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 & 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>
|
||||
Reference in new issue
Block a user