You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Copy file name to clipboardExpand all lines: openspec/specs/mdns-scanning/spec.md
+22-6Lines changed: 22 additions & 6 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -80,9 +80,15 @@ The chain SHALL run in this order and SHALL return on the first step that produc
80
80
-**AND** the panel renders it as `(unknown)` / `(未知)`
81
81
82
82
### Requirement: The state map SHALL expire entries on `remove_service` callbacks AND fall back to a TTL when no remove is observed
83
-
The `ServiceListener``remove_service` callback is the primary source of truth: when the library fires it, the entry SHALL be removed from the snapshot. Before applying the TTL backstop on each snapshot tick, the poller SHALL walk its state map and bump each entry's `last_seen` to `now` whenever zeroconf's DNS cache still holds any non-expired record for that service instance (looked up via `Zeroconf.cache.entries_with_name(name.lower())` filtered through `record.is_expired(now)`). This keeps the local TTL aligned with zeroconf's own record-cache lifetimes — a HomePod re-asserting an unchanged AirPlay record fires no `update_service` callback (zeroconf is change-driven), but zeroconf still holds the record in its cache and the poller SHALL treat the service as alive.
83
+
The `ServiceListener``remove_service` callback is the primary source of truth: when the library fires it, the entry SHALL be removed from the snapshot.
84
84
85
-
As a last-resort sweep for the rare case where zeroconf neither fires `remove_service` nor keeps the record cached (network change, library bug), the poller SHALL also expire entries whose `last_seen` is older than `_BROWSE_TTL_S` (default 300 seconds, exposed for tests). This default replaces the prior 60 s value — a 1-minute TTL was evicting most of a normal home network's mDNS surface because stable services (HomePods, printers, cameras) rarely change their announced info.
85
+
The poller SHALL keep tracked entries alive against zeroconf's own record-cache expiry via two complementary paths, applied on every snapshot tick before the TTL backstop:
86
+
87
+
1.**Cache-refresh (passive).** Walk the state map and bump each entry's `last_seen` to `now` whenever `Zeroconf.cache.entries_with_name(name.lower())` returns at least one non-expired record (filtered via `record.is_expired(now)`). Handles the case where some other zeroconf path (e.g. the library's own periodic re-queries) already refreshed the cache.
88
+
89
+
2.**Active per-service re-probe.** Periodically (cadence ≥ every 30 s per entry, default 30 s) the poller SHALL schedule a fire-and-forget `AsyncServiceInfo.async_request(zc, 1500)` for each tracked `(type, name)` pair. The probe is dispatched via `self._loop.create_task(self._apply_callback("update", type, name))`; the snapshot loop does NOT await it. A live device responds with fresh SRV / TXT records; `_apply_callback` writes them into `_state` and bumps `last_seen`. An unresponsive device's probe is a no-op (`_state` is not mutated); the entry then falls through to the cache-refresh and TTL paths normally.
90
+
91
+
As a last-resort sweep for the rare case where neither callback nor cache-hit nor probe-response observes a tracked service (network change, library bug, zeroconf instance died), the poller SHALL also expire entries whose `last_seen` is older than `_BROWSE_TTL_S` (default 300 seconds, exposed for tests). With the active-probe path keeping live services indefinitely-alive, the TTL is a genuine backstop, not the primary eviction mechanism.
86
92
87
93
#### Scenario: Graceful disappearance
88
94
-**WHEN**`zeroconf` fires `remove_service` for a previously-announced service instance
@@ -92,16 +98,26 @@ As a last-resort sweep for the rare case where zeroconf neither fires `remove_se
92
98
-**WHEN** a HomePod re-announces the same AirPlay record every 30 s, so zeroconf does NOT fire `update_service` (the record's info is unchanged) and `last_seen` would otherwise stay frozen at the original `add_service` time
93
99
-**AND** zeroconf's DNS cache still holds at least one non-expired record for the service-instance name
94
100
-**THEN** the poller bumps the entry's `last_seen` to `now` on the next snapshot tick
95
-
-**AND** the entry SHALL NOT be evicted by the TTL backstop, regardless of how long the service has been silent at the callback layer
101
+
102
+
#### Scenario: Stable service whose announce TTL is shorter than 300 s stays alive via active probe
103
+
-**WHEN** a Bonjour device's announce-published record TTL is 120 s (typical for some HomePods / printers) so zeroconf's DNS cache holds the record only briefly
104
+
-**AND** the cache-refresh path runs out of non-expired records after ~2 min of no callback updates
105
+
-**THEN** the active per-service re-probe fires every 30 s, hits the device's mDNS responder, gets back fresh SRV / TXT records, refreshes zeroconf's cache, and writes a fresh `last_seen` into `_state` via `_apply_callback`
106
+
-**AND** the entry stays in the snapshot indefinitely so long as the device responds
107
+
108
+
#### Scenario: Active probe does NOT block the snapshot loop
109
+
-**WHEN** an unresponsive device causes the probe to hang the full 1500 ms timeout
110
+
-**AND** the snapshot interval is 2 s
111
+
-**THEN** the snapshot tick still yields within `snapshot_interval_s + ε` (probe runs in its own task; the events generator does NOT await it)
96
112
97
113
#### Scenario: Silent disappearance falls back to TTL
98
-
-**WHEN** a service stopped advertising and zeroconf's DNS cache no longer holds any non-expired record for the service-instance name
99
-
-**AND**`_BROWSE_TTL_S` (default 300 s) has elapsed since the last cache hit
114
+
-**WHEN** a service stopped advertising AND zeroconf's DNS cache no longer holds any non-expired record AND the active probe gets no response for the TTL window
115
+
-**AND**`_BROWSE_TTL_S` (default 300 s) has elapsed since `last_seen`
100
116
-**THEN** the entry is removed from the snapshot at the next interval
101
117
102
118
#### Scenario: Cache-refresh is a no-op when the cache returns only expired records
103
119
-**WHEN** zeroconf's cache returns records for the service-instance name BUT every record reports `is_expired(now) == True`
104
-
-**THEN** the poller SHALL NOT bump `last_seen`; the entry's age is governed solely by the TTL backstop from this point
120
+
-**THEN** the cache-refresh path SHALL NOT bump `last_seen`; the active probe and the TTL backstop govern the entry's fate from this point
105
121
106
122
### Requirement: The `BonjourPanel` SHALL render the same vendor / name / services / age / id columns as the BLE panel
107
123
`BonjourPanel.compose()` SHALL build a `VerticalScroll` containing a `Static` body. The body SHALL render one row per `BonjourDevice` in `addresses`-then-name order, with columns (left to right): vendor (cyan when resolved, dim when `(unknown)`), name (white when present, dim italic when missing), service-category (cyan), age relative to "now" (dim), short id (first 8 chars of the service-instance name, dim). CJK column alignment SHALL use `pad_cells` / `fit_cells`.
Copy file name to clipboardExpand all lines: openspec/specs/wifi-scanning/spec.md
+17Lines changed: 17 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -101,3 +101,20 @@ On every poll where the associated `(ssid, bssid)` is unchanged, the backend SHA
101
101
-**WHEN** the first poll after associate / launch reports `transmitRate()=0` and there is no prior non-zero observation
102
102
-**THEN**`Connection.tx_rate_mbps=None`, `tx_rate_idle=False` (the flag is only set when a real cached value is being substituted in; surfacing `n/a` is still the right answer when nothing has ever been observed)
103
103
104
+
### Requirement: The Connection panel SHALL hide the Max field when CoreWLAN reports `Max < Tx`
105
+
The connection-panel renderer SHALL detect the case where `Connection.tx_rate_mbps > Connection.max_link_speed_mbps` — a known CoreWLAN flakiness on macOS 26 where `maximumLinkSpeed()` returns a stale / under-reported value while `transmitRate()` returns the current (higher) PHY rate. In that case the renderer SHALL surface the Tx half alone as `Tx <rate> Mbps`, omitting the trailing ` / <smaller> Mbps`. The "Tx and Max use different CoreWLAN APIs and may diverge" footnote SHALL remain — when Max is plausibly conservative-but-not-wrong (Max ≥ Tx, or Max is None), the row continues to render both numbers.
106
+
107
+
The behaviour SHALL be purely renderer-side: `Connection.max_link_speed_mbps` is unchanged at the model layer; only the `Tx / Max` row visually omits Max when the inconsistency is detected.
108
+
109
+
#### Scenario: macOS 26 reports Tx 286 / Max 229
110
+
-**WHEN**`transmit_rate_mbps=286.0` and `max_link_speed_mbps=229` on the same `Connection`
111
+
-**THEN** the rendered Tx / Max row reads `Tx 286.0 Mbps` (no `/ 229 Mbps` suffix); the `(idle)` annotation logic and the row label are unchanged
112
+
113
+
#### Scenario: Max greater than or equal to Tx renders both
114
+
-**WHEN**`transmit_rate_mbps=144.0` and `max_link_speed_mbps=867` (the typical healthy case)
115
+
-**THEN** the rendered Tx / Max row reads `Tx 144.0 Mbps / 867 Mbps`
116
+
117
+
#### Scenario: Max unknown renders Tx alone
118
+
-**WHEN**`max_link_speed_mbps is None` (CoreWLAN selector unavailable on older macOS)
0 commit comments