Skip to content

Commit 5a76d00

Browse files
chenchaoyiclaude
andauthored
feat(lan): expand identification — v1.7.0 (#119)
* feat(lan): OUI multi-tier lookup + vendor normalization (P1 of expand-lan-identification) Phase 1 of the expand-lan-identification OpenSpec change. Adds the passive-only enrichments that strengthen LAN host identification without any new wire-protocol behaviour. - Multi-tier IEEE OUI lookup (MA-L 24-bit → MA-M 28-bit → MA-S 36-bit), longest prefix wins. `load_ouis_layered()` returns the three dicts; `lookup_oui_vendor` accepts both the legacy single- tier signature (back-compat) and the new layered kwargs form. - `scripts/refresh_ouis.py` extended to fetch all three IEEE registries and partition by the Registry CSV column. MA-M / MA-S output paths added. - `_normalize_vendor()` in lan.py: strips trailing corporate-form noise (CO., LTD, CORPORATION, INC, TECHNOLOGIES …), strips leading Chinese-city prefixes (SHENZHEN, HANGZHOU, BEIJING …), titlecases while preserving `_ACRONYM_OVERRIDES` (HP, IBM, H3C, TP-Link, ASUS, …), truncates to 16-cell column width. - `LANHost.vendor` is now the normalized display form; new `LANHost.vendor_raw` preserves the raw IEEE registry string. - `LANDetailScreen` surfaces the raw IEEE string on a dim continuation line when normalization changed the name, so the user can reconcile odd cases. - 35 new tests across `test_oui_multitier.py`, `test_vendor_normalize.py`, `test_lan.py` (vendor_raw integration), `test_tui_helpers.py` (continuation-line behaviour). Full suite 895/895 passes; regression snapshot passes; openspec validate --strict passes. Bundled OUI data state: MA-L stays at the existing 2026-05-19 freshness (the IEEE CDN was unreachable from the build host at implementation time); MA-M / MA-S ship as `_meta`-only stubs so the graceful-degradation path is exercised at runtime. A `uv run python scripts/refresh_ouis.py` from a network with IEEE access will populate all three. Phases 2–4 (active discovery, heuristics, UX) land in follow-up commits on the same branch. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(lan): populate MA-M / MA-S OUI tiers via Wireshark mirror fallback Resolves the Phase-1 caveat about empty MA-M / MA-S stubs. The IEEE Registration Authority CDN (standards-oui.ieee.org) is consistently unreachable from CN networks — every TLS handshake ends mid-flight with `SSL_ERROR_SYSCALL` / `UNEXPECTED_EOF_WHILE_READING`, on both Python urllib and macOS curl, on 8+ retries. Not a transient. The Wireshark project's `manuf` file at `https://www.wireshark.org/download/automated/data/manuf` is a community-maintained mirror of the same IEEE OUI data, regenerated regularly, exposes all three tiers in one file via `/28` / `/36` prefix-bit annotation, and reaches CN networks reliably. - `scripts/refresh_ouis.py` now supports `--source ieee|wireshark|auto` (default `auto`: IEEE direct first, Wireshark fallback on failure). Also `--manuf-file <path>` for offline re-ingest. - New `parse_wireshark_manuf()` partitions the single `manuf` file back into the three-tier shape `_key_for_assignment` already emits. Wireshark column 3 carries the IEEE vendor string verbatim. - `write_ouis()` gains `source_override` / `source_url_override` kwargs so the resulting `_meta.source` line records which upstream was actually used. - Bundled data now populated: MA-L: 39,223 entries (was 39,445 — minor IEEE-vs-Wireshark dedup differences; Apple / Cisco / etc. all present) MA-M: 6,404 entries (was 0 — stub) MA-S: 11,584 entries (was 0 — stub) - `test_network.py::test_load_wifi_ouis_ships_full_ieee_registry` loosened to case-insensitive substring assertions on vendor strings; Wireshark titlecases where IEEE direct all-caps, both forms normalize to the same display via `_normalize_vendor`. - New tests `test_oui_refresh_script_parses_wireshark_manuf_all_three_tiers` and `test_oui_refresh_script_wireshark_manuf_skips_unknown_widths`. 895/895 tests pass. Graceful-degradation path (`empty MA-M / MA-S`) still covered by `test_oui_multitier.py::test_lookup_falls_back_to_*`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * design(lan): adopt Fing UX patterns — class-first column, camera/smart-home split, Model row Reviewed Fing Desktop 4.0 as a UX benchmark against the existing expand-lan-identification design. Adjustments before Phase 2/3/4 implementation begins, so the design and spec deltas record the final intent. design.md changes: - D10 device-class vocabulary: drop `iot` (too coarse) in favour of `camera` (Hikvision / Dahua / Axis / Tapo / Imou) and `smart-home` (Tuya / Xiaomi / Aqara / Mijia). Fing's `IP Camera` vs `Smart Device` taxonomy makes "how many cameras are silently on my Wi-Fi" answerable, which the broad `iot` did not. - D13 column ordering: class moves to the leftmost data column (before vendor). Fing's leftmost column is Type — same insight, applied to our row layout. Final layout: `[new] class vendor name IP MAC last_seen`. - New D14 Fing UX reference section: records the benchmark patterns adopted (type-first, multi-protocol identification, class granularity, Model in detail view) and the ones rejected (icons, sidebar nav, People view, active TCP probing, filter dropdowns, status pill). lan-inventory spec: - device_class vocabulary updated everywhere it appears. - New scenarios: Hikvision/Dahua/Axis/Tapo/Imou vendor signals `camera`; Tuya/Xiaomi/Aqara/Mijia vendor signals `smart-home`. tui-shell spec: - Detail modal Identity section gains a `Model:` row sourced from `upnp_model` with `upnp_friendly_name` fallback. - Class column position moved to leftmost data column; row layout table added to the requirement text. - New scenarios: camera row, smart-home row. i18n spec: - Drop the `iot` row, add `camera` (`摄像头`) and `smart-home` (`智能家居`) entries. - Add `Model:` modal label (`型号:`). openspec validate --strict passes for both the change and the 22 canonical specs. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(lan): active discovery layer — NBNS / SSDP / mDNS-meta, scene-gated (P2) Phase 2 of expand-lan-identification. Adds the on-the-wire enrichment layer that the passive ICMP+ARP poller depends on for identifying smart-home devices (Hikvision / Tuya / Hisense / TP-Link / NAS / Windows hosts) that publish no Bonjour and no reverse DNS. New module `src/diting/lan_probes.py`: - `encode_nbns_status_query(txn_id)` — RFC 1002 §4.2.18 wildcard NBSTAT query, 50 bytes flat. Pure function. - `parse_nbns_status_response(data)` → `[NBNSNameEntry]` — parses the name table; tolerates compressed pointers (0xC00C) and length-prefixed answer names; truncated / malformed data yields `[]` rather than raising. `workstation_name` picks the unique `0x00`-suffix entry. - `probe_nbns(ips)` — bounded 30-way concurrency, 100ms per host; returns `{ip: name_or_None}`. - `SSDP_MSEARCH_PACKET` byte template + `parse_ssdp_response(data, ip)` → `SSDPResponse | None` (rejects non-200; tolerates malformed). - `probe_ssdp()` — single multicast to 239.255.255.250:1900, 3 s listen window, dedups by source IP. - `parse_upnp_location_xml(xml)` → `(friendly_name, model_name)` via stdlib ElementTree with no external-entity resolution. - `fetch_upnp_location(url)` async wrapper around urllib GET capped at 500ms / 4KB; swallows all URLError / OSError / TimeoutError. - `resolve_lan_active_probe(env, scene_default)` / `resolve_upnp_fetch_enabled(env)` — env var → bool resolution; invalid values fall through to the default. `scene.py`: - `scene_defaults()` gains `lan_active_probe` — True for home / office / audit, False for public. Documented in the docstring. `lan.py`: - `LANInventoryPoller.__init__` gains `active_probe_enabled` and `upnp_fetch_enabled` kwargs; new `_one_shot_probe_armed` flag. - `LANHost` gains `nbns_name`, `upnp_server`, `upnp_friendly_name`, `upnp_model` (all default None). - `_do_sweep_and_emit` calls `_run_active_probes` when enabled or one-shot armed; clears the one-shot flag after the sweep. - `_run_active_probes` runs NBNS + SSDP + mDNS-meta concurrently via `asyncio.gather`; each phase fail-soft on exception. - `_apply_probe_results` merges enrichments into `_state` keyed by IP, preserving prior values when the new value is None (silent host doesn't clobber a previously-captured name). `mdns.py`: - `BonjourPoller.send_meta_query()` sends one PTR for `_services._dns-sd._meta._tcp.local.`; returns True/False; swallows zeroconf internals exceptions. `cli.py` + `tui.py`: - DitingApp `__init__` accepts `lan_active_probe` + `lan_upnp_fetch` kwargs; threads them through to `LANInventoryPoller`. - CLI resolves both env vars at startup; `_resolve_lan_active_probe_with_warning` prints a stderr warning when the env value is non-empty and outside `0`/`1`, then falls through to the scene default. - `--help` documents both env vars under global options. Tests (45 new, 942/942 pass): - `test_lan_probes.py` — 30 tests covering NBNS encode/parse, SSDP packet shape, SSDP response parse, UPnP XML parse (including external-entity DOCTYPE), async fetch wrapper fail-soft, env var resolution. - `test_scene.py` — 3 new tests for `lan_active_probe` per scene. - `test_lan.py` — 8 new tests for `_apply_probe_results` / `_run_active_probes` exception swallow / `_one_shot_probe_armed` consumption. - `test_mdns.py` — 3 new tests for `send_meta_query`. TESTING.md (EN + ZH) updated with 12 new coverage rows. Validates: openspec validate expand-lan-identification --strict ✓ openspec validate --specs --strict (22/22) ✓ regression snapshot ✓ pytest 942/942 ✓ Phase 3 (TTL fingerprint + classifier) and Phase 4 (UX: chip, class column, consent modal) follow on the same branch. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(lan): TTL fingerprint + device-class classifier (P3) Phase 3 of expand-lan-identification. Adds two pure read-side heuristics over the fields populated by Phases 1+2: 1. **TTL fingerprint** — `_ping_one` now also parses `ttl=N` from ping stdout; `LANHost.ttl` carries the raw value and `LANHost.ttl_class` carries the coarse OS-family bucket (`unix` = 50-64, `windows` = 100-128, `router` = 200-255, None otherwise). Same packet, zero additional traffic. 2. **Device-class inference** — new module `src/diting/lan_classify.py` with a documented rules table consuming (vendor_raw, bonjour_services, nbns_name, upnp_server, upnp_friendly_name, ttl_class, is_gateway). Returns one of the documented class strings (`phone | laptop | desktop | tv | camera | smart-home | printer | nas | gaming | speaker | router`) or None. Pure function; total over input; never raises. `lan.py`: - `_ping_one` return shape `(reachable, rtt_ms)` → `(reachable, rtt_ms, ttl)`. `_sweep` updated accordingly. New `_unpack_sweep_entry` helper tolerates both 2-tuple (legacy test fixtures) and 3-tuple shapes so the migration is transparent. - `ttl_class_for(ttl)` helper exposed module-level. - `LANHost` gains `ttl`, `ttl_class`, `device_class` fields (all default None). - `_merge_arp_into_state` populates the TTL fields from the sweep result; preserves TTL across silent ticks; runs `classify()` on every constructed LANHost. - `_apply_probe_results` re-runs `classify()` after merging the active-discovery enrichments, so the `tv` / `camera` rules that depend on `upnp_server` / `upnp_friendly_name` fire after the SSDP phase lands. `tui.py` + `i18n.py`: - `LANDetailScreen._render_body` renders a `Class:` row in the Identity section when `device_class` is non-None, and a `TTL:` row in the Network section (formatted as `<value> (<class>)` when ttl_class is known, raw value otherwise) when ttl is non-None. - i18n catalog gains EN keys + ZH values for `Class`, `TTL`, the 11 class strings (`phone` / `laptop` / `desktop` / `tv` / `camera` / `smart-home` / `printer` / `nas` / `gaming` / `speaker` / `router`), and the 2 TTL-class strings (`unix` / `windows`). Class values pass through `t()` at render time so the JSONL stream carries the EN tokens. Classifier rule highlights: - Gateway always wins `router` regardless of vendor. - AirPrint / IPP / LPD Bonjour → printer; printer-vendor → printer. - UPnP SmartTV / Hisense / Samsung / WebOS / Tizen server header → tv; AirPlay + GoogleCast Bonjour → tv; Hisense / LG / Sony / TCL / Skyworth / Konka / Vizio / Roku vendor → tv. - Hikvision / Dahua / Axis / Tapo / Imou / Reolink / EZVIZ / Amcrest / Uniview vendor → camera; "Hikvision-Webs" server header → camera. - SMB / AFP / NFS / `_adisk` Bonjour → nas; Synology / QNAP / WD / Drobo / Asustor / TerraMaster vendor → nas. - `_companion-link` / `_apple-mobdev2` Bonjour → phone. - Sonos / Bose / Harman / JBL / Anker vendor + `_spotify-connect` Bonjour → speaker. - Nintendo / Sony Interactive vendor → gaming. - TP-Link / Asus / Netgear / Linksys / Ubiquiti / Mikrotik / H3C / Huawei / Ruijie / OpenWrt vendor → router. - Tuya / Xiaomi / Aqara / Mijia / Lumi / Espressif / Imilab vendor → smart-home. - Windows TTL fallback → desktop (weakest, last). Tests (48 new, 990/990 pass total): - `test_device_class.py` — 29 tests covering every class branch + None fallback + pure-function safety (rogue predicate skip). - `test_lan.py` — 14 new tests: `_unpack_sweep_entry` shape tolerance, `ttl_class_for` buckets, LANHost TTL population + silent-tick preservation, classifier wired into merge + probe re-classify path. - `test_tui_helpers.py` — 5 new tests for Class / TTL row rendering (present + omitted variants). TESTING.md (EN + ZH) updated with 8 new coverage rows. Validates: openspec validate expand-lan-identification --strict ✓ openspec validate --specs --strict (22/22) ✓ regression snapshot ✓ pytest 990/990 ✓ Phase 4 (UX: [new] chip, class column on LAN row, public-scene consent modal, README + CHANGELOG) follows on the same branch. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(lan): UX surface — class column, [new] chip, public-scene consent modal (P4 v1.7.0) Phase 4 of expand-lan-identification — final layer. Ties Phases 1-3 together into the user-facing surface. Event + logger: - `LANActiveProbeConsentedEvent` dataclass in events.py with `timestamp` / `scene` / `ssid` / `nbns_packets` / `ssdp_packets` / `mdns_packets`. Audit-only — never emitted for scene-default or env-forced probing. - `EventLogger.emit_lan_active_probe_consented` serializes one JSONL line with stable type `lan_active_probe_consented`; omits `ssid` when None; no-op when sink is None. LAN row layout (Phase 4 / Fing UX benchmark): - New `_COL_LAN_CLASS = 8` slot for the device-class column. Layout: `[new] ★ class vendor name IP MAC last_seen` — class placed LEFTMOST of the data columns per Fing's Type- first convention (it disambiguates faster than vendor — H3C OUI can be router / AP / switch / IoT bridge). - `[new]` chip in dim cyan when `(now - first_seen) < 24 h`; self / gateway never carry the chip. - `_lan_header_line` updated with new `class` column header before `vendor`. LANProbeConsentScreen modal: - Modal centered with heavy-bordered $warning box, ~78 cells wide. - Body: scene + SSID header (`(disassociated)` when SSID is None), packet enumeration (NBNS 137 unicast / SSDP 1900 multicast / mDNS 5353 multicast), three-line consequences statement, one-shot disclaimer. - Footer: `[esc cancel] [wait 2s]` during 2-second cooldown, flips to `[y probe now]` after — uses Textual's `set_timer` to refresh. - `action_confirm` is a silent no-op during cooldown. After cooldown: hands off to `App._consent_one_shot_lan_probe` which logs the JSONL event, arms `_one_shot_probe_armed`, calls `force_now()`, refreshes subtitle so the `[probing]` chip lights up. `P` keybinding (uppercase, hidden from footer) — three gates: must be on the LAN view, scene must be `public`, and `DITING_LAN_PROBE` must not have forced probing on. Outside any of those, the key is a silent no-op (no point opening the modal where it can't change anything). `[probing]` subtitle chip: - Added to `_build_subtitle` when `_one_shot_probe_armed=True`. - Cleared automatically when the consumer task receives the resulting `LANInventoryUpdate` — the poller clears the flag inside `_do_sweep_and_emit` before yielding, so by the time the task refreshes the subtitle, the chip is gone. i18n catalog: - `[new]` / `[probing]` chip strings (EN + ZH). - Full consent modal copy (EN + ZH): `Active LAN probing`, `Scene:`, `Network:`, `(disassociated)`, packet enumeration preamble, three consequence bullets, one-shot disclaimer, footer button labels. - `class` column header. Help text (`?` modal): - New `P` binding entry under Bindings. - New "LAN view" section describing the multi-tier OUI, enrichment stack, scene-gated probing, `DITING_LAN_*` env vars, and the uppercase-P consent flow. Docs: - `README.md` + `docs/zh/README.md` gain a `## LAN identification` section: multi-tier OUI, enrichment stack, scene-gating matrix, ASCII mock of the consent modal. - `CHANGELOG.md` + `docs/zh/CHANGELOG.md` get a v1.7.0 entry summarising all four phases. - `tests/TESTING.md` + `docs/zh/TESTING.md` gain four new coverage rows for Phase 4. Version: `pyproject.toml` 1.6.0 → 1.7.0 (minor — new CLI env vars, new keybinding, new JSONL event type, new bundled data files; no breaking changes). Tests (15 new, 1005/1005 pass total): - `test_events.py` — 4 new tests for the consent event dataclass + EventLogger emit method + ssid omission + None-sink no-op. - `test_tui_helpers.py` — 11 new tests for LAN row class column position, `[new]` chip presence/absence, header ordering, consent modal body contents, footer cooldown state, cooldown press-through no-op. Regression snapshot: passes. openspec validate expand-lan-identification --strict: passes. openspec validate --specs --strict: 22/22 passes. End of `expand-lan-identification` change. Ready for archive + PR review. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(lan,tui): tui-audit findings against expand-lan-identification v1.7.0 Six fixes from the 2026-05-23 audit against the developer's real home network. Each is independently validated; bundled into one commit because they all sit in the change-area and ship together in the v1.7.0 PR. 1. OUI lookup mis-keyed MACs with stripped leading zeros (`src/diting/ble.py`) macOS `arp -an` strips leading zeros per octet (`24:f:9b:29:c:56`, `a0:92:8:f6:4b:e2`). The old tokenizer concatenated-and-sliced `cleaned[0:6]`, mis-aligning whenever any of the first three octets was one hex char. On the developer's live ARP cache this affected 10 of ~50 hosts; 5 of those 10 (a Hikvision camera, 3 Apple devices, an HP printer) silently rendered `(unknown)` for a vendor that IS in the bundled `wifi_ouis.json`. New `_split_mac_octets()` splits on colons / dashes, pads each octet with `.zfill(2)`, then composes the lookup keys from the padded form. Handles colon-separated, dash- separated, and no-separator forms. Pre-existing bug — the single-tier lookup shipped with it; Phase 1 inherited it. 2. Multicast destination MACs leaked into the LAN panel (`src/diting/lan.py`) The kernel ARP cache picks up `01:00:5e:*` (IPv4 multicast) and `33:33:*` (IPv6 multicast) destination MACs as a side effect of any UDP send to a multicast group — diting's own SSDP M-SEARCH triggers `01:00:5e:7f:ff:fa` (239.255.255.250) and mDNS triggers `01:00:5e:00:00:fb` (224.0.0.251). They showed up as ghost rows with vendor=None, class=None, never-reachable. `_is_multicast_dest_mac()` checks both ranges (with zero-padding so the stripped-zero arp form matches); `_read_arp_cache()` filters them out. Two were visible in the audit capture and disappear after the fix. 3. Events panel rendered UTC timestamps instead of local time (`src/diting/tui.py:_ev_ts`) Event constructors use `datetime.now(timezone.utc)`. The `_ev_ts` helper called `.strftime("%H:%M:%S")` without `.astimezone()`, so a 16:19 Beijing-local event showed as `08:19` in the events modal — exactly the 8 h CN offset from UTC. The JSONL `_iso` helper in `event_log.py` already does the right thing; only the TUI helper missed. Added `.astimezone()`, made `_ev_ts` the single point of truth, replaced the 5 inline `event.timestamp.strftime(...)` call sites with `_ev_ts(event)`. Pre-existing bug — user-flagged live during the audit. 4. Classifier mis-classified HomePod + iPad + iPhone as `tv` (`src/diting/lan_classify.py`) AirPlay alone is too weak a signal — HomePods publish AirPlay + `_raop`, iPads publish AirPlay + `_companion-link`, Apple TVs publish AirPlay (sometimes + `_companion-link` for pairing). The rules table had `airplay → tv` first, so everything-with-airplay landed in tv. Reordered: - Speaker rule (`_raop`) moves BEFORE tv → HomePod ✓ - Strong-TV signals (`googlecast`, `_androidtvremote2`) keep their direct tv match - Standalone `airplay → tv` now requires absence of phone companion signal — Apple TV (airplay only, sometimes companion-link) still tv; iPad / iPhone (airplay + companion-link) falls through to the phone rule ✓ - User flagged HomePod live during the audit; iPad serial `L19L6JC6Q2` was also in the captured frame. 5. LAN detail modal was missing the spec-mandated Active discovery section + Model row (`src/diting/tui.py:LANDetailScreen._render_body`) Phase 4 spec required the modal to surface NBNS / UPnP server / friendly name / model. Implementation gap — the spec, TESTING.md, and tests covered it, but the actual render code jumped from `Bonjour services` → `Activity` with no Active discovery section in between. Added: - `Model:` row in Identity (prefers `upnp_model`, falls back to `upnp_friendly_name`). - `Active discovery` section header + rows for NBNS / UPnP server / friendly name / model. `(not probed)` placeholder when none of the four fields is set. - i18n entries (EN + ZH) for the new labels. 6. `[new]` chip fired on every LAN row for 24 h after first LAN-view entry (`src/diting/lan.py`, `src/diting/tui.py`) LAN poller is lazy-constructed on first `n`-cycle to the LAN view; at that moment `first_seen=now` for every host in the kernel ARP cache. The chip predicate `(now - first_seen) < 24 h` was then unconditionally true for every host, making `[new]` universal noise on first launch. Added a 5-minute grace anchored to the poller's `_constructed_at`. Hosts whose first_seen lands within the grace are session baseline — chip is suppressed. Hosts that join later (truly new devices) still trip the chip. `_lan_row_line` gained an optional `chip_anchor` kwarg; `LANPanel.update_hosts` threads it through; the App reads `_lan_inventory_poller._constructed_at`. Back-compat preserved: calls without `chip_anchor` retain the 24-h-only behavior. 7. TTL row showed `(windows)` class for gateways (`src/diting/tui.py:LANDetailScreen._render_body`) CN consumer routers (H3C / Huawei / some TP-Link firmwares) ship with TTL=128. The class heuristic correctly maps it to "windows", but rendering `TTL 128 (windows)` on a router is misleading. The classifier already gives those rows `class=router` via the `is_gateway` rule, so the parenthesised TTL class label adds confusion without signal. Suppress the class label for `is_gateway=True` rows only; non-gateway rows still show it as a useful OS-family hint. Tests: 27 new tests across `test_oui_multitier.py`, `test_lan.py`, `test_device_class.py`, `test_tui_helpers.py`. Full suite 1027/1027 passes; regression snapshot passes; `openspec validate --strict` passes for the change and all 22 canonical specs. TESTING.md (EN + ZH) extended with 7 new coverage rows. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(lan-classify): Bonjour needles must match category strings, not raw service types Follow-up to 97656cd. The 2026-05-23 re-audit showed Apple HomePods (Blue-Pod / Red-Pod / Yellow-Pod in the user's home network) were still being classified as `phone` instead of `speaker` even though my prior fix moved the speaker rule above tv and added the `_raop` needle. Root cause: the classifier's Bonjour rules used **raw service-type strings** (`_raop`, `_companion-link`, `_spotify-connect`, `googlecast`, `smb`, `_adisk`, `airprint`, `ipp`) as substring needles. But `LANHost.bonjour_services` actually stores the **human-readable category names** the mdns module derives from `src/diting/data/bonjour_services.json`: _raop._tcp.local. → "AirPlay audio" _companion-link._tcp.local. → "Apple Companion" _ipp._tcp.local. → "Printer" _smb._tcp.local. → "File share" _googlecast._tcp.local. → "Chromecast" … So every Bonjour-based classifier rule was silently dead code. HomePods, iPads, printers, NAS units — anything whose class depended on a Bonjour signal — fell through to whatever rule landed later (often vendor-based; for HomePods that meant the "Apple Companion" → phone fallback). The tests passed because they used the same wrong-format needles (`("_raop",)`, `("smb", "_adisk")`) — self-consistent but inconsistent with real Bonjour data flowing through the live poller. The audit caught it because the actual category strings came through in real-environment captures and didn't match. Real-data HomePod signature observed in the user's home network: `AirPlay + AirPlay audio + Apple Companion + HomeKit`. The "AirPlay audio" category (from _raop._tcp) is the speaker- specific signal that distinguishes a HomePod from an iPad (both publish AirPlay + Apple Companion). Changes (`src/diting/lan_classify.py`): - Rewrote all Bonjour needle tuples as named module-level constants for clarity: _BONJOUR_SPEAKER_NEEDLES = ("airplay audio", "sonos") _BONJOUR_PHONE_NEEDLES = ("apple companion",) _BONJOUR_PRINTER_NEEDLES = ("printer",) _BONJOUR_NAS_NEEDLES = ("file share",) _BONJOUR_TV_NEEDLES = ("chromecast",) - Added a long header comment over `_RULES` documenting the service-type → category mapping for future maintainers. Tests (`tests/test_device_class.py`): - Updated 5 existing tests that used raw service-type needles: test_airprint_bonjour_signals_printer (AirPrint/IPP → Printer) test_smb_bonjour_signals_nas (smb/_adisk → File share) test_sonos_bonjour_signals_speaker (_spotify-connect → Sonos) test_apple_companion_signals_phone (_companion-link → Apple Companion) test_ipad_airplay_plus_companion_signals_phone_not_tv (real categories) - Replaced test_homepod_airplay_plus_raop_signals_speaker_not_tv with test_homepod_airplay_audio_signals_speaker_not_tv using the actual category strings. - Added test_homepod_full_apple_signature_signals_speaker_not_phone using the live-data signature observed in the audit: `("AirPlay", "AirPlay audio", "Apple Companion", "HomeKit")` → speaker. TESTING.md (EN + ZH) updated to explicitly call out the needle-convention contract — needles must match the category strings produced by mdns, never the raw service-type names. Full suite 1028/1028 passes. openspec validate --strict ✓ for the change and 22/22 canonical specs. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(lan-classify): PS5 / PlayStation routed to gaming, not tv Sony Interactive Entertainment Inc. (the PlayStation vendor) and Sony Corporation (the Bravia / TV vendor) are separate IEEE registrants with separate OUIs. `_TV_VENDOR_NEEDLES` used to be `"sony"`, which is a substring of both — so a PS5's vendor `"Sony Interactive Entertainment Inc."` matched the tv rule first and never reached the gaming rule with the matching `"sony interactive entertainment"` needle. User flagged on 2026-05-23 with a screenshot of their PS5 Pro sitting in 192.168.124.210 classified as `电视` (tv) in the ZH LAN detail modal. Narrowed the needle from `"sony"` to `"sony corporation"`: - Sony Bravia TVs (registrant "Sony Corporation") still match the tv rule. - PS5 / PS4 (registrant "Sony Interactive Entertainment Inc.") fall through to the gaming rule via the existing `"sony interactive entertainment"` needle. Two new tests in `test_device_class.py`: - `test_sony_interactive_entertainment_signals_gaming_not_tv` - `test_sony_corporation_still_signals_tv` Same root cause as the earlier `airplay → tv` mis-class (too broad a needle wins over a more specific later rule). General lesson noted in the rules table comments — vendor needles should be the IEEE registrant's full name fragment, not a brand-family abbreviation that collides. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(lan,tui): Bonjour ↔ LAN cross-reference + Apple model code identification Three concerns landed together because they share infrastructure: 1. **Tier A** — Bonjour rows fall back to LAN OUI vendor when their own name-pattern + service-hint resolver returned None. 2. **Tier B** — Bonjour detail modal gains a `LAN host` section surfacing MAC / OUI vendor / device class / TTL / NBNS / UPnP for the LAN row at the same IP. 3. **Apple model code identification** — Bonjour TXT records carrying `model=Mac14,2` etc. flow through to `LANHost.bonjour_model`, drive a high-priority classifier rule, and render in the LAN detail modal's Identity Model row as `MacBook Air 13-inch (M2, 2022) (Mac14,2)` via the existing `_APPLE_MODELS` table in `mdns_txt_decoders.py`. User flagged on 2026-05-23 PM: their M2 MacBook Air was classified as `音箱` (speaker) under the prior fix (97656cde8c0f3a). Same root cause as the earlier AirPlay-as-tv mis-class: a Bonjour category was diagnostic of TWO different device classes. A Mac running with AirPlay receiver enabled publishes `_raop._tcp` → "AirPlay audio" — the same category my previous "speaker" rule keyed on. HomePods publish AirPlay audio TOO, but ALSO publish `HomeKit` (via the HomePodSensor service). HomeKit is the discriminator. This is the same trap as `"sony"` matching both Bravia and PlayStation: a too-broad needle hit a more-specific later rule that never got to fire. Classifier changes (`src/diting/lan_classify.py`): - New `_apple_model_class(host)` maps Apple's hardware product code (`Mac14,2`, `AudioAccessory6,1`, `iPhone16,1`, `AppleTV14,1`) to laptop / desktop / speaker / phone / tv via the `_APPLE_MODEL_PREFIXES` table. Apple's own product code is the highest-fidelity signal — it can't disagree with itself. - `classify()` now applies the Apple-model-code rule BEFORE the rules-table walk. Resolves Mac-vs-HomePod ambiguity directly. - Speaker rule tightened: `AirPlay audio` alone no longer fires; must be paired with `HomeKit` (HomePod), or the host must match an explicit `_SPEAKER_VENDOR_NEEDLES` brand (Sonos / Bose / JBL / Harman / Anker). - New laptop rule fires on Mac-specific Bonjour categories (`Mac` from `_workstation._tcp`, `Screen sharing` from `_rfb._tcp`). - New Apple-vendor + AirPlay-audio fallback routes Macs without Mac / Screen-sharing services to laptop instead of falling through to phone via Apple Companion. State plumbing (`src/diting/lan.py`): - `_build_bonjour_index` return tuple grows from `(host, services)` to `(host, services, apple_model)`. Pulls `dev.txt.get("model")` from each BonjourDevice on each IP; first-wins. - `LANHost` gains `bonjour_model: str | None`. - `_merge_arp_into_state` consumes the new tuple shape, populates `bonjour_model`, then calls `classify` — the Apple model code is in scope when the classifier runs. Bonjour → LAN cross-reference (`src/diting/tui.py`): - `DitingApp._lan_host_at_ip(ip)` / `_lan_index_by_ip()` — symmetric helpers to the existing Bonjour-into-LAN enrichment. - `_bonjour_borrow_vendor(d, lan_lookup)` — Bonjour rows whose vendor is None lift the LAN-side OUI vendor for the same IPv4. Rendered in dim cyan to mark "borrowed from LAN". - `BonjourPanel.update_devices` accepts `lan_lookup` and threads it through both `_bonjour_row_line` and `_bonjour_by_host_rows`. - `_refresh_mdns_panel` passes the App's per-render LAN index. - `BonjourDetailScreen` accepts `lan_host` kwarg and renders a new `LAN host` section (Tier B) with MAC, OUI vendor, device class, TTL (with gateway-suppression), NBNS name, UPnP server / model. `sync_to_app_selection` re-resolves on cursor-move. - `DitingApp._bonjour_lan_host_for(device)` matches by IPv4 address. LAN detail modal (`src/diting/tui.py`): - Identity Model row source priority: bonjour_model (via `_APPLE_MODELS` friendly-name lookup) → upnp_model → upnp_friendly_name. Mac14,2 → `MacBook Air 13-inch (M2, 2022) (Mac14,2)`. - Unknown model codes still render the raw string so users can match Apple's published identifier tables externally. i18n (`src/diting/i18n.py`): one new entry `"vendor (OUI)"` for the Bonjour modal's LAN cross-reference section. `LAN host` and `class` reused from the LAN modal's existing catalog. Tests (+22 new, 1041/1041 total): - `test_device_class.py` (+7): - `test_mac_with_airplay_receiver_enabled_signals_laptop_not_speaker` — direct regression for the 2026-05-23 PM user-flagged case - `test_homepod_airplay_audio_plus_homekit_signals_speaker_not_tv` (renamed; HomeKit now required) - 6 Apple-model-code tests: laptop / speaker / phone / tv / unknown-prefix fall-through / prefix-ordering - `test_lan.py` (+2): bonjour_index 3-tuple shape verification; apple model code extraction from TXT. - `test_tui_helpers.py` (+2): LAN modal Identity Model row prefers bonjour_model + friendly-name resolution; unknown codes fall back to raw. TESTING.md (EN + ZH) extended with 6 new coverage rows for the classifier changes, model-code path, and Bonjour ↔ LAN cross-reference. Tier C — promote pairwise enrichment to a shared host registry — deferred. Recorded in `project-shared-host-registry` memory note + a "Deferred" section in the change's design.md so the next maintainer can pick it up when a third source (BLE-RPA correlation, `lan.yaml`, edge-hardware sidecar) needs to join. Validates: openspec validate expand-lan-identification --strict ✓ openspec validate --specs --strict (22/22) ✓ pytest 1041/1041 ✓ Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(lan-classify): add `tablet` class + extract Apple model code from rpMd / am TXT keys (no name-based classification) User flagged 2026-05-23 PM: their `Situs-iPad-Pro-M4` (random-MAC iPad) was classified as `phone` because the only Bonjour signal was the catch-all "Apple Companion" category. Two pieces, one principle. **Principle (user-reinforced):** device names (`bonjour_name`, reverse-DNS `hostname`) are user-controllable. A renamed device must NOT change its class — anything else is a spoofing surface in an audit tool. So no name-pattern matching. **Authoritative signal:** Apple Continuity protocols carry the hardware model identifier in different TXT keys for different services: - `_airplay._tcp.local.` → `model=` (e.g. `Mac14,2`) - `_companion-link._tcp.local.` → `rpMd=` (e.g. `iPad14,3`) - `_raop._tcp.local.` → `am=` (e.g. `AudioAccessory6,1`) The random-MAC iPad in the user's network publishes only `_companion-link`, so the previously-extracted `model` key missed it. `_bonjour_extract_apple_model` now walks ``("model", "rpMd", "am")`` in order, first-wins. Random-MAC iPads get classified via `rpMd=iPad14,3` in companion-link TXT without any user-controllable string entering the decision. Changes: - New `tablet` class in the taxonomy (12 classes total). iPads are tablets, not phones — distinct form factor. - `_APPLE_MODEL_PREFIXES`: `iPad` → `tablet` (was `phone`). - `src/diting/lan.py`: `_build_bonjour_index` walks Continuity TXT keys via `_bonjour_extract_apple_model` helper. Documents the per-service-type key conventions in a header comment. - `src/diting/lan_classify.py`: Apple model-code path runs BEFORE the rules table. Deliberate non-rule: name patterns removed; replaced with a long header comment explaining the audit-tool reasoning. - i18n: `tablet` → `平板` (EN + ZH). - README + CHANGELOG (EN + ZH) + spec deltas + design.md vocabulary lists updated 11 → 12 classes. Tests (+10 net new, 1047/1047 total): - `test_apple_model_ipad_signals_tablet_not_phone` — direct regression for the user-flagged case. - `test_bonjour_name_ipad_pattern_does_NOT_signal_tablet` — proves the spoofing surface is closed. - `test_renamed_homepod_to_macbook_still_classifies_correctly` — adversarial: HomeKit-bearing host renamed to "MacBook" stays speaker. - `test_apple_model_code_still_wins_over_misleading_name` — authoritative > misleading. - `test_bonjour_cross_ref_pulls_apple_model_code_from_rpmd_txt` + `_from_am_txt` — verify the new TXT-key extraction for companion-link and raop services. - `_VALID_CLASSES` set updated to include `tablet`. Validates: openspec validate expand-lan-identification --strict ✓ openspec validate --specs --strict (22/22) ✓ pytest 1047/1047 ✓ For random-MAC iPads on networks where the user has firewalled mDNS or the iPad has Continuity disabled, the `_companion-link` TXT model code won't be visible and we genuinely have no authoritative signal. The honest answer is the host falls through to `phone` and the user can see the name in the modal to decide for themselves — better than a name-based guess that can be spoofed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent c1c4bb0 commit 5a76d00

47 files changed

Lines changed: 59830 additions & 17119 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CHANGELOG.md

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,108 @@ behaviours between releases.
1111

1212
## [Unreleased]
1313

14+
## [1.7.0] — 2026-05-23
15+
16+
Minor release. **LAN identification expansion** — the LAN view now
17+
recognises far more devices on a typical CN home / office network
18+
by layering four new identification sources on top of the existing
19+
ARP + ICMP + OUI + Bonjour stack: multi-tier IEEE OUI registry
20+
lookup, NBNS / SSDP / active mDNS probes, ICMP TTL fingerprinting,
21+
and a rules-table device-class classifier. The probe layer is
22+
scene-gated — `home` / `office` / `audit` default to active,
23+
`public` defaults to passive but offers a one-shot consent override
24+
via the new uppercase `P` keybinding (with 2-second cooldown +
25+
JSONL audit event).
26+
27+
The LAN row layout reorganises per a UX benchmark of Fing Desktop:
28+
the class column moves to the leftmost data position (it carries
29+
more signal than vendor when scanning a list), and rows whose
30+
`first_seen < 24 h` are prefixed with a `[new]` chip so unfamiliar
31+
devices stand out.
32+
33+
### Added
34+
- **Multi-tier IEEE OUI registry.** Three bundled JSON files
35+
(`wifi_ouis.json` MA-L, `wifi_ouis_ma_m.json` MA-M,
36+
`wifi_ouis_ma_s.json` MA-S) — 57 211 vendor mappings total. The
37+
lookup function tries 36-bit → 28-bit → 24-bit, longest prefix
38+
wins. CN white-label IoT vendors (Tuya / Aqara / Tapo / Imou)
39+
that only registered MA-S sub-allocations now resolve to their
40+
real brand. `scripts/refresh_ouis.py` extended with
41+
`--source ieee|wireshark|auto` — auto-falls back to the
42+
Wireshark `manuf` mirror when IEEE direct is unreachable
43+
(CN-network-friendly default).
44+
- **Vendor display normalization.** Raw IEEE strings get stripped
45+
of trailing corporate-form tokens (`CO., LTD`, `CORPORATION`,
46+
`INC`, `TECHNOLOGIES`) and leading geographic prefixes
47+
(`SHENZHEN`, `HANGZHOU`, `BEIJING`); titlecased with acronym
48+
preservation (`HP`, `IBM`, `ASUS`, `H3C`, `TP-Link`).
49+
`NEW H3C TECHNOLOGIES CO., LTD``New H3C`. Raw form
50+
preserved on a dim continuation line in the detail modal.
51+
- **Active LAN discovery layer** (new module
52+
`src/diting/lan_probes.py`): NBNS Status Query (RFC 1002
53+
wildcard `*`), SSDP M-SEARCH, active mDNS browse for the
54+
`_services._dns-sd._meta._tcp.local.` record, plus optional
55+
HTTP fetch of UPnP `LOCATION` XML for `friendlyName` +
56+
`modelName`. All three phases run concurrently via
57+
`asyncio.gather`, each fail-soft on exception. Zero new
58+
third-party deps — stdlib `socket` + `urllib` + `xml.etree`
59+
(defused against external entities). No new TCC permissions.
60+
- **Scene-gated active probing.**
61+
`scene_defaults()["lan_active_probe"]` is `True` for home /
62+
office / audit, `False` for public. `DITING_LAN_PROBE=0|1`
63+
overrides; `DITING_LAN_UPNP_FETCH=0|1` separately gates the
64+
LOCATION-XML fetch.
65+
- **Public-scene one-shot consent override.** Uppercase `P` in
66+
the LAN view opens `LANProbeConsentScreen` enumerating exactly
67+
what packets will be sent and the consequences (other guests'
68+
devices, IDS flagging, captive-portal disconnect). Confirm with
69+
`y` after a **2-second cooldown** to run ONE active-probe
70+
sweep; cooldown defeats muscle-memory press-through.
71+
Subsequent sweeps revert to passive; re-press `P` to re-consent.
72+
- **`LANActiveProbeConsentedEvent` JSONL event.** Written on each
73+
consent press, carrying `scene`, `ssid`, and the planned packet
74+
counts. Audit-only — never emitted for scene-default or
75+
env-forced probing.
76+
- **`[probing]` subtitle chip.** Shows in the LAN view's subtitle
77+
while a consented one-shot probe sweep is queued; clears when
78+
the resulting snapshot lands.
79+
- **TTL fingerprint.** `_ping_one` now also parses the ICMP echo's
80+
`ttl=N` segment; `LANHost.ttl` carries the raw value,
81+
`LANHost.ttl_class` carries the coarse bucket (`unix` = 50-64,
82+
`windows` = 100-128, `router` = 200-255, None otherwise).
83+
Surfaces in the detail modal's Network section as
84+
`TTL 64 (unix)`. Zero additional traffic.
85+
- **Device-class classifier** (new module
86+
`src/diting/lan_classify.py`). Pure function over the augmented
87+
LANHost returns one of 12 classes: `phone | tablet | laptop | desktop |
88+
tv | camera | smart-home | printer | nas | gaming | speaker |
89+
router`, or None. Total function — never raises on any field
90+
combination.
91+
- **LAN row layout: class column + `[new]` chip.** Per Fing UX
92+
benchmark, the class column is the leftmost data column (it
93+
disambiguates faster than vendor — a "New H3C" OUI can be a
94+
router, AP, switch, or IoT bridge). Rows with `first_seen <
95+
24 h` carry a `[new]` chip in dim cyan; self / gateway never
96+
carry the chip.
97+
- **Detail modal — Class + TTL + Active-discovery rows.**
98+
Identity section gains `Class:` (when classifier fires); new
99+
Active-discovery section consolidates NBNS name, UPnP server
100+
header, UPnP friendlyName, UPnP modelName. Network section
101+
gains `TTL: <value> (<class>)` when ICMP returned a TTL.
102+
- **EN ↔ ZH i18n** for every new string: 11 class names, 2 TTL
103+
classes, `[new]` / `[probing]` chips, full consent modal copy.
104+
105+
### Documentation
106+
- **`README.md` + `docs/zh/README.md`** gain a `## LAN
107+
identification` section covering the multi-tier OUI, the four
108+
enrichment layers, the scene-gating matrix, and the
109+
public-scene consent flow with an ASCII mock of the modal.
110+
111+
### Spec
112+
- Single OpenSpec change `expand-lan-identification` (proposal +
113+
design.md + tasks.md + seven spec deltas: `lan-inventory`,
114+
`scenes`, `events`, `event-log`, `tui-shell`, `i18n`, `cli`).
115+
14116
## [1.6.0] — 2026-05-22
15117

16118
Minor release. **Scene awareness** — diting now carries an explicit

README.md

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,97 @@ reads office-mode noise as "expected baseline" rather than
209209
`--ble-presence-gate D` continues to override the scene's gate
210210
when you want fine control for one session.
211211

212+
## LAN identification
213+
214+
The LAN view (fourth `n` press) discovers every host on the local
215+
/24 via ARP + ICMP sweep, then enriches each row through a layered
216+
identification stack:
217+
218+
- **Multi-tier OUI lookup** — IEEE MA-L (24-bit) → MA-M (28-bit) →
219+
MA-S (36-bit), longest prefix wins. The bundled JSONs together
220+
carry ~57k vendor mappings, so small white-label IoT vendors
221+
(Tuya / Aqara / Tapo / Imou …) that only registered MA-S
222+
sub-allocations still resolve to a real name.
223+
- **Vendor normalization** — the raw IEEE string is shortened for
224+
display (`NEW H3C TECHNOLOGIES CO., LTD` → `New H3C`,
225+
`SHENZHEN BILIAN ELECTRONIC CO.,LTD` → `Bilian`). The original
226+
text is preserved on a dim continuation line in the detail modal.
227+
- **Reverse DNS + Bonjour cross-reference** — `gethostbyaddr`
228+
hostname when the router publishes PTR records, plus a sweep of
229+
the live Bonjour state for any device matching this IP.
230+
- **Active discovery** — NBNS Status Query (UDP 137 unicast),
231+
SSDP M-SEARCH (UDP 1900 multicast), and an mDNS browse query
232+
for the meta-service record. Optionally fetches the UPnP
233+
LOCATION XML for `friendlyName` + `modelName`. Layered on top
234+
so a host that publishes neither Bonjour nor reverse DNS — most
235+
Windows machines, IP cameras, smart TVs, NAS — still becomes
236+
identifiable.
237+
- **TTL fingerprint** — the ICMP echo already returns a TTL value;
238+
diting buckets it into `unix` (50-64), `windows` (100-128), or
239+
`router` (200-255). Surfaces in the detail modal as e.g.
240+
`TTL 64 (unix)`.
241+
- **Device class** — a rules-table classifier consumes vendor,
242+
Bonjour categories, NBNS / UPnP fields, and TTL to assign one
243+
of: `phone | tablet | laptop | desktop | tv | camera | smart-home |
244+
printer | nas | gaming | speaker | router`. Rendered as the
245+
leftmost data column on each row.
246+
247+
Rows whose `first_seen < 24 h` are prefixed with a `[new]` chip
248+
so unfamiliar devices stand out at a glance.
249+
250+
### Active probing is scene-aware
251+
252+
The active-discovery layer is the one piece of LAN identification
253+
that **sends packets to other hosts**. To stay polite about that,
254+
diting gates the layer through the active scene:
255+
256+
| Scene | NBNS + SSDP + mDNS-meta | Why |
257+
|----------|--------------------------|--------------------------------------------------------------------------------------|
258+
| `home` | on by default | Your own network. Probes go to devices you bought. |
259+
| `office` | on by default | Corp networks already see this traffic from every other device. |
260+
| `audit` | on by default | You're actively investigating; probe everything. |
261+
| `public` | **off by default** | Coffee shops / hotels / airports — you don't own the network, other guests do. |
262+
263+
Two env vars override the scene default at startup:
264+
265+
- `DITING_LAN_PROBE=0|1` — force probing off / on regardless of
266+
scene.
267+
- `DITING_LAN_UPNP_FETCH=0|1` — gate the optional HTTP fetch of
268+
UPnP LOCATION URLs (set to `0` to keep M-SEARCH on but skip the
269+
follow-up fetch). Default on.
270+
271+
### Public-scene one-shot consent
272+
273+
In `public` scene the LAN view binds uppercase **`P`** to a
274+
consent modal:
275+
276+
```
277+
┌─ Active LAN probing ──────────────────────────────────┐
278+
│ Scene: public Network: HotelGuest │
279+
│ │
280+
│ Active probing sends UDP packets to OTHER hosts on │
281+
│ this network: │
282+
│ · NBNS UDP 137 unicast │
283+
│ · SSDP M-SEARCH UDP 1900 multicast │
284+
│ · mDNS UDP 5353 multicast │
285+
│ │
286+
│ On a public network you accept that: │
287+
│ · other guests' devices receive your probes │
288+
│ · hotel / airport IDS may flag this as scanning │
289+
│ · captive portals may rate-limit or disconnect │
290+
│ │
291+
│ One-shot probe. Re-confirm next time. │
292+
│ │
293+
│ [ esc cancel ] [ wait 2s ] │
294+
└───────────────────────────────────────────────────────┘
295+
```
296+
297+
Press `y` after a 2-second cooldown (defeats muscle-memory
298+
press-through) to run **one** active-probe sweep and write a
299+
`lan_active_probe_consented` line to your JSONL log. Subsequent
300+
sweeps revert to passive — every press of `P` re-opens the
301+
modal, no sticky state.
302+
212303
## The name
213304

214305
**diting (谛听)** is a mythical beast in Chinese Buddhist lore —

docs/zh/CHANGELOG.md

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,92 @@
1010

1111
## [Unreleased]
1212

13+
## [1.7.0] — 2026-05-23
14+
15+
Minor release。**LAN 识别能力扩展** —— LAN 视图现在能识别国内
16+
家庭 / 办公网里更多种类的设备。在原有的 ARP + ICMP + OUI +
17+
Bonjour 栈之上叠加了四层新识别源:多层级 IEEE OUI 注册表查询、
18+
NBNS / SSDP / 主动 mDNS 探测、ICMP TTL 指纹,以及一张规则表
19+
驱动的设备分类器。探测层按 scene 门控 —— `home` / `office` /
20+
`audit` 默认主动,`public` 默认 passive,但通过新加的大写 `P`
21+
键提供"用户自担"的单次确认开关(含 2 秒冷却 + JSONL 审计事件)。
22+
23+
LAN 行的列布局参考 Fing Desktop 重新组织:class 列移到最左侧
24+
(在扫描列表时,分类比厂商更有信息量),首次出现时间 < 24 小时
25+
的行前面带 `[新]` chip,陌生设备一眼能挑出来。
26+
27+
### 新增
28+
- **多层级 IEEE OUI 注册表。** 三个 bundled JSON 文件
29+
`wifi_ouis.json` MA-L,`wifi_ouis_ma_m.json` MA-M,
30+
`wifi_ouis_ma_s.json` MA-S)—— 一共 57 211 条厂商映射。查询
31+
函数按 36 位 → 28 位 → 24 位走,最长前缀胜出。CN 小白牌
32+
IoT 厂商(Tuya / Aqara / Tapo / Imou)只在 MA-S 注册过子段,
33+
这下能解析到真实品牌了。`scripts/refresh_ouis.py` 新增
34+
`--source ieee|wireshark|auto` —— IEEE 直连失败时自动 fallback
35+
到 Wireshark `manuf` 镜像(CN 网络友好默认行为)。
36+
- **厂商名规范化。** 把 IEEE 原文剥掉公司形态尾缀
37+
`CO., LTD` / `CORPORATION` / `INC` / `TECHNOLOGIES`)和
38+
开头的地理前缀(`SHENZHEN` / `HANGZHOU` / `BEIJING`),
39+
titlecase 同时保留缩写(`HP` / `IBM` / `ASUS` / `H3C` /
40+
`TP-Link`)。`NEW H3C TECHNOLOGIES CO., LTD``New H3C`
41+
详情模态里以 dim 续行保留 IEEE 原文以便核对。
42+
- **主动 LAN 探测层**(新模块 `src/diting/lan_probes.py`):
43+
NBNS Status Query(RFC 1002 通配符 `*`)、SSDP M-SEARCH、
44+
主动 mDNS browse 查询 `_services._dns-sd._meta._tcp.local.`
45+
记录,可选地 HTTP 拉取 UPnP `LOCATION` XML 提取
46+
`friendlyName` + `modelName`。三个 phase 通过
47+
`asyncio.gather` 并发,每个独立 fail-soft。零新第三方依赖
48+
—— 全用 stdlib `socket` + `urllib` + `xml.etree`(已防外部
49+
实体)。无新 TCC 权限。
50+
- **主动探测按 scene 门控。**
51+
`scene_defaults()["lan_active_probe"]` 在 home / office /
52+
audit 是 `True`,在 public 是 `False``DITING_LAN_PROBE=0|1`
53+
覆盖;`DITING_LAN_UPNP_FETCH=0|1` 单独控制 LOCATION-XML
54+
HTTP 拉取。
55+
- **Public scene 单次确认开关。** LAN 视图下大写 `P` 打开
56+
`LANProbeConsentScreen`,明确列出会发什么包(NBNS UDP 137
57+
unicast / SSDP UDP 1900 multicast / mDNS UDP 5353 multicast)
58+
和后果(其他客人设备会收到、IDS 可能告警、captive portal
59+
可能限速或踢出)。等 **2 秒冷却** 后按 `y` 运行**一次**
60+
主动探测 sweep;冷却用来防误触。之后所有 sweep 都回到
61+
passive;要再扫一次需要重新按 `P` 重新确认。
62+
- **`LANActiveProbeConsentedEvent` JSONL 事件。** 每次按下 `y`
63+
确认时写一条,带 `scene` / `ssid` / 即将发送的包数。仅审计
64+
用 —— scene 默认开或 env 强制开的探测不写。
65+
- **`[探测中]` subtitle chip。** 在 LAN 视图的 subtitle 里显示,
66+
从用户确认开始到结果 snapshot 到达。
67+
- **TTL 指纹。** `_ping_one` 现在解析 ICMP 回包里的 `ttl=N`
68+
`LANHost.ttl` 保留原值,`LANHost.ttl_class` 是分桶(`unix`
69+
= 50-64,`windows` = 100-128,`router` = 200-255,其他 None)。
70+
详情模态的 Network 段显示 `TTL 64 (unix)`。零额外流量。
71+
- **设备分类器**(新模块 `src/diting/lan_classify.py`)。
72+
一张规则表消费 vendor / Bonjour 类目 / NBNS / UPnP 字段 / TTL,
73+
输出 12 类之一:`phone | tablet | laptop | desktop | tv | camera |
74+
smart-home | printer | nas | gaming | speaker | router` 或
75+
None。纯函数 —— 对任何字段组合都不抛。
76+
- **LAN 行布局:class 列 + `[new]` chip。** 按 Fing UX 实证,
77+
class 列放到 vendor **左侧**("New H3C" OUI 可以是路由器 /
78+
AP / 交换机 / IoT 桥,class 比 vendor 区分得更快)。
79+
`first_seen < 24h` 的行带 dim cyan 的 `[新]` chip;
80+
self / gateway 永不带。
81+
- **详情模态:Class + TTL + Active discovery 段。**
82+
Identity 段新增 `Class:` 行(分类器命中时);新的
83+
Active discovery 段汇总 NBNS 名 / UPnP server 头 / UPnP
84+
friendlyName / UPnP modelName。Network 段新增
85+
`TTL: <值> (<class>)`(TTL 已知时)。
86+
- **EN ↔ ZH i18n** 覆盖每个新字符串:11 个 class 名、2 个 TTL
87+
class、`[新]` / `[探测中]` chip、整个确认模态的所有文案。
88+
89+
### 文档
90+
- **`README.md` + `docs/zh/README.md`** 新增 `## LAN 识别能力`
91+
章节,覆盖多层级 OUI、四层富集机制、scene 门控矩阵、Public
92+
scene 的确认流程(含模态 ASCII mock)。
93+
94+
### 规范
95+
- 一个 OpenSpec change `expand-lan-identification`(proposal +
96+
design.md + tasks.md + 七个 spec delta:`lan-inventory`
97+
`scenes``events``event-log``tui-shell``i18n``cli`)。
98+
1399
## [1.6.0] — 2026-05-22
14100

15101
Minor release。**场景感知** —— diting 现在明确知道「你身处什么

0 commit comments

Comments
 (0)