Skip to content

Commit 28a9f9a

Browse files
authored
Merge pull request #86 from chenchaoyi/feature/wifi-event-ssid-and-name-enrichment
feat(events): SSID enrichment for Wi-Fi roam + RF stir lines
2 parents 21d49d0 + 90e9140 commit 28a9f9a

17 files changed

Lines changed: 722 additions & 6 deletions

File tree

docs/zh/TESTING.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,7 @@
158158
| Cooldown + rearm 防止重复事件 | (gap — 没有专门的 cooldown / rearm 测试;行为在 fusion-confidence 测试里间接覆盖) |
159159
| 校准从文件加载 | `test_environment.py::test_calibration_overrides_adaptive_baseline``::test_calibration_round_trip``::test_load_calibration_returns_empty_dict_on_missing_file` |
160160
| 措辞:相关性,不是「有人」 | (review-enforced — `i18n.py` 没有任何字符串断言 "person" / "motion" / "presence") |
161+
| `RFStirEvent` 在触发时把当前 `Connection.ssid` 带进事件 | `test_environment.py::test_rf_stir_event_carries_ssid_from_current_connection` |
161162

162163
### `event-log`
163164

@@ -181,6 +182,7 @@
181182
| JSONL 用英文键 | `test_event_log.py::test_schema_keys_stay_english_under_zh_locale` |
182183
| 时间戳本地 TZ ISO-8601 带偏移 | `test_event_log.py::test_timestamps_are_iso_utc``::test_naive_datetime_treated_as_local_not_utc` |
183184
| `NetworkChangeEvent` 是控制信号,用户不可见 | `test_event_log.py::test_emit_network_change_carries_router_ip_transition`(writer 接受它);不进 user-visible-ring 是 review-enforced |
185+
| `RoamEvent` 新增 `previous_ssid` / `new_ssid``RFStirEvent` 新增 `ssid`,均默认 `None`(schema 兼容性追加) | `test_event_log.py::test_event_to_jsonl_roundtrip_roam_with_ssid_pair``::test_event_to_jsonl_roundtrip_rf_stir_with_ssid``::test_event_to_jsonl_omits_ssid_keys_when_none` |
184186

185187
### `i18n`
186188

@@ -286,6 +288,7 @@
286288
| 浮出的候选带评分 + 按 c 提示 | `test_tui_helpers.py::test_score_line_reports_better_same_ssid_candidate` |
287289
| 按 c 切换 Wi-Fi 关再开 | (人工 — `force_reroam()` 是 backend 特定的) |
288290
| `_health_line``_link_score` 词汇一致 | (review-enforced — 约定;这条要拦的 bug 之前出过一次) |
291+
| `WiFiPoller` 在每次 BSSID 切换观察到的 `Connection.ssid` 上填出 `previous_ssid` / `new_ssid` | `test_poller.py::test_roam_event_fills_ssid_from_connection_updates` |
289292

290293
### `tui-shell`
291294

@@ -300,6 +303,7 @@
300303
| Header 显示 title + 时钟;subtitle 反映实时状态 | `test_tui_smoke.py::test_brand_header_carries_live_title_and_subtitle` |
301304
| 品牌雷达 mark(`docs/design/diting-design/assets/logo-mark.svg`)以 Unicode 半格字符在 header 中以品牌橙渲染 | `test_tui_smoke.py::test_brand_header_renders_logo_mark``tui_snapshot.py::wifi_main_en`(regression 捕获半格字符上 `fill: #fea62b` 的品牌橙样式) |
302305
| App title 固定为 `diting v<版本>`(取自 importlib.metadata),运行版本号一眼可见 | `test_tui_smoke.py::test_app_title_carries_version` |
306+
| Wi-Fi 事件行(漫游、RF 扰动)展示相关 SSID:previous_ssid == new_ssid 时单段 `SSID: <名>`;不同时 `SSID: <前> → <后>`;两侧均为 `None``""`(隐藏 SSID)时整段省略 | `test_tui_helpers.py::test_format_roam_event_includes_ssid_when_same_on_both_sides``::test_format_roam_event_renders_ssid_transition_when_different``::test_format_roam_event_omits_ssid_segment_when_both_none``::test_format_roam_event_omits_ssid_segment_for_hidden_ssid``::test_format_rf_stir_event_includes_ssid_when_present``::test_format_rf_stir_event_omits_ssid_segment_when_none` |
303307
| 所有 list-style 视图面板共享同一套行选中 + 查看手势(`up` / `down``i` / `enter`、鼠标点击即查看;Esc / `i` / `q` 关 modal 不动选择);如需偏离该手势必须改本 Requirement | `test_tui_smoke.py::test_wifi_inspect_opens_modal_on_first_press``::test_bonjour_inspect_opens_modal_on_first_press`(与既有的 BLE 覆盖 `tui_snapshot.py::ble_detail_decoded` 并列) |
304308

305309
### `wifi-detail-modal`
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
# Design
2+
3+
## D1. Why add SSID, not "do better AP-name lookup"
4+
5+
The current event lines fall back to raw BSSIDs because the user's
6+
`aps.yaml` doesn't have entries for those BSSIDs. That's expected
7+
— filling the inventory is the user's job and `format_bssid` already
8+
does the right thing when entries exist.
9+
10+
What the user is *actually* asking for is the SSID. The SSID is
11+
populated by macOS automatically for every association the user
12+
makes (it's the network's broadcast name); we don't need the user
13+
to maintain any extra config to get it. Adding SSID to the event
14+
gives the user a stable, always-available label for "which
15+
network", independent of how thorough their `aps.yaml` is.
16+
17+
## D2. Where the SSID is read
18+
19+
Both event sources already have access to the SSID at emission
20+
time:
21+
22+
- `WiFiPoller._maybe_emit_roam` runs against `ConnectionUpdate`,
23+
which carries `Connection.ssid`. The poller already remembers
24+
`_last_bssid` to detect roams; we add a parallel `_last_ssid`
25+
field updated in lockstep.
26+
- The environment monitor's `RFStirEvent` emission happens
27+
inside `EnvironmentMonitor.observe()`, which receives the
28+
current `Connection` snapshot from the poller. The monitor
29+
already pulls `bssid` off it; we pull `ssid` too.
30+
31+
No new IPC, no new threading, no new awaits.
32+
33+
## D3. Field-default + serialisation
34+
35+
`RoamEvent` and `RFStirEvent` are `@dataclass(frozen=True,
36+
slots=True)`. Adding a default-None field is safe (existing
37+
constructors that omit the field still work); reordering existing
38+
fields is not — Python forbids non-default-before-default fields,
39+
and serialisation order in `event_to_jsonl` matters for analysis
40+
reproducibility.
41+
42+
So the new fields land at the **end** of each dataclass:
43+
44+
```python
45+
@dataclass(frozen=True, slots=True)
46+
class RoamEvent:
47+
timestamp: datetime
48+
previous_bssid: str
49+
previous_channel: int | None
50+
new_bssid: str
51+
new_channel: int | None
52+
previous_ssid: str | None = None # NEW
53+
new_ssid: str | None = None # NEW
54+
```
55+
56+
```python
57+
@dataclass(frozen=True, slots=True)
58+
class RFStirEvent:
59+
timestamp: datetime
60+
bssid: str
61+
location: str
62+
magnitude_db: float
63+
duration_s: float
64+
confidence: str
65+
mode: str
66+
ssid: str | None = None # NEW
67+
```
68+
69+
`event_to_jsonl` (in `event_log.py`) writes existing keys first
70+
and appends the new keys to the end of the dict. JSONL consumers
71+
that ignore unknown keys see no change.
72+
73+
## D4. Renderer — when to show SSID, when to skip
74+
75+
Roam event line, two cases:
76+
77+
- **Same SSID on both sides** (band switch within an ESS, or
78+
inter-AP roam keeping the same network): render
79+
`SSID: <name>` once.
80+
- **Different SSIDs** (the user roamed off one network and onto
81+
another — rare in practice, but possible): render
82+
`SSID: <prev> → <new>`. The `` matches the BSSID arrow on
83+
the same line.
84+
85+
When both `previous_ssid` and `new_ssid` are None, the SSID
86+
segment is omitted entirely — adding `SSID: n/a` is worse than
87+
just leaving it out.
88+
89+
RF stir event line: `· SSID <name>` segment is appended to the
90+
existing `<location> 处 RF 扰动` body when `event.ssid` is
91+
populated. Omitted when None (location alone is enough; the
92+
user can correlate against the connection panel).
93+
94+
ZH catalog keys are added for the new wrapper strings (`SSID:
95+
{ssid}`, `SSID: {prev} → {new}`, `SSID {ssid}`).
96+
97+
## D5. Hidden + redacted SSID edge cases
98+
99+
- **Hidden SSID** (CoreWLAN returns `""`): the renderer treats
100+
empty-string the same as None — segment omitted. A hidden SSID
101+
has nothing useful to show.
102+
- **TCC-redacted SSID** (CoreWLAN returns `None` because Location
103+
Services is denied): same — segment omitted. The user already
104+
sees the redacted state in the connection panel.
105+
- **Inter-AP roam with one side TCC-redacted** (rare; the poller
106+
loses Location between the previous and new association):
107+
the renderer surfaces whichever side is known. If neither, the
108+
segment is omitted.
109+
110+
## D6. Test surface
111+
112+
`tests/test_tui_helpers.py` already covers the existing event
113+
formatters. We add:
114+
115+
- `test_format_roam_event_includes_ssid_when_same_on_both_sides`
116+
- `test_format_roam_event_renders_ssid_transition_when_different`
117+
- `test_format_roam_event_omits_ssid_segment_when_both_none`
118+
- `test_format_roam_event_omits_ssid_segment_for_hidden_ssid`
119+
- `test_format_rf_stir_event_includes_ssid_when_present`
120+
- `test_format_rf_stir_event_omits_ssid_segment_when_none`
121+
122+
Plus a roundtrip test in `tests/test_event_log.py`:
123+
- `test_event_to_jsonl_roundtrip_roam_with_ssid_pair`
124+
- `test_event_to_jsonl_roundtrip_rf_stir_with_ssid`
125+
126+
And a poller test:
127+
- `test_roam_event_fills_ssid_from_connection_updates`
128+
129+
The environment monitor side picks up coverage by widening one
130+
existing test in `tests/test_environment.py` to assert the
131+
emitted event carries `ssid`.
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
## Why
2+
3+
User report (2026-05-18): the Events panel renders Wi-Fi-side
4+
events as raw BSSIDs only.
5+
6+
```
7+
09:49:30 [漫游] 1c:28:af:5d:2b:b4 -> 1c:28:af:5e:9d:b4 [跨 AP 漫游]
8+
09:49:56 [扰动] ?af:5e:9d 处 RF 扰动 σ 4.8 dB · 中
9+
09:50:04 [扰动] ?af:5e:9d 处 RF 扰动 σ 4.9 dB · 中
10+
```
11+
12+
For a user with multiple SSIDs on the same physical link (home
13+
guest + private, office corp + IoT, café open + 5 GHz) the BSSIDs
14+
alone don't say which network experienced the roam / disturbance.
15+
The AP-name half is already handled by `format_bssid` when
16+
`aps.yaml` has the BSSID mapped — but the SSID is never surfaced.
17+
18+
The fix is small and additive: carry the associated SSID at the
19+
moment the event fires (the poller / environment monitor already
20+
have it on the `Connection` they observed) and render it on the
21+
event line. AP-name rendering keeps its existing inventory-lookup
22+
behaviour; users with sparse `aps.yaml` files still see the
23+
cluster-label or raw BSSID, but at minimum they now know which
24+
SSID was affected.
25+
26+
## What Changes
27+
28+
### `events` — RoamEvent + RFStirEvent carry SSID
29+
- **MODIFIED:** `RoamEvent` SHALL carry `previous_ssid: str | None`
30+
and `new_ssid: str | None`. Both are the SSID associated with
31+
the previous / new BSSID at the moment the roam was observed,
32+
taken from the `Connection.ssid` field. Optional with
33+
default-None for backwards compat with any code path that
34+
constructs the event without going through the poller.
35+
- **MODIFIED:** `RFStirEvent` SHALL carry `ssid: str | None`. It
36+
is the SSID associated with the BSSID at the moment the σ
37+
threshold was crossed (the environment monitor reads it from
38+
the current `Connection`). Optional with default-None for the
39+
same reason.
40+
- **MODIFIED:** `event_to_jsonl` SHALL serialise the new fields
41+
under English keys (`previous_ssid`, `new_ssid`, `ssid`).
42+
Existing keys SHALL NOT change.
43+
44+
### `roam-detection` — poller fills SSIDs on RoamEvent
45+
- **MODIFIED:** the roam detector (`WiFiPoller._maybe_emit_roam`)
46+
SHALL remember the SSID of the previous connection alongside
47+
the BSSID, and SHALL pass both `previous_ssid` and `new_ssid`
48+
when constructing the `RoamEvent`. Hidden SSIDs (`""`) and
49+
TCC-redacted SSIDs (`None`) flow through verbatim; the
50+
formatter handles them gracefully.
51+
52+
### `environment-monitor` — RFStirEvent picks up current SSID
53+
- **MODIFIED:** the environment monitor SHALL pass the current
54+
`Connection.ssid` to `RFStirEvent` when a threshold crossing
55+
fires. The monitor already accepts the `Connection` snapshot;
56+
this is a single extra field on the constructor call.
57+
58+
### `tui-shell` — event line renders SSID
59+
- **MODIFIED:** `_format_roam_event` SHALL show the SSID
60+
alongside the BSSID/AP-name annotation. When `previous_ssid`
61+
and `new_ssid` are identical (the typical band-switch case),
62+
the line surfaces a single `SSID: <name>` segment. When they
63+
differ (a true SSID hop), both SSIDs SHALL be rendered with
64+
an `` between them. When both SSIDs are None, the segment
65+
SHALL be omitted (no `SSID: n/a` clutter).
66+
- **MODIFIED:** `_format_rf_stir_event` SHALL append `· SSID
67+
<name>` after the location/AP-name segment when `event.ssid`
68+
is non-None. When `event.ssid` is None the segment SHALL be
69+
omitted entirely.
70+
- AP-name rendering is unchanged: it continues to come from
71+
`format_bssid` (roam) and `event.location` (rf_stir), which
72+
both read `aps.yaml` via `NetworkInventory`.
73+
74+
## Out of Scope
75+
76+
- Backfilling SSIDs for `LatencySpikeEvent` / `LossBurstEvent`.
77+
Those events are anchored on a target IP (Router / WAN), not
78+
a BSSID; SSID context is already captured upstream by
79+
`NetworkChangeEvent` (control-plane) and the Diagnostics
80+
panel's connection line.
81+
- Restructuring `RoamEvent` into a strict union of "band switch"
82+
vs "inter-AP roam" subtypes. The existing single struct with
83+
inventory-driven labelling stays; only the field set grows.
84+
- Surfacing AP host alongside SSID in the headless JSONL. That
85+
would expand the analyser's read schema; this PR keeps the
86+
JSONL additive (new keys only).
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
## MODIFIED Requirements
2+
3+
### Requirement: Each event SHALL be a frozen dataclass with an explicit timestamp
4+
Every event class SHALL be defined as a `@dataclass(frozen=True, slots=True)` with at minimum a `timestamp: datetime` field (timezone-aware, local TZ at construction). Mutating an event after emission is prohibited; that is enforced by `frozen=True`. All other fields are event-type-specific.
5+
6+
Wi-Fi-anchored events (`RoamEvent`, `RFStirEvent`) SHALL additionally carry SSID context for the affected association:
7+
8+
- `RoamEvent` SHALL carry `previous_ssid: str | None = None` and `new_ssid: str | None = None`. Each is the SSID associated with the corresponding BSSID at the moment the poller observed the roam.
9+
- `RFStirEvent` SHALL carry `ssid: str | None = None`. It is the SSID associated with the BSSID at the moment the σ threshold was crossed.
10+
11+
Both fields are optional with a default of `None` for backwards compatibility with code paths that construct the event without going through the poller / environment monitor; new fields land at the end of each dataclass so positional construction in legacy callers keeps working.
12+
13+
#### Scenario: Constructing an RFStirEvent
14+
- **WHEN** `RFStirEvent(timestamp=..., bssid=..., location=..., magnitude_db=..., duration_s=..., confidence=..., mode=...)` is created
15+
- **THEN** the resulting object is hashable, comparable, and immutable; `event.ssid` is `None`
16+
17+
#### Scenario: Constructing an RFStirEvent with SSID
18+
- **WHEN** `RFStirEvent(..., ssid="tedo_5G")` is created
19+
- **THEN** the resulting object exposes `event.ssid == "tedo_5G"`
20+
21+
#### Scenario: Constructing a RoamEvent with SSID pair
22+
- **WHEN** `RoamEvent(..., previous_ssid="tedo_5G", new_ssid="tedo_5G")` is created
23+
- **THEN** the resulting object exposes both fields verbatim
24+
25+
### Requirement: JSONL serialisation SHALL use locale-stable English keys
26+
`event_to_jsonl` SHALL emit JSON with English keys (`type`, `bssid`, `ssid`, `state`, `magnitude_db`, etc.) regardless of the active UI language. User-supplied strings (SSID, AP location names from aps.yaml) SHALL pass through with `ensure_ascii=False` so a Chinese SSID like `咖啡馆` lands readable in the log instead of `哖...`.
27+
28+
When `RoamEvent.previous_ssid` / `new_ssid` are set, the JSONL line SHALL include them under the keys `previous_ssid` and `new_ssid` after the existing BSSID / channel keys. When `RFStirEvent.ssid` is set, the JSONL line SHALL include it under the key `ssid` after the existing `bssid` / `location` keys. When the SSID field is `None`, the key SHALL be omitted (the serialiser already skips `None` values for optional fields; this keeps old log entries diff-stable).
29+
30+
#### Scenario: ZH UI, Chinese SSID
31+
- **WHEN** the user runs `diting --lang zh --log /tmp/wifi.jsonl`, gets a roam event from `咖啡馆 → Office`
32+
- **THEN** the JSONL line is `{"type":"roam","previous_ssid":"咖啡馆","new_ssid":"Office", ...}` — keys English, values raw UTF-8
33+
34+
#### Scenario: RFStirEvent with SSID
35+
- **WHEN** an `RFStirEvent` fires for an AP on `tedo_5G`
36+
- **THEN** the JSONL line carries `"ssid":"tedo_5G"` after `"bssid"` and `"location"`
37+
38+
#### Scenario: RoamEvent with no known SSID (TCC redacted)
39+
- **WHEN** a `RoamEvent` fires with both SSIDs `None` (Location Services denied mid-session)
40+
- **THEN** the JSONL line omits both `previous_ssid` and `new_ssid` keys, matching the legacy pre-enrichment shape
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
## ADDED Requirements
2+
3+
### Requirement: Wi-Fi-anchored event lines SHALL surface the affected SSID alongside the BSSID / AP-name
4+
The Events panel's renderer for `RoamEvent` and `RFStirEvent` SHALL include the associated SSID (carried by the event itself) as part of the event line:
5+
6+
- `RoamEvent`: when `previous_ssid == new_ssid` (the common case — band switch within an ESS, or inter-AP roam keeping the same network) the line SHALL render a single `SSID: <name>` segment after the BSSID arrow. When the SSIDs differ the line SHALL render `SSID: <prev> → <new>` using the same arrow glyph as the BSSID pair. When both SSIDs are `None` OR both are `""` (hidden) the SSID segment SHALL be omitted entirely; `SSID: n/a` SHALL NOT appear.
7+
- `RFStirEvent`: when `ssid` is a non-empty string the line SHALL append `· SSID <name>` after the location body. When `ssid` is `None` or `""` the segment SHALL be omitted.
8+
9+
AP-name rendering is unchanged: it continues to come from `format_bssid` (roam line) and `event.location` (rf_stir line), both of which read `aps.yaml` via `NetworkInventory`. SSID context is additive — a fully-populated `aps.yaml` keeps showing the friendly AP name, and an empty inventory keeps showing the cluster label / raw BSSID; both cases gain the SSID segment for free.
10+
11+
i18n: the new wrapper strings (`SSID: {ssid}`, `SSID: {prev} → {new}`, `SSID {ssid}`) SHALL be added to the EN + ZH catalogs.
12+
13+
#### Scenario: Roam between band siblings on the same SSID
14+
- **WHEN** the event ring contains a `RoamEvent` with `previous_ssid="tedo"` and `new_ssid="tedo"`
15+
- **THEN** the rendered line carries `SSID: tedo` exactly once, after the BSSID arrow segment
16+
17+
#### Scenario: Roam across two distinct SSIDs
18+
- **WHEN** the event has `previous_ssid="home"` and `new_ssid="office"`
19+
- **THEN** the rendered line carries `SSID: home → office`
20+
21+
#### Scenario: Roam with both SSIDs unknown (TCC redacted)
22+
- **WHEN** the event has `previous_ssid=None` and `new_ssid=None`
23+
- **THEN** the rendered line OMITS the SSID segment; the BSSID arrow segment renders unchanged
24+
25+
#### Scenario: Hidden SSID on both sides
26+
- **WHEN** the event has `previous_ssid=""` and `new_ssid=""` (CoreWLAN returns empty string for hidden SSIDs)
27+
- **THEN** the rendered line OMITS the SSID segment; empty strings are not surfaced as `SSID: `
28+
29+
#### Scenario: RF stir with a known SSID
30+
- **WHEN** the event has `ssid="tedo_5G"` and `location="?af:5e:9d"`
31+
- **THEN** the rendered line reads `?af:5e:9d 处 RF 扰动 σ 4.8 dB · 中 · SSID tedo_5G` (positions of i18n decorations may vary; the `SSID tedo_5G` segment is present)
32+
33+
#### Scenario: RF stir without an SSID
34+
- **WHEN** the event has `ssid=None`
35+
- **THEN** the rendered line is unchanged from the legacy (pre-enrichment) shape — the trailing `· SSID …` segment is absent

0 commit comments

Comments
 (0)