|
| 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`. |
0 commit comments