Compare commits

...
Author SHA1 Message Date
Alex SouthwellandSAPHID ae63fa584c Add read-only Windows Wireless Adapter doctor (#166)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-24 12:44:24 +00:00
14 changed files with 816 additions and 3 deletions

No files matched your search

+15
View File
@@ -74,6 +74,21 @@ jobs:
- name: Server tests
run: python -m unittest discover -s tests -v
# Fast, PC-only Windows query validation, without capture libraries or a headset.
wireless-windows:
runs-on: windows-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- uses: actions/setup-node@v4
with:
node-version: "24"
- name: Wireless doctor fixtures, UI and live read-only Windows inventory
run: python -m unittest discover -s tests -p 'test_wireless.py' -v
# The iPhone app: builds for the Simulator and runs its unit tests.
ios:
runs-on: macos-latest
+7
View File
@@ -2,6 +2,13 @@
## Unreleased
- Add Windows Tools → Wireless Adapter doctor: an explicit read-only PC snapshot
of distinct Wi-Fi Direct PnP instances, presence/link state, PC Wi-Fi and
reported channels/bands, with possible 2.4 GHz overlap guidance. Unknowns stay
visible; no adapter disable action is offered because an unused instance
cannot be identified reliably. No headset access or settings changes.
[Sources, verification and limits](docs/windows-wireless-doctor.md).
- 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,
@@ -0,0 +1,74 @@
# Windows Wireless Adapter doctor verification, 24 October 2026
PC-only implementation and verification. No Frame was contacted, before or
after 13:00 UTC. No adapters or security settings were changed. This evidence
does not establish a physical Steam wireless adapter fix or RF interference.
## Windows VM
An existing local Windows 11 VM (build 26200), Python 3.12.14 from the installed
Frame Control bundle, ran the isolated suite from a task-specific Downloads
folder:
```powershell
& 'C:\Users\frame\AppData\Local\Programs\Frame Control\resources\python\python.exe' -m unittest discover -s tests -p test_wireless.py -v
```
Result: **15 tests, OK, one skipped** (Node absent). This includes the real
read-only PowerShell inventory and authenticated HTTP handler tests. The app's
connector was never started. Existing test SSH credentials were used only for
the Windows VM; no credentials were copied into the repository.
- [Exact test output](windows-wireless-tests-2026-10-24.txt).
- [Sanitized live inventory](windows-wireless-summary-2026-10-24.json): two
virtual-machine network devices, with PnP/presence/adapter/route sources
available. WLAN unavailable; channels/bands remained null. All PnP IDs,
device names and interface names were removed from this artifact.
- The guest clock was approximately seven hours ahead of the host. `observedAt`
is retained as returned by Windows, not used to establish test timing.
The first probe used a list of requested default prefixes. A missing IPv6
route caused PowerShell to throw away available IPv4 data. The final query
filters `Get-NetRoute` results instead, and the rerun above confirms routes
are available. No network configuration was changed to make the query work.
## Browser UI and local tests
Production `ui/index.html` ran against a loopback-only synthetic API fixture,
with no connector and no headset transport. Opening/revisiting the tool caused
no wireless query; an explicit click rendered two distinct Wi-Fi Direct PnP
instances and a 2.4 GHz PC interface while the headset state was offline.
Unknown virtual-adapter channels, non-present/disabled status, and unconfirmed
interference remained visible. Checked at 1280 and 640 CSS pixels without
horizontal overflow.
![Production browser UI with explicitly labelled synthetic Windows readings](windows-wireless-synthetic-2026-10-24.png)
The screenshot uses synthetic readings, not the VM's two Ethernet interfaces.
The yellow synthetic-data label was added by the browser fixture for this
capture; it is not product UI.
Local commands:
```sh
python3 -m unittest discover -s tests -p 'test_wireless.py' -v
node tests/wireless.cjs
python3 -m unittest discover -s tests -v
python3 -m py_compile ui/*.py
node --check /tmp/wireless-index-scripts.js
```
The targeted suite passed 15 tests with the live Windows-only test skipped;
the direct Node run passed all four UI tests. The complete server suite passed
1,423 tests with 16 skips before the additional missing-metadata test; a final
complete run and Windows CI results are recorded in the PR. The syntax check
used inline scripts extracted from the production HTML.
## What remains unverified
No physical Steam dongle, Razer headset, live Wi-Fi Direct session, channel
reading from real wireless hardware, dropout reproduction or recovery action.
The English netsh parser is fixture-tested; localised or denied readings remain
unknown. Windows CI validates query execution on hosted virtual hardware.
There is no adapter disable action because the available evidence cannot
reliably identify a safe extra instance.
@@ -0,0 +1,40 @@
{
"platform": "Windows-11-10.0.26200-SP0",
"python": "3.12.14",
"state": "partial",
"sources": {
"pnp": "ok",
"present": "ok",
"adapters": "ok",
"routes": "ok",
"wlan": "unavailable"
},
"observedAt": "2026-10-24T19:15:45.946191+00:00",
"deviceCount": 2,
"devicesWithoutIdentifiers": [
{
"present": true,
"pnpStatus": "OK",
"problemCode": 0,
"operational": 6,
"media": 0,
"defaultRoute": false,
"wifi": false,
"hardware": false,
"channel": null,
"band": null
},
{
"present": true,
"pnpStatus": "OK",
"problemCode": 0,
"operational": 1,
"media": 1,
"defaultRoute": true,
"wifi": false,
"hardware": true,
"channel": null,
"band": null
}
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 214 KiB

@@ -0,0 +1,24 @@
test_ambiguous_adapter_join_never_selects_the_first_match (test_wireless.Inventory.test_ambiguous_adapter_join_never_selects_the_first_match) ... ok
test_channels_require_exact_guid_and_band_is_never_guessed (test_wireless.Inventory.test_channels_require_exact_guid_and_band_is_never_guessed) ... ok
test_counts_unique_pnp_ids_not_rows_or_suffix_order (test_wireless.Inventory.test_counts_unique_pnp_ids_not_rows_or_suffix_order) ... ok
test_distinct_instances_presence_disabled_and_activity_are_separate (test_wireless.Inventory.test_distinct_instances_presence_disabled_and_activity_are_separate) ... ok
test_friendly_name_is_only_a_candidate_and_other_duplicate_names_are_not_direct (test_wireless.Inventory.test_friendly_name_is_only_a_candidate_and_other_duplicate_names_are_not_direct) ... ok
test_missing_interface_metadata_and_invalid_guids_stay_unknown (test_wireless.Inventory.test_missing_interface_metadata_and_invalid_guids_stay_unknown) ... ok
test_missing_sources_are_unknown_and_adapter_fallback_is_retained (test_wireless.Inventory.test_missing_sources_are_unknown_and_adapter_fallback_is_retained) ... ok
test_raw_ssid_mac_and_errors_are_not_returned (test_wireless.Inventory.test_raw_ssid_mac_and_errors_are_not_returned) ... ok
test_concurrent_requests_do_not_spawn_more_probes (test_wireless.Probe.test_concurrent_requests_do_not_spawn_more_probes) ... ok
test_failure_timeout_and_invalid_output_are_explicit_and_release_lock (test_wireless.Probe.test_failure_timeout_and_invalid_output_are_explicit_and_release_lock) ... ok
test_http_is_authenticated_pc_only_and_never_calls_ssh (test_wireless.Probe.test_http_is_authenticated_pc_only_and_never_calls_ssh) ... GET "GET /api/host/wireless HTTP/1.1" 403 -
GET "GET /api/host/wireless HTTP/1.1" 400 -
GET "GET /api/host/wireless HTTP/1.1" 200 -
ok
test_live_windows_inventory (test_wireless.Probe.test_live_windows_inventory) ... ok
test_non_windows_starts_nothing (test_wireless.Probe.test_non_windows_starts_nothing) ... ok
test_static_noninteractive_local_command_and_timeout (test_wireless.Probe.test_static_noninteractive_local_command_and_timeout) ... ok
test_ui_behavior (test_wireless.Probe.test_ui_behavior) ... skipped 'Node required'
----------------------------------------------------------------------
Ran 15 tests in 8.139s
OK (skipped=1)
Windows wireless smoke: partial {'pnp': 'ok', 'present': 'ok', 'adapters': 'ok', 'routes': 'ok', 'wlan': 'unavailable'} devices= 2
+5
View File
@@ -58,6 +58,11 @@ counts them while they run.
restore chosen parts after a factory reset, and **Return to Game Mode** when
the headset is stuck in Desktop Mode. Sign-in files and keys are never copied.
Not tested on a headset yet. See [backup-and-recovery.md](backup-and-recovery.md).
- **Wireless Adapter doctor** (Windows, Tools → This PC): explicitly read local
PnP instances, presence/link states and available channel/band readings;
explain possible Wi-Fi Direct duplicates and 2.4 GHz audio overlap. No headset
access, automatic changes or disable button. See
[Windows wireless diagnosis](windows-wireless-doctor.md).
- **Battery** with charging state: charge rate in watts, time to full or empty,
charger type and wattage (for example USB-C PD 20 W), and battery temperature.
- **Status**: storage, memory, temperature, Wi-Fi, uptime, and whether SteamVR,
+16
View File
@@ -483,3 +483,19 @@ rule, which the original source-only assertion missed. Check computed wrapping,
the complete explanation, tooltip punctuation, Refresh bounds and selection
after **Use this headset** when changing these styles. This fixture does not
exercise Electron integration or a real SSH identity change.
## PC-only Windows wireless diagnosis
```sh
python3 -m unittest discover -s tests -p 'test_wireless.py' -v
node --test tests/wireless.cjs
```
The checks workflow runs this targeted suite on Windows, including the real
read-only PowerShell query, and the normal server suite includes it on all
platforms. HTTP tests instantiate only a handler, never the app connector.
Fixtures cover duplicate instance identity, unknown states, GUID matching,
localised/denied channel data and 6 GHz ambiguity. No tests contact a real Frame.
A Windows CI runner/VM cannot prove physical Steam dongle behaviour or audio RF
interference. See [sources and limits](windows-wireless-doctor.md).
+101
View File
@@ -0,0 +1,101 @@
# Windows Wireless Adapter doctor
Open **Tools → This PC → Wireless Adapter doctor**, then choose **Read PC
adapters**. It works without a headset connection. It only queries this Windows
PC, with no automatic polling, changes, elevation or UAC prompt. It never
contacts the Frame. A direct `#wireless` link on another platform explains that
Windows is required.
The snapshot shows:
- Exact network PnP instance IDs, including non-present devices, with separate
presence, PnP status/problem code, operational state and media connection state.
- Microsoft Wi-Fi Direct Virtual Adapter candidates and their distinct instance
count. `SWD\WIFIDIRECT\` is enumerator evidence; an English device-name match
is explicitly labelled **Name only**. A repeated row for one ID is counted once.
- PC Wi-Fi interfaces, hardware-interface status and whether Windows has a
default route configured on an interface. Neither a route nor **Up** proves
that Steam uses that adapter. Multiple configured defaults are possible.
- Reported channel and band, when `netsh wlan show interfaces` supplies recognised
English fields and an exact matching interface GUID. Missing, localised,
disconnected or denied readings remain **Unknown**. There is no name-based
channel association. A PC's router channel is not the Steam dongle's channel.
- Possible spectrum overlap when an interface reports **2.4 GHz**, without
claiming interference. With no such reading, overlap stays **Unknown**:
unreported virtual/proprietary radios and audio dongles may still use 2.4 GHz.
Channel 1–14 alone does not identify a band: 6 GHz reuses low channel numbers.
## Why there is no disable button
[夕凪's report](https://x.com/Oo_yuunagi_oO/status/2113904070352343135)
(24 October 2026) says extra Microsoft Wi-Fi Direct Virtual Adapter instances
appear when their wireless adapter becomes unavailable, and disabling the newly
appeared one *seems* to stop recurrence. This is useful owner evidence, but does
not establish which instance would be safe to disable on another PC.
Windows can legitimately create several Wi-Fi Direct virtual interfaces.
A `#2` suffix is not a creation timestamp; non-present, down, or disconnected
status is not proof of an unused instance. The inventory does not reliably map
Steam's active radio/session to an expendable PnP instance. Disabling the wrong
one could interrupt streaming, PC Wi-Fi, Mobile Hotspot or wireless displays.
Frame Control therefore supplies evidence and guidance, with no disable,
re-enable, driver removal or other write endpoint. It never changes security or
location settings, requests administrator access, or bypasses execution policy.
Before any manual changes, retain the exact instance IDs and compare snapshots
before/after a drop with Steam Support. Do not disable a device based only on
this report. A future disable feature needs a verified Steam adapter/session
mapping, fresh protection for primary/active instances, explicit confirmation
for one exact instance, and a tested recovery path that re-enables that same ID.
## Checking a 2.4 GHz audio conflict
[Deko's report](https://x.com/DekoDrak/status/2113412044644897023)
(22 October 2026) describes Razer wireless-headset interference and temporarily
disabling the Steam Frame dongle. It also mentions a PC/Wi-Fi shutdown during
Windows Update and asks whether the dongle caused it. The post does not prove
that connection or specify a measured audio radio channel. Treat the suggested
2.4 GHz conflict as a hypothesis, not a diagnosis of the shutdown.
Try wired audio briefly, separate radio dongles with a USB extension lead, or
compare PC Ethernet / an existing 5 or 6 GHz network. Change one thing at a time
and record whether symptoms recur. The doctor cannot measure RF congestion,
read an audio dongle's channel, identify a faulty driver, or prove a dropout fix.
## Sources, privacy and limits
The single local PowerShell process uses `Get-PnpDevice -Class Net`, its
`-PresentOnly` variant, `Get-NetAdapter -IncludeHidden`, `Get-NetRoute` for
IPv4/IPv6 defaults, and `netsh wlan show interfaces`. Each source reports
availability separately. Access denial, unavailable cmdlets/services and
unsupported features produce partial/unknown results; there is no permission
workaround. Queries run sequentially, so even one snapshot can span a state
change. One probe runs at a time, with a 20-second process timeout.
SSIDs, MAC addresses and raw command/error output are omitted from the API/UI.
PnP IDs and adapter/interface names can identify this PC; review before sharing.
The tool does not send or save reports, and does not add these readings to
telemetry or problem-report diagnostics. Copy relevant text manually if needed.
The shipped basic network-identity probe (`frame_network.py`) finds a gateway,
network name and Tailscale state. It does not inventory virtual adapters or
check channel overlap. The new doctor is a separate, explicit PC query.
## Verification
`tests/test_wireless.py` checks duplicate identity, presence/disabled/link
separation, ambiguous joins, partial sources, privacy filtering, 6 GHz/channel
ambiguity, localisation, missing band and denied output. HTTP tests require the
UI key, reject headset-local mode, and assert no SSH call. `tests/wireless.cjs`
executes the production UI with boundary fixtures: navigation starts no query,
clicks query once, unknown values and failure/retry remain visible, and PnP IDs
and names are escaped as text.
Windows CI also executes the real read-only PowerShell probe against the
runner's network devices. That proves Windows query execution, not a physical
Steam dongle, Wi-Fi Direct session or Razer headset. See the PR for exact CI
results. An existing local Windows 11 VM passed 15 targeted tests (one UI test skipped
because Node is absent), including the live query. PnP, presence, adapters and
routes were available; WLAN was unavailable in this VM. See
[VM evidence](evidence/windows-wireless-2026-10-24.md). No Frame was accessed; no physical
adapter/dropout fix or RF measurements are claimed.
+233
View File
@@ -0,0 +1,233 @@
"""PC-only wireless diagnosis: fixtures, HTTP boundaries and live Windows queries.
Never starts the app connector or contacts a headset, including on Windows CI.
"""
import sandbox # noqa: F401
import base64
import copy
import http.client
import json
import shutil
import subprocess
import sys
import threading
import unittest
from pathlib import Path
from unittest.mock import patch
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / 'ui'))
import frame_wireless as wireless
import server
G1 = '11111111-2222-3333-4444-555555555555'
G2 = '66666666-2222-3333-4444-555555555555'
DIRECT = 'SWD\\WIFIDIRECT\\one'
DIRECT2 = 'SWD\\WIFIDIRECT\\two'
PC = 'PCI\\radio'
def wlan(guid=G1, channel=1, band='2.4', state='connected'):
return f' Name : Wi-Fi\n GUID : {{{guid}}}\n State : {state}\n Channel : {channel}\n Band : {band} GHz\n'
def sample():
return {'sources': dict.fromkeys(('pnp', 'present', 'adapters', 'routes', 'wlan'), 'ok'),
'pnp': [{'InstanceId': DIRECT, 'FriendlyName': 'Microsoft Wi-Fi Direct Virtual Adapter', 'Status': 'OK', 'Problem': 0},
{'InstanceId': DIRECT2, 'FriendlyName': 'Microsoft Wi-Fi Direct Virtual Adapter #2', 'Status': 'Error', 'Problem': 22},
{'InstanceId': PC, 'FriendlyName': 'PC radio', 'Status': 'OK', 'Problem': 0}],
'present': [DIRECT, PC],
'adapters': [{'PnPDeviceID': DIRECT, 'InterfaceGuid': G2, 'ifIndex': 2, 'InterfaceOperationalStatus': 2, 'MediaConnectState': 2},
{'PnPDeviceID': PC, 'InterfaceGuid': '{' + G1.upper() + '}', 'ifIndex': 3,
'InterfaceOperationalStatus': 1, 'MediaConnectState': 1, 'NdisPhysicalMedium': 9, 'HardwareInterface': True}],
'routes': [{'InterfaceIndex': 3}], 'wlan': wlan()}
class Inventory(unittest.TestCase):
def test_distinct_instances_presence_disabled_and_activity_are_separate(self):
r = wireless.analyse(sample())
self.assertTrue(r['multipleWifiDirect'])
self.assertEqual(r['wifiDirectInstances'], [DIRECT, DIRECT2])
first, second, pc = r['devices']
self.assertTrue(first['present'])
self.assertEqual(first['operational'], 2)
self.assertFalse(second['present'])
self.assertEqual(second['problemCode'], 22)
self.assertIsNone(second['operational'])
self.assertTrue(pc['defaultRoute'])
self.assertTrue(pc['wifi'])
self.assertTrue(pc['hardware'])
self.assertFalse(first['defaultRoute'])
self.assertEqual(pc['channel'], 1)
self.assertIsNone(first['channel'])
self.assertEqual(r['overlap'], 'possible')
def test_counts_unique_pnp_ids_not_rows_or_suffix_order(self):
d = sample()
d['pnp'].append(dict(d['pnp'][0], InstanceId=DIRECT.upper()))
d['pnp'][1]['FriendlyName'] = 'Microsoft Wi-Fi Direct Virtual Adapter #57'
r = wireless.analyse(d)
self.assertEqual(len(r['wifiDirectInstances']), 2)
self.assertEqual(len(r['devices']), 3)
d['pnp'] = [dict(d['pnp'][0], FriendlyName='ローカライズされた名前')]
d['adapters'] = []
r = wireless.analyse(d)
self.assertTrue(r['devices'][0]['wifiDirect'])
self.assertEqual(r['devices'][0]['identityEvidence'], 'PnP enumerator')
self.assertFalse(r['multipleWifiDirect'])
def test_friendly_name_is_only_a_candidate_and_other_duplicate_names_are_not_direct(self):
d = sample()
d['pnp'] = [{'InstanceId': 'ROOT\\1', 'FriendlyName': 'Microsoft Wi-Fi Direct Virtual Adapter #4'},
{'InstanceId': 'ROOT\\2', 'FriendlyName': 'Ethernet'},
{'InstanceId': 'ROOT\\3', 'FriendlyName': 'Ethernet'}]
d['adapters'] = []
r = wireless.analyse(d)
self.assertEqual(r['wifiDirectInstances'], ['ROOT\\1'])
self.assertEqual(r['devices'][0]['identityEvidence'], 'Name only')
def test_missing_sources_are_unknown_and_adapter_fallback_is_retained(self):
for source in ('present', 'adapters', 'routes', 'wlan'):
d = sample()
d['sources'][source] = 'unavailable'
r = wireless.analyse(d)
pc = r['devices'][2]
self.assertEqual(r['state'], 'partial')
field = {'present': 'present', 'adapters': 'operational', 'routes': 'defaultRoute', 'wlan': 'channel'}[source]
self.assertIsNone(pc[field])
d = sample()
d['sources']['pnp'] = 'unavailable'
r = wireless.analyse(d)
pc = next(x for x in r['devices'] if x['id'] == PC)
self.assertTrue(pc['wifi'])
self.assertIsNone(pc['pnpStatus'])
def test_ambiguous_adapter_join_never_selects_the_first_match(self):
d = sample()
d['adapters'].append(copy.deepcopy(d['adapters'][1]))
pc = wireless.analyse(d)['devices'][2]
self.assertIsNone(pc['channel'])
self.assertIsNone(pc['operational'])
self.assertIsNone(pc['defaultRoute'])
def test_missing_interface_metadata_and_invalid_guids_stay_unknown(self):
d = sample()
d['adapters'][1].pop('NdisPhysicalMedium')
d['adapters'][1].pop('ifIndex')
d['routes'].append({})
pc = wireless.analyse(d)['devices'][2]
self.assertIsNone(pc['wifi'])
self.assertIsNone(pc['defaultRoute'])
for invalid in ('{' + G1, G1 + '}', 'not-a-guid'):
d['adapters'][1]['InterfaceGuid'] = invalid
self.assertIsNone(wireless.analyse(d)['devices'][2]['channel'])
def test_channels_require_exact_guid_and_band_is_never_guessed(self):
d = sample()
d['wlan'] = wlan(G2, 149, '5') + wlan(G1, 1, '6')
r = wireless.analyse(d)
self.assertEqual([(x['channel'], x['band']) for x in (r['devices'][0], r['devices'][2])], [(149, '5'), (1, '6')])
self.assertEqual(r['overlap'], 'unknown')
d['wlan'] = wlan().replace(' Band : 2.4 GHz\n', '')
r = wireless.analyse(d)
self.assertEqual(r['devices'][2]['channel'], 1)
self.assertIsNone(r['devices'][2]['band'])
self.assertEqual(r['overlap'], 'unknown')
for raw in (wlan(G2), wlan(state='disconnected'), wlan(channel='oops'),
wlan().replace('Channel', 'チャネル'), 'Access denied. Location consent required.'):
d['wlan'] = raw
self.assertIsNone(wireless.analyse(d)['devices'][2]['channel'])
d['wlan'] = wlan() + wlan()
self.assertIsNone(wireless.analyse(d)['devices'][2]['channel'])
def test_raw_ssid_mac_and_errors_are_not_returned(self):
d = sample()
d['wlan'] += ' SSID : private-network\n BSSID : 11:22:33:44:55:66\nsecret error text\n'
out = json.dumps(wireless.analyse(d))
for value in ('private-network', '11:22:33:44:55:66', 'secret error text'):
self.assertNotIn(value, out)
class Probe(unittest.TestCase):
def test_non_windows_starts_nothing(self):
with patch.object(wireless.frame_host, 'WINDOWS', False), patch.object(wireless.subprocess, 'run') as run:
self.assertEqual(wireless.diagnose()['state'], 'unsupported')
run.assert_not_called()
def test_static_noninteractive_local_command_and_timeout(self):
result = subprocess.CompletedProcess([], 0, json.dumps(sample()).encode(), b'')
with patch.object(wireless.frame_host, 'WINDOWS', True), patch.object(wireless.subprocess, 'run', return_value=result) as run:
r = wireless.diagnose()
self.assertEqual(r['state'], 'ok')
self.assertIn('observedAt', r)
args, kwargs = run.call_args
command = args[0]
self.assertTrue(command[0].endswith('powershell.exe'))
self.assertEqual(command[1:4], ['-NoProfile', '-NonInteractive', '-EncodedCommand'])
self.assertEqual(base64.b64decode(command[4]).decode('utf-16-le'), wireless.PROBE)
self.assertEqual(kwargs['stdin'], subprocess.DEVNULL)
self.assertEqual(kwargs['timeout'], 20)
self.assertNotIn('shell', kwargs)
def test_failure_timeout_and_invalid_output_are_explicit_and_release_lock(self):
for error in (OSError('private error'), subprocess.TimeoutExpired('powershell', 20)):
with patch.object(wireless.frame_host, 'WINDOWS', True), patch.object(wireless.subprocess, 'run', side_effect=error):
r = wireless.diagnose()
self.assertEqual(r['state'], 'unavailable')
self.assertNotIn('private error', json.dumps(r))
self.assertFalse(wireless._lock.locked())
for output in (b'not JSON', b'[]', b'{}', b'x' * 1_000_001):
with patch.object(wireless.frame_host, 'WINDOWS', True), patch.object(wireless.subprocess, 'run', return_value=subprocess.CompletedProcess([], 0, output, b'')):
self.assertEqual(wireless.diagnose()['state'], 'unavailable')
def test_concurrent_requests_do_not_spawn_more_probes(self):
wireless._lock.acquire()
try:
with patch.object(wireless.frame_host, 'WINDOWS', True), patch.object(wireless.subprocess, 'run') as run:
self.assertEqual(wireless.diagnose()['state'], 'busy')
run.assert_not_called()
finally:
wireless._lock.release()
@unittest.skipUnless(wireless.frame_host.WINDOWS, 'Live read-only Windows inventory runs on Windows CI')
def test_live_windows_inventory(self):
r = wireless.diagnose()
self.assertIn(r['state'], ('ok', 'partial'), r)
self.assertEqual(r['sources']['adapters'], 'ok', r['sources'])
self.assertTrue(r['devices'], 'Windows runner should expose at least its virtual network interface')
print('Windows wireless smoke:', r['state'], r['sources'], 'devices=', len(r['devices']))
def test_http_is_authenticated_pc_only_and_never_calls_ssh(self):
httpd = server.ThreadingHTTPServer(('127.0.0.1', 0), server.Handler)
thread = threading.Thread(target=httpd.serve_forever, daemon=True)
thread.start()
try:
with patch.object(server, 'ssh', side_effect=AssertionError('No headset access')), patch.object(server.frame_wireless, 'diagnose', return_value={'state': 'partial'}) as diagnose:
conn = http.client.HTTPConnection(*httpd.server_address)
conn.request('GET', '/api/host/wireless')
response = conn.getresponse()
self.assertEqual(response.status, 403)
response.read()
diagnose.assert_not_called()
with patch.object(server, 'LOCAL', True):
conn.request('GET', '/api/host/wireless', headers={'X-Frame-UI': server.UI_KEY})
response = conn.getresponse()
self.assertEqual(response.status, 400)
response.read()
diagnose.assert_not_called()
with patch.object(server, 'LOCAL', False):
conn.request('GET', '/api/host/wireless', headers={'X-Frame-UI': server.UI_KEY})
response = conn.getresponse()
self.assertEqual(response.status, 200)
self.assertEqual(json.loads(response.read()), {'state': 'partial'})
diagnose.assert_called_once_with()
conn.close()
finally:
httpd.shutdown()
httpd.server_close()
thread.join()
@unittest.skipUnless(shutil.which('node'), 'Node required')
def test_ui_behavior(self):
r = subprocess.run(['node', '--test', str(ROOT / 'tests/wireless.cjs')], capture_output=True, text=True)
self.assertEqual(r.returncode, 0, r.stdout + r.stderr)
+49
View File
@@ -0,0 +1,49 @@
const {test}=require('node:test');
const assert=require('node:assert/strict');
const {workspaceFixture,html}=require('./workspace_fixture.cjs');
function fixture() {
const f=workspaceFixture();
const start=html.indexOf('// ---- Read-only PC wireless doctor ----');
f.run(html.slice(start,html.indexOf('// ---- Read-only game storage ----',start)));
return f;
}
const sample={state:'partial',observedAt:'2026-10-24T12:00:00Z',sources:{pnp:'ok',wlan:'unavailable'},
multipleWifiDirect:true,wifiDirectInstances:['one','two'],overlap:'unknown',devices:[{
id:'SWD\\WIFIDIRECT\\<unsafe>',name:'<img onerror=alert(1)>',interface:'<script>bad</script>',
wifiDirect:true,identityEvidence:'PnP enumerator',present:false,pnpStatus:'Error',problemCode:22,
operational:null,media:null,defaultRoute:null,channel:1,band:null}]};
test('opening and revisiting the tool never starts a query',()=>{
const f=fixture();let calls=0;f.context.api=()=>calls++;
f.navigate('#wireless');assert.equal(f.activePage,'tools');
assert.equal(f.el('wireless').hidden,false);assert.equal(f.el('wireless').parentNode,f.el('toolWorkspace'));
f.navigate('#tools');f.navigate('#wireless');assert.equal(calls,0);
});
test('distinct instances, unknown states and privacy limits remain explicit and escaped',()=>{
const f=fixture();f.context.sample=sample;const out=f.run('renderWireless(sample)');
for(const phrase of ['2 distinct','do not prove','overlap is unknown','Unknown','(disabled)',
'English netsh','not the Steam dongle channel','review before sharing','&lt;img','&lt;script','&lt;unsafe&gt;']) assert.ok(out.includes(phrase),phrase);
assert.doesNotMatch(out,/<script>|<img/);
assert.doesNotMatch(out,/disable.*button|onclick=/);
f.context.sample={...sample,overlap:'possible'};
assert.match(f.run('renderWireless(sample)'),/spectrum overlap.*possible; interference is unconfirmed/);
});
test('click reads once, disables repeated clicks and renders partial inventory',async()=>{
const f=fixture();let resolve,calls=0;
f.context.api=path=>{assert.equal(path,'/api/host/wireless');calls++;return new Promise(yes=>resolve=yes);};
const request=f.el('wirelessRead').onclick();
assert.equal(f.el('wirelessRead').disabled,true);assert.match(f.el('wirelessStatus').textContent,/Reading/);
resolve(sample);await request;
assert.equal(calls,1);assert.equal(f.el('wirelessRead').disabled,false);
assert.match(f.el('wirelessResult').innerHTML,/distinct Wi-Fi Direct/);
assert.match(f.el('wirelessStatus').textContent,/Partial PC snapshot/);
});
test('unsupported, busy and failed probes leave no stale inventory and allow retry',async()=>{
for(const result of [{state:'unsupported',message:'Windows only'},{state:'busy',message:'Try again'},new Error('Denied')]) {
const f=fixture();f.el('wirelessResult').textContent='stale inventory';
f.context.api=async()=>{if(result instanceof Error) throw result; return result;};
await f.el('wirelessRead').onclick();
assert.equal(f.el('wirelessResult').textContent,'');
assert.match(f.el('wirelessStatus').textContent,/Windows only|Try again|Denied/);
assert.equal(f.el('wirelessRead').disabled,false);
}
});
+188
View File
@@ -0,0 +1,188 @@
"""On-demand, read-only Windows PC wireless inventory. Never contacts a headset.
PnP identity is evidence, not permission to disable a device. Multiple Wi-Fi
Direct instances are normal on some PCs; neither their suffix nor link state
identifies an unused instance or proves which radio belongs to Steam.
"""
import base64
import json
import os
import re
import subprocess
import threading
from datetime import datetime, timezone
import frame_host
TIMEOUT = 20
_lock = threading.Lock()
# Static queries only. No user values, execution-policy override, elevation,
# scans, network traffic, or settings changes. Do not return raw netsh output:
# it can contain SSIDs, MACs and location-permission instructions.
PROBE = r"""
$ErrorActionPreference = 'Stop'
$ProgressPreference = 'SilentlyContinue'
[Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false)
$r = @{pnp=@(); present=@(); adapters=@(); routes=@(); wlan=''; sources=@{}}
try {
$r.pnp = @(Get-PnpDevice -Class Net | Select-Object InstanceId,FriendlyName,Status,Problem)
$r.sources.pnp = 'ok'
} catch { $r.sources.pnp = 'unavailable' }
try {
$r.present = @(Get-PnpDevice -Class Net -PresentOnly | ForEach-Object { $_.InstanceId })
$r.sources.present = 'ok'
} catch { $r.sources.present = 'unavailable' }
try {
$r.adapters = @(Get-NetAdapter -IncludeHidden | Select-Object PnPDeviceID,Name,InterfaceDescription,
InterfaceGuid,ifIndex,InterfaceOperationalStatus,MediaConnectState,NdisPhysicalMedium,HardwareInterface)
$r.sources.adapters = 'ok'
} catch { $r.sources.adapters = 'unavailable' }
try {
$r.routes = @(Get-NetRoute | Where-Object {
$_.DestinationPrefix -eq '0.0.0.0/0' -or $_.DestinationPrefix -eq '::/0'
} | Select-Object InterfaceIndex)
$r.sources.routes = 'ok'
} catch { $r.sources.routes = 'unavailable' }
try {
$r.wlan = (& "$env:SystemRoot\System32\netsh.exe" wlan show interfaces | Out-String)
if ($LASTEXITCODE -eq 0) { $r.sources.wlan = 'ok' } else { $r.sources.wlan = 'unavailable' }
} catch { $r.sources.wlan = 'unavailable' }
ConvertTo-Json -InputObject $r -Depth 5 -Compress
"""
GUID = re.compile(r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", re.I)
def guid(value):
value = str(value or '').strip()
if value.startswith('{') and value.endswith('}'):
value = value[1:-1]
return value.lower() if GUID.fullmatch(value) else None
def number(value):
return value if type(value) is int else None
def text(value):
return value[:2048] if isinstance(value, str) else ''
def rows(value):
return [r for r in value if isinstance(r, dict)] if isinstance(value, list) else []
def parse_wlan(raw):
"""English netsh labels only; localised/denied/missing fields stay unknown.
Bind each block by exact GUID, never by friendly name or output order.
Channels 1–14 also exist in 6 GHz: never infer band from channel alone.
"""
blocks, block = [], {}
for line in raw.splitlines() if isinstance(raw, str) else []:
m = re.match(r'^\s*(Name|GUID|State|Channel|Band)\s*:\s*(.*?)\s*$', line)
if not m:
continue
key, value = m.groups()
if key == 'Name':
if block:
blocks.append(block)
block = {}
block[key] = value
if block:
blocks.append(block)
out = {}
for b in blocks:
identity = guid(b.get('GUID'))
if not identity or identity in out:
# Repeated identities cannot be joined reliably.
if identity:
out[identity] = {'channel': None, 'band': None}
continue
channel = b.get('Channel', '')
channel = int(channel) if re.fullmatch(r'[0-9]{1,3}', channel) else None
band = re.fullmatch(r'(2\.4|5|6)\s*GHz', b.get('Band', ''), re.I)
connected = b.get('State') == 'connected'
out[identity] = {'channel': channel if connected and channel and 1 <= channel <= 233 else None,
'band': band.group(1) if connected and band else None}
return out
def analyse(data):
if not isinstance(data, dict) or not isinstance(data.get('sources'), dict):
raise ValueError('Invalid inventory')
sources = {k: 'ok' if data['sources'].get(k) == 'ok' else 'unavailable'
for k in ('pnp', 'present', 'adapters', 'routes', 'wlan')}
present = {p.casefold() for p in (data.get('present') or []) if isinstance(p, str)}
adapters = {}
for a in rows(data.get('adapters')) if sources['adapters'] == 'ok' else []:
key = text(a.get('PnPDeviceID')).casefold()
if key:
adapters.setdefault(key, []).append(a)
routes = {r['InterfaceIndex'] for r in rows(data.get('routes'))
if number(r.get('InterfaceIndex')) is not None} if sources['routes'] == 'ok' else set()
wlan = parse_wlan(data.get('wlan', '')) if sources['wlan'] == 'ok' else {}
devices, seen = [], set()
inventory = rows(data.get('pnp')) if sources['pnp'] == 'ok' else []
pnp_ids = {text(p.get('InstanceId')).casefold() for p in inventory}
# Retain PC interfaces even when PnP inventory is incomplete/unavailable.
inventory += [{'InstanceId': matches[0]['PnPDeviceID'],
'FriendlyName': matches[0].get('InterfaceDescription')}
for key, matches in adapters.items() if key not in pnp_ids]
for p in inventory:
identity = text(p.get('InstanceId'))
if not identity or identity.casefold() in seen:
continue
seen.add(identity.casefold())
matches = adapters.get(identity.casefold(), [])
a = matches[0] if len(matches) == 1 else {}
name = text(p.get('FriendlyName')) or text(a.get('InterfaceDescription')) or 'Unnamed network device'
direct_id = identity.upper().startswith('SWD\\WIFIDIRECT\\')
direct_name = re.sub(r'\s*#\d+$', '', name).casefold() == 'microsoft wi-fi direct virtual adapter'
radio = wlan.get(guid(a.get('InterfaceGuid')), {})
devices.append({
'id': identity, 'name': name, 'interface': text(a.get('Name')),
'wifiDirect': direct_id or direct_name,
'identityEvidence': 'PnP enumerator' if direct_id else 'Name only' if direct_name else None,
'wifi': number(a.get('NdisPhysicalMedium')) in (1, 9) if number(a.get('NdisPhysicalMedium')) is not None else None,
'hardware': a.get('HardwareInterface') if type(a.get('HardwareInterface')) is bool else None,
'present': identity.casefold() in present if sources['present'] == 'ok' else None,
'pnpStatus': text(p.get('Status')) or None,
'problemCode': number(p.get('Problem')),
'operational': number(a.get('InterfaceOperationalStatus')),
'media': number(a.get('MediaConnectState')),
'defaultRoute': a['ifIndex'] in routes if number(a.get('ifIndex')) is not None and sources['routes'] == 'ok' else None,
'channel': radio.get('channel'), 'band': radio.get('band'),
})
direct = [d['id'] for d in devices if d['wifiDirect']]
return {'sources': sources, 'devices': devices, 'wifiDirectInstances': direct,
'multipleWifiDirect': len(direct) > 1,
'overlap': 'possible' if any(d['band'] == '2.4' for d in devices) else 'unknown',
'state': 'ok' if all(s == 'ok' for s in sources.values()) else 'partial'}
def diagnose():
"""No cached inventory, automatic retries, UAC or SSH. One bounded probe."""
if not frame_host.WINDOWS:
return {'state': 'unsupported', 'message': 'Wireless Adapter doctor is available on a Windows PC.'}
if not _lock.acquire(blocking=False):
return {'state': 'busy', 'message': 'A PC wireless inventory is already running. Try again shortly.'}
try:
executable = os.path.join(os.environ.get('SystemRoot', r'C:\Windows'),
'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe')
command = base64.b64encode(PROBE.encode('utf-16-le')).decode('ascii')
result = subprocess.run([executable, '-NoProfile', '-NonInteractive', '-EncodedCommand', command],
stdin=subprocess.DEVNULL, capture_output=True, timeout=TIMEOUT,
creationflags=getattr(subprocess, 'CREATE_NO_WINDOW', 0))
if result.returncode or len(result.stdout) > 1_000_000:
raise ValueError('Probe failed')
report = analyse(json.loads(result.stdout.decode('utf-8-sig')))
report['observedAt'] = datetime.now(timezone.utc).isoformat()
return report
except subprocess.TimeoutExpired:
return {'state': 'unavailable', 'message': 'Windows wireless inventory timed out. No settings were changed.'}
except (OSError, ValueError, TypeError):
return {'state': 'unavailable', 'message': 'Windows wireless inventory could not be read. No settings were changed.'}
finally:
_lock.release()
+59 -3
View File
@@ -1431,8 +1431,24 @@
<div class="page" data-page="tools">
<div class="workspace-heading"><div><a href="#tools" id="toolBack" class="workspace-link" hidden>← All tools</a><h1 id="toolTitle">Tools</h1><p id="toolSubtitle">Choose a task for your Frame.</p></div></div>
<div id="toolDirectory"><section class="tool-group"><h2>Content & files</h2><div class="quick-routes"><a href="#transfer"><span>Send to Frame</span><small>Add files, APKs or your own games</small></a><a href="#media"><span>Media player</span><small>Play your own videos and media</small></a><a href="#linuxApps"><span>Linux apps</span><small>Install and manage desktop apps</small></a></div></section><section class="tool-group"><h2>Play & control</h2><div class="quick-routes"><a href="#capture"><span>Capture & Live</span><small>See and control the headset view</small></a><a href="#pad" title="Type on the headset (T)"><span>Type on the headset</span><small>Keyboard, trackpad and clipboard</small></a><a href="#panels"><span>Panel switcher</span><small>Bring an open panel into view</small></a><a href="#vrTools"><span>VR tools</span><small>Performance HUD and VR utilities</small></a><a href="#comfort"><span>Family & comfort</span><small>Session reminders and breaks</small></a><a href="#macview" id="macToolLink" hidden><span>Mac in the headset</span><small>Share Mac windows and displays</small></a><a href="#steamPipewire" id="steamPipewireLink" hidden><span>Steam desktop sharing</span><small>Linux: launch Steam or re-pick a screen</small></a><a href="#macview" id="pcToolLink" hidden><span>PC in the headset</span><small>Experimental: share this PC's screen</small></a></div></section><section class="tool-group"><h2>Your Frame</h2><div class="quick-routes"><a href="#powerReport"><span>Power &amp; heat report</span><small>Read-only observation and offline downloads</small></a><a href="#storage"><span>Game storage</span><small>Free space and Steam install sizes</small></a><a href="#backup" id="backupToolLink"><span>Backup &amp; recovery</span><small>Save games and settings, restore after a reset</small></a></div><a class="workspace-link" href="#settings">Connection, display &amp; preferences →</a></section><section class="tool-group"><h2>Assistant</h2><a class="workspace-link" href="/assistant">Open assistant &amp; AI agents →</a><p class="sub">Use your own model endpoint or review a proposed MCP action.</p></section></div>
<div id="toolDirectory"><section class="tool-group"><h2>Content & files</h2><div class="quick-routes"><a href="#transfer"><span>Send to Frame</span><small>Add files, APKs or your own games</small></a><a href="#media"><span>Media player</span><small>Play your own videos and media</small></a><a href="#linuxApps"><span>Linux apps</span><small>Install and manage desktop apps</small></a></div></section><section class="tool-group"><h2>Play & control</h2><div class="quick-routes"><a href="#capture"><span>Capture & Live</span><small>See and control the headset view</small></a><a href="#pad" title="Type on the headset (T)"><span>Type on the headset</span><small>Keyboard, trackpad and clipboard</small></a><a href="#panels"><span>Panel switcher</span><small>Bring an open panel into view</small></a><a href="#vrTools"><span>VR tools</span><small>Performance HUD and VR utilities</small></a><a href="#comfort"><span>Family & comfort</span><small>Session reminders and breaks</small></a><a href="#macview" id="macToolLink" hidden><span>Mac in the headset</span><small>Share Mac windows and displays</small></a><a href="#steamPipewire" id="steamPipewireLink" hidden><span>Steam desktop sharing</span><small>Linux: launch Steam or re-pick a screen</small></a><a href="#macview" id="pcToolLink" hidden><span>PC in the headset</span><small>Experimental: share this PC's screen</small></a></div></section><section class="tool-group" id="wirelessToolGroup" hidden><h2>This PC</h2><div class="quick-routes"><a href="#wireless"><span>Wireless Adapter doctor</span><small>Read-only Windows adapter and interference checks</small></a></div></section><section class="tool-group"><h2>Your Frame</h2><div class="quick-routes"><a href="#powerReport"><span>Power &amp; heat report</span><small>Read-only observation and offline downloads</small></a><a href="#storage"><span>Game storage</span><small>Free space and Steam install sizes</small></a><a href="#backup" id="backupToolLink"><span>Backup &amp; recovery</span><small>Save games and settings, restore after a reset</small></a></div><a class="workspace-link" href="#settings">Connection, display &amp; preferences →</a></section><section class="tool-group"><h2>Assistant</h2><a class="workspace-link" href="/assistant">Open assistant &amp; AI agents →</a><p class="sub">Use your own model endpoint or review a proposed MCP action.</p></section></div>
<div class="grid-3" id="toolWorkspace">
<section class="panel" id="wireless" hidden>
<h2>Wireless Adapter doctor</h2>
<p class="sub">Read-only snapshot of this Windows PC. Works without a headset connection. Nothing on the Frame is read or changed.</p>
<button class="action" id="wirelessRead">Read PC adapters</button>
<p id="wirelessStatus" role="status" aria-live="polite">Choose Read PC adapters to start. No automatic polling.</p>
<div id="wirelessResult"></div>
<div class="card" style="margin-top:16px"><h3>Before changing an adapter</h3>
<p>Multiple Microsoft Wi-Fi Direct Virtual Adapter instances can be normal. A number such as #2, a disconnected link or a non-present device does not establish which instance is extra or safe to disable. This tool cannot reliably identify the Steam dongle's primary or active instance, so it offers no disable action.</p>
<p>Disabling the wrong instance can interrupt Steam streaming, PC Wi-Fi, Mobile Hotspot or wireless displays. Keep the exact PnP instance IDs and compare snapshots before and after a drop with Steam Support. Do not disable an instance based on this list alone.</p>
</div>
<div class="card" style="margin-top:16px"><h3>Check possible 2.4 GHz interference</h3>
<p>A Razer or other 2.4 GHz audio dongle can share spectrum with Wi-Fi. This inventory cannot measure interference, detect the audio dongle's channel or prove what caused a dropout or PC shutdown.</p>
<p>Try wired headphones briefly and compare. Space USB radio dongles apart with an extension lead; if your PC uses 2.4 GHz Wi-Fi, compare Ethernet or an existing 5/6 GHz network. Change one thing at a time and keep notes. No network or security settings are changed here.</p>
</div>
</section>
<section class="panel" id="powerReport">
<h2>Power &amp; heat report</h2>
<p class="sub">Observe battery, CPU, temperatures and available sensors without changing settings. One observation at a time, every five seconds, for at most five minutes.</p>
@@ -5611,6 +5627,45 @@ document.querySelectorAll('[data-power-format]').forEach(b => b.onclick = async
finally { b.disabled = !powerState?.id; }
});
// ---- Read-only PC wireless doctor ----
let wirelessSeq = 0;
const wirelessKnown = value => value === true ? 'Yes' : value === false ? 'No' : 'Unknown';
function renderWireless(r) {
const unknown = value => value == null ? 'Unknown' : esc(value);
const states = (value, known) => known[value] || (value == null ? 'Unknown' : `Other (${esc(value)})`);
return `<p class="hint">${r.multipleWifiDirect ? `${r.wifiDirectInstances.length} distinct Wi-Fi Direct PnP instances found. Multiple instances do not prove a fault or identify a safe disable target.` : 'No duplicate Wi-Fi Direct instances identified in the available inventory. Missing data does not prove there are none.'}</p>
<p>${r.overlap === 'possible' ? '2.4 GHz use reported: spectrum overlap with a 2.4 GHz headset is possible; interference is unconfirmed.' : '2.4 GHz overlap is unknown. Other radios, the Steam dongle and audio channels may be unreported, even when PC Wi-Fi uses 5/6 GHz.'}</p>
<p class="sub">Sources: ${Object.entries(r.sources).map(([key,value]) => `${esc(key)}: ${esc(value)}`).join(' · ')}. Unavailable can mean access denied, missing commands or unsupported Windows features. Channel/band parsing needs English netsh labels and an exact interface GUID. Leave location and security permissions unchanged.</p>
${r.devices.map(d => `<div class="card" style="margin-top:12px;overflow-wrap:anywhere"><h3>${esc(d.name)}</h3>
<p class="sub">${esc(d.interface)}${d.wifiDirect ? ` · Wi-Fi Direct (${esc(d.identityEvidence)})` : d.wifi === true ? ' · PC Wi-Fi' : d.wifi === false ? ' · Other network device' : ' · Network device (type unknown)'} · Hardware interface: ${wirelessKnown(d.hardware)}</p>
<p><strong>PnP instance:</strong> <code style="user-select:text">${esc(d.id)}</code></p>
<p>Present: ${wirelessKnown(d.present)} · PnP status: ${unknown(d.pnpStatus)} · Problem code: ${unknown(d.problemCode)}${d.problemCode === 22 ? ' (disabled)' : ''}</p>
<p>Operational: ${states(d.operational,{1:'Up',2:'Down',3:'Testing',4:'Unknown',5:'Dormant',6:'Not present',7:'Lower layer down'})} · Media: ${states(d.media,{1:'Connected',2:'Disconnected'})} · Default route configured: ${wirelessKnown(d.defaultRoute)}</p>
<p>Reported channel: ${unknown(d.channel)} · Band: ${d.band ? `${esc(d.band)} GHz` : 'Unknown'}</p>
</div>`).join('') || '<p>No readable network devices. This does not prove no adapters are installed.</p>'}
<p class="sub">Snapshot: ${esc(r.observedAt)}. Queries run sequentially; state can change during or after a read. Up/connected and configured routes do not establish which adapter Steam uses. A PC Wi-Fi channel is not the Steam dongle channel; virtual and proprietary radio channels often remain unknown. SSIDs and MAC addresses are omitted. PnP IDs may identify this PC: review before sharing.</p>`;
}
$('wirelessRead').onclick = async () => {
const seq = ++wirelessSeq;
$('wirelessRead').disabled = true;
$('wirelessResult').textContent = '';
$('wirelessStatus').textContent = 'Reading this PC’s adapters…';
try {
const r = await api('/api/host/wireless');
if (seq !== wirelessSeq) return;
if (!['ok','partial'].includes(r.state)) {
$('wirelessStatus').textContent = r.message || 'PC wireless inventory unavailable.';
return;
}
$('wirelessResult').innerHTML = renderWireless(r);
$('wirelessStatus').textContent = r.state === 'partial' ? 'Partial PC snapshot. Some sources are unavailable. No settings changed.' : 'PC snapshot received. Unknown channel/band fields may remain. No settings changed.';
} catch (e) {
if (seq === wirelessSeq) $('wirelessStatus').textContent = `PC wireless inventory unavailable: ${e.message}`;
} finally {
if (seq === wirelessSeq) $('wirelessRead').disabled = false;
}
};
// ---- Read-only game storage ----
let storageSeq = 0;
const storageState = s => ({'permission-denied':'Permission denied', missing:'Missing or disconnected',
@@ -5952,10 +6007,10 @@ const PAGES = ["apps", "games", "tools", "settings"];
const SECONDARY_PAGES = ["home", "capture", "devices", "control"];
const SECTION_PAGE = {library:"games", discover:"apps", android:"apps", view:"capture", shots:"capture", device:"home",
steamPipewire:"tools", vrTools:"tools", comfort:"tools", assistant:"tools", pad:"tools", sideloaded:"games", getgames:"games", steamStore:"games", androidLibrary:"apps", appStore:"apps", linuxApps:"apps",
display:"settings", androidSettings:"settings", transfer:"tools", media:"tools", artworkSettings:"settings", autoSync:"settings", power:"settings", macview:"tools", privacy:"settings", panels:"tools", storage:"tools", powerReport:"tools", backup:"tools"};
display:"settings", androidSettings:"settings", transfer:"tools", media:"tools", artworkSettings:"settings", autoSync:"settings", power:"settings", macview:"tools", privacy:"settings", panels:"tools", storage:"tools", wireless:"tools", powerReport:"tools", backup:"tools"};
const TOOL_TITLES = {steamPipewire:"Steam desktop sharing", transfer:"Send to Frame", media:"Media player", artworkSettings:"Library artwork", autoSync:"Copy to this computer",
pad:"Type on the headset", panels:"Panel switcher", vrTools:"VR tools", comfort:"Family & comfort", androidSettings:"App display & reports",
power:"Remote & power", macview:"Mac in the headset", privacy:"Privacy & updates", storage:"Game storage", powerReport:"Power & heat report", backup:"Backup & recovery"};
power:"Remote & power", macview:"Mac in the headset", privacy:"Privacy & updates", storage:"Game storage", wireless:"Wireless Adapter doctor", powerReport:"Power & heat report", backup:"Backup & recovery"};
const workspace = {source:"all", scope:"installed", apps:"installed", scroll:new Map(), key:null, hash:null, y:0};
let page = "apps";
// Reuse the existing live lists and form handlers in their new destinations.
@@ -7466,6 +7521,7 @@ api("/api/host").then(h => {
window.frameControlCenter?.host(h);
$("macToolLink").hidden = h.mobile || h.os !== "macOS";
$("pcToolLink").hidden = h.mobile || h.os !== "Windows";
$("wirelessToolGroup").hidden = h.mobile || h.os !== "Windows";
$("steamPipewireLink").hidden = h.mobile || h.os !== "Linux";
if (!h.mobile && h.os === "Windows") TOOL_TITLES.macview = "PC in the headset";
$("backupToolLink").hidden = !!h.mobile; // backups are folders on a computer
+5
View File
@@ -72,6 +72,7 @@ import frame_telemetry # noqa: E402
import frame_titles # noqa: E402
import frame_touch # noqa: E402
import frame_webinstall # noqa: E402
import frame_wireless # noqa: E402
import frame_vr # noqa: E402
import frame_utilities # noqa: E402
import frame_wireless_doctor # noqa: E402
@@ -3507,6 +3508,10 @@ class Handler(BaseHTTPRequestHandler):
self.send_json({"os": "SteamOS", "fileManager": None, "computer": DEVICE, "mobile": True} if LOCAL else
{"os": frame_host.NAME, "fileManager": frame_host.FILE_MANAGER,
"computer": "Mac" if frame_host.MAC else "PC"})
elif path == "/api/host/wireless":
if LOCAL:
raise Failure("Wireless Adapter doctor runs on a Windows PC, not a headset", 400)
self.send_json(frame_wireless.diagnose())
elif path == "/api/connection":
self.send_json(connection_state())
elif path == "/api/connection/events":