mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-24 22:00:25 +02:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e9b3ae813f | ||
|
|
427b66f8bc | ||
|
|
b87e915c89 |
No files matched your search
@@ -74,21 +74,6 @@ 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
|
||||
|
||||
@@ -2,13 +2,6 @@
|
||||
|
||||
## 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,
|
||||
|
||||
@@ -76,7 +76,7 @@ Drag files onto the window to send them. Drop a game's .zip, folder or .exe to a
|
||||
<td valign="top">
|
||||
|
||||
**📸 Screenshots and recordings**<br>
|
||||
Browse the shots you take in the headset and save them to your Pictures folder, or have new ones and your Steam clips copied over automatically. Record SteamVR's mirrored headset view, with its sound, or export Steam's clips at any length, as .mp4 files in your Videos folder. [Recording details and passthrough limits](docs/recording.md#does-record-include-passthrough).
|
||||
Browse the shots you take in the headset and save them to your Pictures folder, or have new ones and your Steam clips copied over automatically. Record what the headset shows, with its sound, or export Steam's clips at any length, as .mp4 files in your Videos folder ([not tested on a headset yet](docs/recording.md)).
|
||||
|
||||
**⌨️ Keyboard and trackpad**<br>
|
||||
Type and point in the headset from your computer or phone, through Valve's own input path, with nothing to install. Accents and emoji use KDE Connect, which Frame Control brings along and sets up the first time you type one. Copy text to the headset's clipboard and back again. Press T to open it.
|
||||
@@ -257,7 +257,6 @@ Frame's software fits together, all checked against a real headset and labelled
|
||||
| [Frame Control in detail](docs/frame-control.md) | Every feature, how it works, per-platform notes, building |
|
||||
| [Scripts and headset setup](docs/scripts.md) | The command-line helpers, minimum typing, streaming options, floating panels |
|
||||
| [How the Frame works](docs/how-the-frame-works.md) | SteamVR → gamescope → Plasma, verified facts, debugging |
|
||||
| [SteamOS October 2026 compatibility](docs/steamos-2026-10-compat.md) | Frame OS 0.5.6 connection changes and SteamOS 3.9.5 PC host notes |
|
||||
| [Android apps (Lepton)](docs/apks.md) | Sideloading, the rated F-Droid catalogue, per-app instances |
|
||||
| [Import from my Quest](docs/quest-import.md) | Copy games you own off your Quest by USB; what runs, and why licence-checked games may not |
|
||||
| [My Meta library](docs/meta-library.md) | Experimental: install and test Quest games your Meta account owns, with your own token |
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "frame-control",
|
||||
"version": "0.4.20",
|
||||
"version": "0.4.19",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "frame-control",
|
||||
"version": "0.4.20",
|
||||
"version": "0.4.19",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"electron": "^44.4.5",
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frame-control",
|
||||
"productName": "Frame Control",
|
||||
"version": "0.4.20",
|
||||
"version": "0.4.19",
|
||||
"description": "Desktop app for managing a Valve Steam Frame over SSH",
|
||||
"private": true,
|
||||
"main": "main.js",
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
# SteamOS 0.5.6 compatibility check, 25 October 2026
|
||||
|
||||
The real Frame was accessed **after** the 00:00 AEDT hold, under a separately
|
||||
acquired `/tmp/frame-test.lock`. The coordinator had finished its v0.4.19 smoke
|
||||
test and released its lock first. This check took place approximately 00:04–
|
||||
00:08 AEDT (13:04–13:08 UTC). A device clock reading in the first snapshot was
|
||||
**00:04:58 AEDT**. The one temporary capture was removed and our lock was
|
||||
released. Steam and SteamVR were left running.
|
||||
|
||||
| Check | Observation |
|
||||
|---|---|
|
||||
| OS | `VERSION_ID=0.5.6`, `VARIANT_ID=vr`, `BUILD_ID=20261022.6153909` |
|
||||
| Update channel | `atomupd-manager tracked-branch` returned `beta`; tracked variant `vr`; update status `idle`. `atomupd-manager status` and `check` returned D-Bus `AccessDenied` to the normal user. No root, update, or channel change was attempted. |
|
||||
| Discovery | `frame.local` and a sample `frame-0000.local` name were unresolved from the Linux host. Avahi was active on the Frame, but `/etc/avahi/avahi-daemon.conf` has `publish-addresses=no`. The configured `ssh frame` alias worked. The host reached the Frame over Tailscale, so a local-subnet scan and same-LAN cached-IP reconnection were not exercised. |
|
||||
| Script fallback | `steamos-session-select` was present and `holo-session-select` absent. The installed devkit utilities contained `steamos-prepare-upload` and `steamos-delete`; their `holo-*` replacements were absent. No session switch, install, or delete command was run. |
|
||||
| Battery and memory | Frame Control's `ui/frame_status.py` returned a system battery at 98%, Charging, with its source under `/sys/class/power_supply/max1720x_bat_7-36`; memory was 16,422,952,960 bytes total and 11,256,848,384 bytes available. The battery was 97% at the initial raw sysfs check and 98% at the final check while charging. |
|
||||
| Recording source | The user `steamvr-v4l2cam.service` and `/dev/video99` were present. A single frame from that device was black while the unworn headset was in standby. Frame Control's MP4 path reads that same SteamVR `system.HeadsetView` mirror and never opens a passthrough camera. Active passthrough inclusion in the mirror remains unverified. The sample image was not retained or published. |
|
||||
| Dominant eye | Neither standard `steamvr.vrsettings` path checked had a readable dominant-eye setting. No source or effect on the mirror was verified; Frame Control does not attach dominant-eye metadata. |
|
||||
|
||||
This was a bounded compatibility check, not a rerun of the coordinator's
|
||||
13-step smoke test. The coordinator reported that smoke passed on this same
|
||||
build, with battery 95% to 96% while charging and cleanup complete.
|
||||
@@ -1,74 +0,0 @@
|
||||
# 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.
|
||||
|
||||

|
||||
|
||||
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.
|
||||
@@ -1,40 +0,0 @@
|
||||
{
|
||||
"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.
|
Before Width: | Height: | Size: 214 KiB |
@@ -1,24 +0,0 @@
|
||||
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
|
||||
@@ -58,11 +58,6 @@ 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,
|
||||
|
||||
@@ -8,15 +8,13 @@ installed; the desktop app does not bundle this command.
|
||||
|
||||
```sh
|
||||
scripts/frame-doctor.sh
|
||||
scripts/frame-doctor.sh --alias bedroom --lan 192.168.1.40
|
||||
scripts/frame-doctor.sh --alias bedroom --lan frame.local
|
||||
scripts/frame-doctor.sh --alias frame --lan 192.168.1.40
|
||||
```
|
||||
|
||||
Use only addresses belonging to your intended headset. The command tries the
|
||||
existing `frame` SSH alias first (Tailscale if setup configured it that way),
|
||||
then an optional `--lan` address, then USB-C at `10.86.200.233`. SteamOS 0.5.6
|
||||
stopped publishing mDNS names, so use a known IP for `--lan` if `.local` no longer
|
||||
resolves. `--alias`, `--lan` and `--usb`
|
||||
then `frame.local`, then USB-C at `10.86.200.233`. `--alias`, `--lan` and `--usb`
|
||||
override those defaults. It reports the successful path without printing its
|
||||
address. It neither discovers other devices nor enables Tailscale.
|
||||
|
||||
|
||||
+1
-16
@@ -1,6 +1,6 @@
|
||||
# Recording and Steam clips
|
||||
|
||||
Frame Control can record SteamVR's mirrored headset view, with its sound, and save it as
|
||||
Frame Control can record what the headset shows, with its sound, and save it as
|
||||
an `.mp4` on your computer. It can also export the clips and background
|
||||
recordings from Steam's own game recording, at any length, as one `.mp4` each.
|
||||
Both are on **Tools → Capture & Live**. Everything is saved to
|
||||
@@ -47,21 +47,6 @@ the Frame, converted to `.mp4` there (without re-encoding) and copied to
|
||||
|
||||
Recording and **Live** can run together; both read the same device.
|
||||
|
||||
### Does Record include passthrough?
|
||||
|
||||
**Record captures SteamVR's `system.HeadsetView` mirror from `/dev/video99`.**
|
||||
Frame Control does not open the headset's passthrough cameras or add a separate
|
||||
camera layer to the MP4. If SteamVR puts passthrough into that mirror, it will
|
||||
appear in the recording; Frame Control cannot force or promise that result.
|
||||
Steam's own **Steam clips** use a separate game recording path and may differ.
|
||||
|
||||
On 25 October 2026, the Frame was in standby. A frame captured from
|
||||
`/dev/video99` was black, as expected in standby, so this check **did not
|
||||
establish whether active passthrough appears in the mirror**. A wearer-visible
|
||||
passthrough session and its simultaneous mirror frame are still needed for a
|
||||
yes-or-no answer about passthrough pixels. See the
|
||||
[live compatibility evidence](evidence/steamos-compat-live-2026-10-25.md).
|
||||
|
||||
## Steam clips
|
||||
|
||||
**Steam clips** lists what Steam's game recording has kept on the Frame: saved
|
||||
|
||||
+2
-6
@@ -46,14 +46,14 @@ unless your router's DNS registers DHCP client names.
|
||||
|
||||
```sh
|
||||
ssh-keygen -t ed25519 -f ~/.ssh/id_ed25519_frame -N '' -C "mac->steam-frame"
|
||||
ssh-copy-id -i ~/.ssh/id_ed25519_frame.pub steamos@192.168.1.40
|
||||
ssh-copy-id -i ~/.ssh/id_ed25519_frame.pub steamos@frame.local
|
||||
```
|
||||
|
||||
`~/.ssh/config` block (managed between marker lines by the script):
|
||||
|
||||
```
|
||||
Host frame
|
||||
HostName 192.168.1.40
|
||||
HostName frame.local
|
||||
User steamos
|
||||
IdentityFile ~/.ssh/id_ed25519_frame
|
||||
IdentityFile ~/.ssh/id_rsa_frame_devkit
|
||||
@@ -62,10 +62,6 @@ Host frame
|
||||
```
|
||||
|
||||
The script only asks for the password if the pairing below doesn't work.
|
||||
Replace the example IP with the one in the Frame's Quick Settings. SteamOS
|
||||
0.5.6 stopped publishing `.local` names; an existing SSH alias or the headset's
|
||||
current IP is more reliable. Frame Control remembers authenticated IPs per
|
||||
network and checks the saved SSH identity on reconnect.
|
||||
|
||||
## Password-free pairing (SteamOS devkit service)
|
||||
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
# SteamOS October 2026 compatibility
|
||||
|
||||
SteamOS 3.9.5 is the **PC host / Steam Machine** update. It is not the Frame
|
||||
headset OS. The headset release discussed here is **SteamOS 0.5.6 beta**.
|
||||
|
||||
## Finding and connecting to the headset
|
||||
|
||||
SteamOS 0.5.6 stopped publishing mDNS names, including `frame.local`. Existing
|
||||
addresses in Frame Control still work if they resolve, and the app tries all
|
||||
saved addresses. After an authenticated connection through a `.local` name,
|
||||
Frame Control also remembers the numeric IP on that network. If the name stops
|
||||
resolving, it tries that IP and checks the same pinned SSH identity before using
|
||||
it. The IP is never reused automatically on a different network.
|
||||
|
||||
In **Devices**, **Find on this network** checks the SteamOS devkit advertisement,
|
||||
known IPs and the local IPv4 `/24`. It offers scan results only when their SSH
|
||||
key matches the headset's saved key. The scan is bounded to a private subnet;
|
||||
it cannot find a headset across a router or on another subnet. Enter the IP
|
||||
shown in the headset's Quick Settings in the address field in those cases.
|
||||
For initial pairing, **Set Up Connection** accepts a manual IP and reuses an
|
||||
existing `~/.ssh/config` `HostName` before trying mDNS. A configured SSH alias
|
||||
continues to work. Frame doctor no longer assumes `frame.local`: supply
|
||||
`--lan <IP>` when a separate LAN route is needed.
|
||||
|
||||
## SteamOS script names
|
||||
|
||||
Session switching already prefers `holo-session-select` over
|
||||
`steamos-session-select`, after the Frame's `steamosctl` control. Desktop
|
||||
process detection accepts `holo-nested-desktop` and its old name. The sideload
|
||||
helper prefers `holo-prepare-upload` and `holo-delete` if a future devkit
|
||||
provides them, falling back to the vendored `steamos-*` scripts. The devkit
|
||||
service name `_steamos-devkit._tcp` is a protocol identifier, so it is not
|
||||
renamed.
|
||||
|
||||
## SteamOS 3.9.5 Linux PC hosts
|
||||
|
||||
NetworkManager's `nmcli` supplies the active SSID and route diagnostics; the
|
||||
SSID reader falls back to `iwgetid`. Neither depends on whether NetworkManager
|
||||
uses wpa_supplicant (new installs) or iwd (upgraded installs). The current code
|
||||
does not manage either backend or the Steam Frame Wireless Adapter. An adapter
|
||||
doctor belongs in its own PC host feature.
|
||||
|
||||
## Android APKs and telemetry
|
||||
|
||||
The 0.5.6 Desktop Mode **Add to Steam** action for `.apk` files is another way
|
||||
to make a Steam library entry. Frame Control already creates a non-Steam
|
||||
shortcut for every installed APK through the running Steam client's API, with
|
||||
its own persistent Lepton instance and artwork. It does not edit
|
||||
`shortcuts.vdf` or require a Steam restart, so no extra shortcut option is
|
||||
needed in its install flow.
|
||||
|
||||
The battery reader selects a system battery by power-supply `type` and `scope`
|
||||
and reads standard sysfs fields. The memory reader uses `/proc/meminfo`.
|
||||
Neither relies on the performance overlay's labels or hard-coded SteamOS
|
||||
paths. SteamOS 0.5.6's improved overlay reporting does not establish a sysfs
|
||||
path change. A live check on build `20261022.6153909` confirmed that Frame
|
||||
Control's existing readers return battery and memory values. The dominant-eye
|
||||
setting is not read yet; recording metadata must not claim an eye until its
|
||||
source and effect on `/dev/video99` are verified. The same check found the
|
||||
`beta` update branch, but the normal user could not query update availability
|
||||
through Atomupd; no update was applied. See the
|
||||
[live evidence](evidence/steamos-compat-live-2026-10-25.md).
|
||||
|
||||
Sources: [SteamOS 3.9 news](https://store.steampowered.com/news/app/1675200),
|
||||
[Frame OS news](https://steamcommunity.com/app/4165890/allnews/), and the
|
||||
Frame Control implementation in `ui/frame_network.py`, `ui/frame_status.py`,
|
||||
`ui/frame_android.py`, `ui/frame_link.py` and `frame/android/steam_shortcuts.py`.
|
||||
@@ -483,19 +483,3 @@ 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).
|
||||
@@ -1,101 +0,0 @@
|
||||
# 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.
|
||||
+1
-6
@@ -73,11 +73,7 @@ discover_devkit() {
|
||||
pick_host() {
|
||||
local candidates=()
|
||||
[[ -n "$host_arg" ]] && candidates+=("$host_arg")
|
||||
if [[ -z "$host_arg" ]]; then
|
||||
local saved=$(ssh -G "$FRAME_ALIAS" 2>/dev/null | awk '$1 == "hostname" {print $2; exit}')
|
||||
[[ -n "$saved" && "$saved" != "$FRAME_ALIAS" && "$saved" =~ $HOST_RE ]] && candidates+=("$saved")
|
||||
candidates+=("$FRAME_ALIAS.local" "$FRAME_ALIAS")
|
||||
fi
|
||||
[[ -z "$host_arg" ]] && candidates+=("$FRAME_ALIAS.local" "$FRAME_ALIAS")
|
||||
local h
|
||||
for h in "${candidates[@]}"; do
|
||||
if reachable "$h"; then
|
||||
@@ -228,7 +224,6 @@ pair_with_devkit() {
|
||||
print "==> Looking for the Steam Frame"
|
||||
if ! HOST=$(pick_host); then
|
||||
print -u2 "Could not reach the Frame on port 22 or $DEVKIT_PORT."
|
||||
print -u2 "SteamOS 0.5.6 stopped publishing .local names; use its IP from Quick Settings."
|
||||
print -u2 "Check: Developer Mode on + user password set; same Wi-Fi; no client isolation."
|
||||
print -u2 "Then re-run with the IP from Quick Settings: scripts/connect.sh 192.168.x.y"
|
||||
exit 1
|
||||
|
||||
@@ -293,7 +293,7 @@ def load_baseline(path):
|
||||
def main(argv=None):
|
||||
parser = argparse.ArgumentParser(description='Read-only Frame setup diagnostics. Never pairs, repairs or asks for a password.')
|
||||
parser.add_argument('--alias', default='frame', help='Existing trusted SSH alias (default: frame)')
|
||||
parser.add_argument('--lan', help='Optional LAN IP or host name; SteamOS 0.5.6 no longer publishes .local names')
|
||||
parser.add_argument('--lan', default='frame.local', help='LAN fallback host/IP')
|
||||
parser.add_argument('--usb', default='10.86.200.233', help='USB fallback IP; use only with the intended headset connected')
|
||||
parser.add_argument('--plan', action='store_true', help='Print a read-only repair plan from recognized evidence')
|
||||
parser.add_argument('--save-report', metavar='FILE', help='Create a local JSON baseline (never overwrite an existing file)')
|
||||
@@ -307,8 +307,6 @@ def main(argv=None):
|
||||
print('UNKNOWN Baseline: Cannot read a valid doctor report; no comparison performed.')
|
||||
return 2
|
||||
for value in (args.alias, args.lan, args.usb):
|
||||
if value is None:
|
||||
continue
|
||||
if not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9._:-]{0,252}', value):
|
||||
parser.error('Use a plain alias, hostname or IP address.')
|
||||
print('Frame doctor: read-only; no passwords, pairing, repairs or raw logs. Reports are saved only with --save-report.')
|
||||
@@ -329,11 +327,7 @@ def main(argv=None):
|
||||
print('UNKNOWN Baseline: SSH target configuration differs; comparison refused.')
|
||||
return 2
|
||||
source = Path(__file__).read_text()
|
||||
routes = [('configured alias (Tailscale if configured)', None)]
|
||||
if args.lan:
|
||||
routes.append(('LAN', args.lan))
|
||||
routes.append(('USB-C', args.usb))
|
||||
for label, host in routes:
|
||||
for label, host in (('configured alias (Tailscale if configured)', None), ('LAN', args.lan), ('USB-C', args.usb)):
|
||||
result = run(ssh_args(args.alias, host, identity) + ['python3 -B - --probe'], timeout=90, input=source)
|
||||
if result is not None and result.returncode == 0:
|
||||
try:
|
||||
|
||||
@@ -230,9 +230,8 @@ def cleanup(smoke):
|
||||
# It logs a failed sync and exits 0, so read its log; a second run lists
|
||||
# what Steam still has registered, which must not include ours.
|
||||
try:
|
||||
command = frame_titles.devkit_script('delete') + ' 2>&1'
|
||||
first = ssh(command, timeout=120)
|
||||
second = ssh(command, timeout=120)
|
||||
first = ssh('python3 ~/devkit-utils/steamos-delete 2>&1', timeout=120)
|
||||
second = ssh('python3 ~/devkit-utils/steamos-delete 2>&1', timeout=120)
|
||||
for out in (first, second):
|
||||
if 'sync of devkit games failed' in out:
|
||||
raise AssertionError(out.strip().splitlines()[-1])
|
||||
|
||||
@@ -292,13 +292,6 @@ class Session(unittest.TestCase):
|
||||
patch.object(remote.shutil, 'which', side_effect=lambda n: '/usr/bin/' + n if n in available else None):
|
||||
self.assertEqual(remote.session()['selector'], expected)
|
||||
|
||||
def test_holo_nested_desktop_is_recognized(self):
|
||||
with patch.object(remote, 'SDDM_CONFS', ()), \
|
||||
patch.object(remote, '_session_target', return_value='not-found'), \
|
||||
patch.object(remote, '_running', side_effect=lambda name, full=False: name == 'holo-nested-desktop'), \
|
||||
patch.object(remote.shutil, 'which', return_value=None):
|
||||
self.assertTrue(remote.session()['nested_desktop'])
|
||||
|
||||
def test_script_probe_does_not_match_names_in_transmitted_python_source(self):
|
||||
with patch.object(remote.subprocess, 'call', return_value=1) as call:
|
||||
self.assertFalse(remote._running('steamos-nested-desktop', full=True))
|
||||
|
||||
@@ -185,7 +185,6 @@ class ConfigBlock(unittest.TestCase):
|
||||
fc.write_config("10.0.0.5", 22, "deck")
|
||||
text = fc.CONFIG.read_text(encoding="utf-8")
|
||||
self.assertEqual(fc.configured_user(), "deck") # not "me" from Host other
|
||||
self.assertEqual(fc.configured_host(), "10.0.0.5")
|
||||
finally:
|
||||
fc.SSH_DIR, fc.CONFIG = saved
|
||||
self.assertEqual(text.count(fc.BEGIN), 1)
|
||||
|
||||
@@ -323,16 +323,6 @@ class Pins(Base):
|
||||
|
||||
|
||||
class Registry(Base):
|
||||
def test_mdns_result_remembers_numeric_ip_only_on_its_network(self):
|
||||
d = self.reg.add_device("frame-cache", hosts=["frame-cache.local"])
|
||||
self.reg.record_success(d["id"], "frame-cache.local", "n-home", 2.0, ip="192.168.1.40")
|
||||
a = fd.Registry(self.reg.path).get(d["id"])["addresses"][0]
|
||||
self.assertEqual((a["last_ip"], a["last_ip_network"]), ("192.168.1.40", "n-home"))
|
||||
self.reg.record_success(d["id"], "frame-cache.local", "n-home", 2.0, ip="host;bad")
|
||||
self.assertEqual(self.reg.get(d["id"])["addresses"][0]["last_ip"], "192.168.1.40")
|
||||
self.reg.update_address(d["id"], "frame-cache.local", new_host="frame-next.local")
|
||||
self.assertNotIn("last_ip", self.reg.get(d["id"])["addresses"][0])
|
||||
|
||||
def test_replaced_registry_is_read_even_when_timestamp_and_size_match(self):
|
||||
d = self.reg.add_device('frame', name='First')
|
||||
before = self.reg.path.stat()
|
||||
|
||||
@@ -46,8 +46,7 @@ class FrameDoctor(unittest.TestCase):
|
||||
self.assertFalse(any('/register' in str(c.args[0]) for c in run.call_args_list))
|
||||
|
||||
def test_fallback_preserves_identity_and_disables_mutating_ssh_options(self):
|
||||
_, text, run = self.main([result(code=255), result(json.dumps({'guard': 'loop', 'checks': []}))],
|
||||
argv=['--lan', 'frame.local'])
|
||||
_, text, run = self.main([result(code=255), result(json.dumps({'guard': 'loop', 'checks': []}))])
|
||||
args = run.call_args_list[2].args[0]
|
||||
for flag in ('HostKeyAlias=pinned-frame', 'HostName=frame.local', 'BatchMode=yes',
|
||||
'StrictHostKeyChecking=yes', 'UpdateHostKeys=no', 'PermitLocalCommand=no',
|
||||
@@ -56,10 +55,6 @@ class FrameDoctor(unittest.TestCase):
|
||||
self.assertIn('OK Connection: LAN', text)
|
||||
self.assertIn('Counters are rising', text)
|
||||
|
||||
def test_no_implicit_mdns_fallback(self):
|
||||
_, _, run = self.main([result(code=255), result(code=255)])
|
||||
self.assertFalse(any('HostName=frame.local' in str(c.args[0]) for c in run.call_args_list))
|
||||
|
||||
def test_hostkey_failure_does_not_suggest_deleting_identity(self):
|
||||
_, text, _ = self.main([result(code=255, err='REMOTE HOST IDENTIFICATION HAS CHANGED')] * 3)
|
||||
self.assertIn('Do not delete known_hosts', text)
|
||||
|
||||
@@ -14,15 +14,6 @@ import frame_titles # noqa: E402
|
||||
from frame_titles import FrameError # noqa: E402
|
||||
|
||||
|
||||
class DevkitNames(unittest.TestCase):
|
||||
def test_holo_script_is_preferred_with_vendored_steamos_fallback(self):
|
||||
command = frame_titles.devkit_script('prepare-upload', '--gameid MyGame')
|
||||
self.assertLess(command.index('holo-prepare-upload'), command.index('steamos-prepare-upload'))
|
||||
self.assertIn('[ -f ~/devkit-utils/holo-prepare-upload ]', command)
|
||||
with self.assertRaises(ValueError):
|
||||
frame_titles.devkit_script('unknown')
|
||||
|
||||
|
||||
def elf(machine, e_type=3, interp=True, pad=0):
|
||||
"""A 64-bit little-endian ELF header plus one program header (PT_INTERP or PT_LOAD)."""
|
||||
ident = b'\x7fELF' + bytes([2, 1, 1]) + b'\0' * 9
|
||||
|
||||
@@ -58,50 +58,6 @@ class FailureDetail(unittest.TestCase):
|
||||
|
||||
|
||||
class Probe(unittest.TestCase):
|
||||
def test_mdns_falls_back_only_to_ip_authenticated_on_this_network(self):
|
||||
address = {"host": "frame.local", "kind": "mdns", "last_ip": "192.168.1.40",
|
||||
"last_ip_network": "n-home"}
|
||||
def result(host, port, **kwargs):
|
||||
return {"state": "answered", "ip": host, "rtt_ms": 1} if host == "192.168.1.40" else \
|
||||
{"state": "unresolved", "detail": "Can't find name"}
|
||||
with mock.patch.object(fl, "probe", side_effect=result) as probe:
|
||||
self.assertEqual(fl.probe_address(address, 22, "n-home")["ip"], "192.168.1.40")
|
||||
self.assertEqual([c.args[0] for c in probe.call_args_list], ["frame.local", "192.168.1.40"])
|
||||
probe.reset_mock()
|
||||
self.assertEqual(fl.probe_address(address, 22, "n-cafe")["state"], "unresolved")
|
||||
probe.assert_called_once()
|
||||
|
||||
def test_find_uses_cached_ip_and_neighbours_without_trusting_them(self):
|
||||
device = {"id": "d1", "alias": "frame", "port": 22, "addresses": [{"host": "frame.local", "kind": "mdns",
|
||||
"last_ip": "192.168.1.40", "last_ip_network": "n-test"}]}
|
||||
link = mock.Mock()
|
||||
link.active_device.return_value = device
|
||||
link.state = {"network": NET}
|
||||
def answer(host, port, timeout=3):
|
||||
return {"state": "answered" if host in ("192.168.1.40", "192.168.1.41") else "unresolved",
|
||||
"detail": "test", "ip": host if host.startswith("192.") else None, "rtt_ms": 1}
|
||||
with mock.patch.object(fl, "probe", side_effect=answer), \
|
||||
mock.patch.object(fl.frame_network, "local_scan_candidates", return_value=["192.168.1.40", "192.168.1.41"]), \
|
||||
mock.patch.object(fl.frame_network, "neighbour_cache", return_value=""), \
|
||||
mock.patch.object(fl.frame_devices, "pinned_keys", return_value=[("ssh-ed25519", "SHA256:test")]), \
|
||||
mock.patch.object(fl, "matches_pinned_key", return_value=True), \
|
||||
mock.patch.object(fl.frame_host, "which", return_value=None), \
|
||||
mock.patch("frame_connect.discover_devkit", return_value=[]):
|
||||
found = fl.mdns_find(link)
|
||||
self.assertEqual(found["scanned"], 1) # cached IP is tried once
|
||||
self.assertEqual({h["host"] for h in found["hosts"]}, {"192.168.1.40", "192.168.1.41"})
|
||||
self.assertTrue(found["hosts"][0]["added"] is False)
|
||||
|
||||
def test_scan_requires_saved_matching_ssh_key(self):
|
||||
key = ("ssh-ed25519", fd.fingerprint("a2V5"))
|
||||
response = subprocess.CompletedProcess([], 0, "192.168.1.41 ssh-ed25519 a2V5\n", "")
|
||||
with mock.patch.object(fl.frame_host, "which", return_value="ssh-keyscan"), \
|
||||
mock.patch.object(fl.frame_host, "run_ssh", return_value=response):
|
||||
self.assertTrue(fl.matches_pinned_key("192.168.1.41", 22, [key]))
|
||||
self.assertFalse(fl.matches_pinned_key("192.168.1.41", 22, [("ssh-ed25519", "SHA256:other")]))
|
||||
self.assertFalse(fl.matches_pinned_key("name;bad", 22, [key]))
|
||||
self.assertFalse(fl.matches_pinned_key("192.168.1.41", 22, []))
|
||||
|
||||
def test_answers_refusals_and_unknown_names(self):
|
||||
with socket.socket() as srv:
|
||||
srv.bind(("127.0.0.1", 0))
|
||||
|
||||
@@ -102,17 +102,6 @@ class Parsers(unittest.TestCase):
|
||||
self.assertEqual(fn.parse_nmcli(NMCLI), "Cafe: upstairs")
|
||||
self.assertIsNone(fn.parse_nmcli("no:Neighbour\n"))
|
||||
|
||||
def test_neighbour_scan_stays_on_local_subnet(self):
|
||||
nearby = ("192.168.50.31 dev wlan0 lladdr 00:11:22:33:44:55 REACHABLE\n"
|
||||
"192.168.51.31 dev wlan0 lladdr 00:11:22:33:44:66 STALE\n"
|
||||
"192.168.50.1 dev wlan0 lladdr 00:11:22:33:44:77 STALE\n")
|
||||
self.assertEqual(fn.parse_neighbours(nearby, "192.168.50.9", "192.168.50.1"), ["192.168.50.31"])
|
||||
candidates = fn.local_scan_candidates("192.168.50.9", "192.168.50.1", nearby)
|
||||
self.assertEqual(candidates[0], "192.168.50.31")
|
||||
self.assertEqual(len(candidates), 252)
|
||||
self.assertNotIn("192.168.51.31", candidates)
|
||||
self.assertEqual(fn.local_scan_candidates("100.101.1.4", "100.101.1.1"), [])
|
||||
|
||||
def test_windows(self):
|
||||
self.assertEqual(fn.parse_route_windows(WIN_ROUTE), ("192.168.1.1", "192.168.1.50"))
|
||||
self.assertEqual(fn.parse_arp_windows(WIN_ARP, "192.168.1.1"), "b4:fb:e4:b5:67:55")
|
||||
|
||||
@@ -1,233 +0,0 @@
|
||||
"""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)
|
||||
@@ -1,49 +0,0 @@
|
||||
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','<img','<script','<unsafe>']) 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);
|
||||
}
|
||||
});
|
||||
@@ -428,8 +428,7 @@ def session():
|
||||
gamescope = (_running('gamescope') or _running('gamescope-wl') or
|
||||
_running('start-gamescope-session', full=True))
|
||||
plasma = _running('plasmashell')
|
||||
nested = (_running('holo-nested-desktop', full=True) or
|
||||
_running('steamos-nested-desktop', full=True)) # scripts: process name is the shell's
|
||||
nested = _running('steamos-nested-desktop', full=True) # a script: its process name is the shell's
|
||||
targets = {unit: _session_target(unit) for unit in SESSION_TARGETS}
|
||||
game, desktop = (targets[unit] for unit in SESSION_TARGETS)
|
||||
mode = 'unknown'
|
||||
|
||||
+1
-19
@@ -249,10 +249,7 @@ def pick_host(arg):
|
||||
if arg and not HOST_RE.fullmatch(arg):
|
||||
say(f" - {arg!r} isn't a host name or IP address")
|
||||
return None
|
||||
candidates = [arg] if arg else list(dict.fromkeys([configured_host(), f"{FRAME_ALIAS}.local", FRAME_ALIAS]))
|
||||
for cand in candidates:
|
||||
if not cand:
|
||||
continue
|
||||
for cand in [arg] if arg else [f"{FRAME_ALIAS}.local", FRAME_ALIAS]:
|
||||
host, port = split_port(cand)
|
||||
if reachable(host, port):
|
||||
return host, port
|
||||
@@ -377,20 +374,6 @@ def configured_user():
|
||||
return None
|
||||
|
||||
|
||||
def configured_host():
|
||||
"""Reuse the last configured route before trying a deprecated .local name."""
|
||||
if not CONFIG.exists():
|
||||
return None
|
||||
inside = False
|
||||
for line in CONFIG.read_text(encoding="utf-8").splitlines():
|
||||
if line in (BEGIN, END):
|
||||
inside = line == BEGIN
|
||||
elif inside and line.startswith(" HostName "):
|
||||
host = line[11:].strip()
|
||||
return host if HOST_RE.fullmatch(host) else None
|
||||
return None
|
||||
|
||||
|
||||
def pair_with_devkit(host, port, user):
|
||||
"""Try devkit pairing and confirm key login. -> (user, None) or (user, reason to fall back)."""
|
||||
say("==> Pairing through the headset's SteamOS devkit service (no password)")
|
||||
@@ -441,7 +424,6 @@ def main(argv):
|
||||
found = pick_host(argv[0] if argv else None)
|
||||
while not found:
|
||||
say("Could not reach the Frame over SSH.")
|
||||
say("SteamOS 0.5.6 stopped publishing .local names; use the IP shown in Quick Settings.")
|
||||
say("Check: Developer Mode on and a user password set; same network; no client isolation.")
|
||||
try:
|
||||
typed = input("Type the Frame's IP address (Quick Settings shows it), or press Enter to quit: ").strip()
|
||||
|
||||
+1
-11
@@ -599,11 +599,6 @@ class Registry:
|
||||
for a in d["addresses"]:
|
||||
a.setdefault("networks", [])
|
||||
a.setdefault("label", "")
|
||||
# Old or hand-edited registries must not turn a cached route into an
|
||||
# arbitrary hostname or an SSH option. Only a numeric address is used.
|
||||
if not frame_network.valid_ip(a.get("last_ip", "")):
|
||||
a.pop("last_ip", None)
|
||||
a.pop("last_ip_network", None)
|
||||
if d.get("identity") or any(a.get("last_ok") for a in d["addresses"]):
|
||||
# Files from before the flag existed: it had an identity (or a connection that
|
||||
# worked, which saved one). Kept from now on, whatever happens to its addresses.
|
||||
@@ -779,8 +774,6 @@ class Registry:
|
||||
label = None if label is None else check_text(label, "label")
|
||||
if moved:
|
||||
a.update(host=new_host, networks=[], last_ok=None, last_rtt_ms=None) # a new place: learn again
|
||||
a.pop("last_ip", None)
|
||||
a.pop("last_ip_network", None)
|
||||
if kind is not None:
|
||||
a["kind"] = kind
|
||||
if label is not None:
|
||||
@@ -803,7 +796,7 @@ class Registry:
|
||||
d["addresses"].insert(j, d["addresses"].pop(i))
|
||||
self.save()
|
||||
|
||||
def record_success(self, device_id, host, network_id, rtt_ms, ip=None):
|
||||
def record_success(self, device_id, host, network_id, rtt_ms):
|
||||
"""Learn: this address worked on this network."""
|
||||
with self._changing():
|
||||
try:
|
||||
@@ -814,9 +807,6 @@ class Registry:
|
||||
a["networks"] = (a["networks"] + [network_id])[-16:]
|
||||
a["last_ok"] = time.time()
|
||||
a["last_rtt_ms"] = rtt_ms
|
||||
if ip and frame_network.valid_ip(ip) and network_id:
|
||||
a["last_ip"] = ip
|
||||
a["last_ip_network"] = network_id
|
||||
self._find(device_id)["identity_established"] = True # survives editing the address away
|
||||
self.save()
|
||||
|
||||
|
||||
+8
-77
@@ -26,7 +26,6 @@ when the page asks.
|
||||
Python stdlib only. Runs on this computer, never on the Frame.
|
||||
"""
|
||||
from bisect import bisect_right
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
import copy
|
||||
import hashlib
|
||||
import ipaddress
|
||||
@@ -170,18 +169,6 @@ def probe(host, port, timeout=PROBE_TIMEOUT, update=None):
|
||||
return last or {"state": "timeout", "detail": "No answer"}
|
||||
|
||||
|
||||
def probe_address(address, port, network_id, update=None):
|
||||
"""Try a saved mDNS address, then its last authenticated IP on this network."""
|
||||
res = probe(address["host"], port, update=update)
|
||||
cached = address.get("last_ip") if address.get("last_ip_network") == network_id else None
|
||||
if (address["kind"] == "mdns" and cached and frame_network.valid_ip(cached) and
|
||||
res["state"] in ("unresolved", "timeout", "unreachable")):
|
||||
fallback = probe(cached, port)
|
||||
if fallback["state"] == "answered":
|
||||
return dict(fallback, detail="Last known IP answered (mDNS unavailable)")
|
||||
return res
|
||||
|
||||
|
||||
def _ip_kind(text):
|
||||
"""ipv4, ipv6, ipv6 link-local or tailscale for an IP address (zone allowed), else None."""
|
||||
try:
|
||||
@@ -1062,8 +1049,7 @@ class Link:
|
||||
attempt_no = self.state["attempt"]
|
||||
|
||||
def run_probe(i, host):
|
||||
res = probe_address(ranked[i][0], port, net.get("id"),
|
||||
update=lambda **f: self.probe_update(i, attempt_no, **f))
|
||||
res = probe(host, port, update=lambda **f: self.probe_update(i, attempt_no, **f))
|
||||
res.setdefault("ip", None)
|
||||
res.setdefault("rtt_ms", None)
|
||||
self.probe_update(i, attempt_no, **{k: res[k] for k in ("state", "detail", "ip", "rtt_ms")})
|
||||
@@ -1094,7 +1080,7 @@ class Link:
|
||||
"rtt_ms": results[pick]["rtt_ms"], "why": ranked[pick][1], "network": net.get("id"),
|
||||
"network_name": net["name"]}
|
||||
self.publish(via=via)
|
||||
self.learn(device, a["host"], net, results[pick]["rtt_ms"], results[pick]["ip"])
|
||||
self.learn(device, a["host"], net, results[pick]["rtt_ms"])
|
||||
return True
|
||||
with self.cond:
|
||||
why_not = failure_detail(self.state["error"])
|
||||
@@ -1149,10 +1135,10 @@ class Link:
|
||||
else:
|
||||
done.wait(min(0.5, max(0.01, deadline - time.monotonic())))
|
||||
|
||||
def learn(self, device, host, net, rtt, ip=None):
|
||||
def learn(self, device, host, net, rtt):
|
||||
if device.get("transient"):
|
||||
return
|
||||
self.reg.record_success(device["id"], host, net.get("id"), rtt, ip=ip)
|
||||
self.reg.record_success(device["id"], host, net.get("id"), rtt)
|
||||
self.reg.record_identity(device["id"], [f for _, f in frame_devices.pinned_keys(device["id"])])
|
||||
# Set Up Connection may have changed the block while this attempt ran: take that
|
||||
# in (and reconnect) rather than writing this attempt's older settings over it.
|
||||
@@ -1872,32 +1858,8 @@ def tailscale_find(link, device_id=None):
|
||||
return {"up": True, "peers": peers, "tailnet": ts.get("tailnet"), "message": None}
|
||||
|
||||
|
||||
def matches_pinned_key(host, port, saved):
|
||||
"""Only suggest a scanned SSH host when its key matches this headset's pin."""
|
||||
if not saved or not frame_network.valid_ip(host):
|
||||
return False
|
||||
keyscan = frame_host.which("ssh-keyscan")
|
||||
if not keyscan:
|
||||
return False
|
||||
try:
|
||||
reply = frame_host.run_ssh([keyscan, "-T", "2", "-p", str(port), host],
|
||||
capture_output=True, text=True, stdin=subprocess.DEVNULL, timeout=4)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return False
|
||||
pins = set(saved)
|
||||
for line in reply.stdout.splitlines():
|
||||
fields = line.split()
|
||||
if len(fields) >= 3 and (fields[1], frame_devices.fingerprint(fields[2])) in pins:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def mdns_find(link, device_id=None):
|
||||
"""Find nearby SSH endpoints using mDNS, a remembered IP and a bounded /24 scan.
|
||||
|
||||
Finding an SSH port does not identify a headset. Adding an address still uses
|
||||
the normal pinned SSH identity check before any command is run on it.
|
||||
"""
|
||||
"""Headsets on this network: SteamOS devkit services (mDNS) and <alias>.local."""
|
||||
device = link.reg.get(device_id) if device_id else link.active_device()
|
||||
known = {a["host"].rstrip(".").lower() for a in device.get("addresses") or []}
|
||||
try:
|
||||
@@ -1905,15 +1867,11 @@ def mdns_find(link, device_id=None):
|
||||
found = frame_connect.discover_devkit()
|
||||
except (ImportError, SystemExit, OSError):
|
||||
found = []
|
||||
cached = [a["last_ip"] for a in device.get("addresses", []) if a.get("kind") == "mdns" and a.get("last_ip") and
|
||||
a.get("last_ip_network") == (link.state.get("network") or {}).get("id")]
|
||||
names = list(dict.fromkeys([h.rstrip(".") for h in found] + cached +
|
||||
[f"{device['alias']}.local", "frame.local"]))
|
||||
names = list(dict.fromkeys([h.rstrip(".") for h in found] + [f"{device['alias']}.local", "frame.local"]))
|
||||
rows = [None] * len(names)
|
||||
port = device.get("port") or 22
|
||||
|
||||
def check(i, host):
|
||||
res = probe(host, port, timeout=3)
|
||||
res = probe(host, 22, timeout=3)
|
||||
rows[i] = {"host": host, "state": res["state"], "ip": res.get("ip"), "rtt_ms": res.get("rtt_ms"),
|
||||
"detail": res["detail"], "advertised": host in [h.rstrip(".") for h in found],
|
||||
"added": host.lower() in known or (res.get("ip") or "").lower() in known}
|
||||
@@ -1924,31 +1882,4 @@ def mdns_find(link, device_id=None):
|
||||
for t in threads:
|
||||
t.join(8)
|
||||
hosts = [r for r in rows if r and (r["advertised"] or r["state"] in ("answered", "refused"))]
|
||||
network = link.state.get("network") or {}
|
||||
local = network.get("local_ip") or frame_network.local_ip()
|
||||
candidates = frame_network.local_scan_candidates(local, network.get("gateway"), frame_network.neighbour_cache())
|
||||
candidates = [ip for ip in candidates if ip not in names]
|
||||
saved = frame_devices.pinned_keys(device["id"]) if device.get("id") else []
|
||||
# A random SSH service on the subnet is not a Frame. With no existing pin,
|
||||
# use explicit IP entry; do not offer unverified scan results for pairing.
|
||||
if not saved:
|
||||
candidates = []
|
||||
if candidates:
|
||||
def scan(ip):
|
||||
res = probe(ip, port, timeout=0.3)
|
||||
return res if res["state"] == "answered" and matches_pinned_key(ip, port, saved) else None
|
||||
|
||||
with ThreadPoolExecutor(max_workers=24) as pool:
|
||||
futures = {pool.submit(scan, ip): ip for ip in candidates}
|
||||
for future in as_completed(futures):
|
||||
ip = futures[future]
|
||||
try:
|
||||
res = future.result()
|
||||
except OSError:
|
||||
continue
|
||||
if res:
|
||||
hosts.append({"host": ip, "state": "answered", "ip": ip,
|
||||
"rtt_ms": res.get("rtt_ms"), "detail": "Matches this headset's saved SSH identity",
|
||||
"advertised": False, "added": ip.lower() in known})
|
||||
return {"hosts": hosts, "tool": bool(frame_host.which("dns-sd") or frame_host.which("avahi-browse")),
|
||||
"scanned": len(candidates), "scan_requires_pin": not bool(saved)}
|
||||
return {"hosts": hosts, "tool": bool(frame_host.which("dns-sd") or frame_host.which("avahi-browse"))}
|
||||
@@ -240,42 +240,6 @@ def local_ip(towards="192.0.2.1"):
|
||||
return None
|
||||
|
||||
|
||||
def parse_neighbours(text, local, gateway=None):
|
||||
"""Numeric IPv4 neighbours on this host's /24, excluding itself and its router.
|
||||
|
||||
Accepts Linux `ip neigh`, macOS `arp -an` and Windows `arp -a`. The active
|
||||
search uses the same /24; the cache just puts recently seen peers first.
|
||||
"""
|
||||
if not valid_ip(local):
|
||||
return []
|
||||
subnet = ipaddress.ip_network(local + "/24", strict=False)
|
||||
found = []
|
||||
for match in re.finditer(r"(?<![\d.])(?:\d{1,3}\.){3}\d{1,3}(?![\d.])", text):
|
||||
ip = match.group()
|
||||
if valid_ip(ip) and ipaddress.ip_address(ip) in subnet and ip not in (local, gateway) and ip not in found:
|
||||
found.append(ip)
|
||||
return found
|
||||
|
||||
|
||||
def local_scan_candidates(local=None, gateway_ip=None, neighbours=""):
|
||||
"""Bounded IPv4 search order for an explicit Find action; never scan a WAN."""
|
||||
local = local or local_ip()
|
||||
if (not valid_ip(local) or not ipaddress.ip_address(local).is_private or
|
||||
ipaddress.ip_address(local).is_loopback or is_tailscale(local)):
|
||||
return []
|
||||
subnet = ipaddress.ip_network(local + "/24", strict=False)
|
||||
recent = parse_neighbours(neighbours, local, gateway_ip)
|
||||
return recent + [str(ip) for ip in subnet.hosts() if str(ip) not in recent and str(ip) not in (local, gateway_ip)]
|
||||
|
||||
|
||||
def neighbour_cache():
|
||||
if frame_host.MAC:
|
||||
return run(["arp", "-an"])
|
||||
if frame_host.WINDOWS:
|
||||
return run(["arp", "-a"])
|
||||
return run(["ip", "-4", "neigh", "show"])
|
||||
|
||||
|
||||
def gateway():
|
||||
"""(gateway IP, interface) of the default route."""
|
||||
if frame_host.MAC:
|
||||
|
||||
+3
-11
@@ -31,14 +31,6 @@ GAMES = 'devkit-game' # ditto; steamos-prepare-upload makes
|
||||
STAMP = '.frame-control-stamp'
|
||||
PY = 'python3 ~/' + UTILS + '/'
|
||||
|
||||
|
||||
def devkit_script(name, args=''):
|
||||
"""Prefer Valve's holo name when a newer devkit ships it; use our vendored copy otherwise."""
|
||||
if name not in ('prepare-upload', 'delete'):
|
||||
raise ValueError('unknown devkit script')
|
||||
return (f'if [ -f ~/{UTILS}/holo-{name} ]; then {PY}holo-{name} {args}; '
|
||||
f'else {PY}steamos-{name} {args}; fi')
|
||||
|
||||
# Valve's reserved sideload names: uploading one of these replaces the Steam client itself.
|
||||
# devkit-steam is the trampoline file that switches SteamOS to a sideloaded client
|
||||
# (select_steam.sh); a folder there breaks Valve's devkit tools.
|
||||
@@ -683,7 +675,7 @@ def _install(plan, step, artwork=None):
|
||||
ensure_utils()
|
||||
existed = ssh(f'test -d {GAMES}/{gid} && echo yes || true', timeout=30).strip() == 'yes'
|
||||
step('Preparing the title folder', 0.05)
|
||||
ready = _json_out(ssh(devkit_script('prepare-upload', f'--gameid {gid}'), timeout=60), 'steamos-prepare-upload')
|
||||
ready = _json_out(ssh(f'{PY}steamos-prepare-upload --gameid {gid}', timeout=60), 'steamos-prepare-upload')
|
||||
directory = str(ready.get('directory') or '')
|
||||
if not DIR_RE.match(directory) or not directory.endswith(f'/{GAMES}/{gid}'):
|
||||
raise FrameError(f'steamos-prepare-upload returned an unexpected folder {directory!r}')
|
||||
@@ -734,7 +726,7 @@ def _install(plan, step, artwork=None):
|
||||
if shortcut:
|
||||
frame_android.shortcut_tool('remove', str(shortcut))
|
||||
else:
|
||||
ssh(devkit_script('delete', f'--delete-title {gid}'), timeout=120)
|
||||
ssh(f'{PY}steamos-delete --delete-title {gid}', timeout=120)
|
||||
except FrameError:
|
||||
pass
|
||||
registered = False
|
||||
@@ -904,7 +896,7 @@ def remove(gid):
|
||||
shortcut = None
|
||||
# steamos-delete removes the folder, the shortcut and its Proton prefix (found through the
|
||||
# shortcut), so it runs first; its json files stay, so clear them too.
|
||||
ssh(devkit_script('delete', f'--delete-title {gid}'), timeout=120)
|
||||
ssh(f'{PY}steamos-delete --delete-title {gid}', timeout=120)
|
||||
ssh(f'rm -f {_json_files(gid)}', timeout=30)
|
||||
# Then tidy what it leaves (artwork, collections); best effort.
|
||||
if shortcut:
|
||||
|
||||
+1
-2
@@ -276,8 +276,7 @@ def panels(limit=None):
|
||||
|
||||
|
||||
# The headset's Linux desktop, by process name (docs/how-the-frame-works.md).
|
||||
DESKTOP = ("plasmashell", "kwin_wayland", "kwin_wayland_wrapper", "holo-nested-desktop",
|
||||
"steamos-nested-desktop", "startplasma-wayland")
|
||||
DESKTOP = ("plasmashell", "kwin_wayland", "kwin_wayland_wrapper", "steamos-nested-desktop", "startplasma-wayland")
|
||||
|
||||
|
||||
def desktop_running():
|
||||
|
||||
@@ -1,188 +0,0 @@
|
||||
"""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()
|
||||
+4
-62
@@ -1431,24 +1431,8 @@
|
||||
|
||||
<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" 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 & 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 & recovery</span><small>Save games and settings, restore after a reset</small></a></div><a class="workspace-link" href="#settings">Connection, display & preferences →</a></section><section class="tool-group"><h2>Assistant</h2><a class="workspace-link" href="/assistant">Open assistant & 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"><h2>Your Frame</h2><div class="quick-routes"><a href="#powerReport"><span>Power & 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 & recovery</span><small>Save games and settings, restore after a reset</small></a></div><a class="workspace-link" href="#settings">Connection, display & preferences →</a></section><section class="tool-group"><h2>Assistant</h2><a class="workspace-link" href="/assistant">Open assistant & 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 & 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>
|
||||
@@ -5627,45 +5611,6 @@ 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',
|
||||
@@ -6007,10 +5952,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", wireless:"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", 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", wireless:"Wireless Adapter doctor", powerReport:"Power & heat report", backup:"Backup & recovery"};
|
||||
power:"Remote & power", macview:"Mac in the headset", privacy:"Privacy & updates", storage:"Game storage", 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.
|
||||
@@ -7346,7 +7291,6 @@ function renderDetail() {
|
||||
<span class="sub">Identity: ${d.pinned ? "saved. A different device answering at one of these addresses is refused."
|
||||
: d.identity_lost ? "the saved key is missing or can't be read, so nothing is trusted until you check its key."
|
||||
: "not saved yet; the next connection saves it."}</span></div>
|
||||
<div class="hint">SteamOS 0.5.6 stopped publishing mDNS names. If a <code>.local</code> name stops working, Frame Control tries its last known IP on the same network. Find on this network checks nearby IPs; you can also enter the headset's IP above or use its SSH alias. A found SSH port is checked against the headset's saved identity before use.</div>
|
||||
<div class="hint">An address's kind is worked out from it: <code>.local</code> names are mDNS, <code>100.x</code> and
|
||||
<code>.ts.net</code> are Tailscale, and other IPs are this network. Edit an address to set it yourself.</div>
|
||||
</details>`;
|
||||
@@ -7424,9 +7368,8 @@ function renderFound(d) {
|
||||
rows = f.data.hosts.map(h => `<div class="item"><span class="dot ${h.state === "answered" ? "on" : ""}"></span>
|
||||
<div class="grow"><div class="t">${esc(h.host)} ${h.advertised ? '<span class="tag">SteamOS devkit</span>' : ""}</div>
|
||||
<div class="s res-${esc(h.state)}">${esc(h.detail)}${h.ip ? " · " + esc(h.ip) : ""}</div></div>
|
||||
${add(h.host, h.host.endsWith('.local') ? "mdns" : "lan", h.host.endsWith('.local') ? "mDNS" : "Local network", h.added)}${h.ip && h.ip !== h.host && !h.ip.includes(":") && !h.added ? add(h.ip, "lan", "", false) : ""}</div>`);
|
||||
${add(h.host, "mdns", "mDNS", h.added)}${h.ip && !h.ip.includes(":") && !h.added ? add(h.ip, "lan", "", false) : ""}</div>`);
|
||||
if (!f.data.tool) rows.push(`<div class="sub">Install Bonjour (Windows) or avahi (Linux) to search for SteamOS devkit services.</div>`);
|
||||
rows.push(`<div class="sub">${f.data.scan_requires_pin ? "Enter this headset's IP above to pair it first. The subnet search starts after its SSH identity is saved." : `Checked ${Number(f.data.scanned || 0)} nearby IPs against this headset's saved SSH identity.`} A device may be absent if SSH is off or this computer is on another subnet.</div>`);
|
||||
}
|
||||
el.innerHTML = `<h3>${f.where === "tailscale" ? "On your tailnet" : "On this network"}</h3>` +
|
||||
(rows.length ? `<div class="list">${rows.join("")}</div>` : `<div class="sub">Nothing found.</div>`);
|
||||
@@ -7521,7 +7464,6 @@ 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
|
||||
|
||||
@@ -72,7 +72,6 @@ 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
|
||||
@@ -3508,10 +3507,6 @@ 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":
|
||||
|
||||
Reference in new issue
Block a user