Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ behaviours between releases.

## [Unreleased]

### Changed
- **`diting --help` restructured.** Subcommands and global options now live in clearly-separated sections (was one flat list). `--notify` is promoted to a top-level entry instead of an inline hint under "(no args)". `analyze` lists its flags (`--since` / `--for-llm` / `--anonymize`) in the top-level help — previously they were only discoverable via the README. Each entry uses 2-line descriptions instead of 5-line paragraphs; the README still carries the long-form documentation. Both EN and ZH catalogs updated.
- **BLE anonymous-advert presence gate, default 5 s.** `BLEPoller` no longer fires `BLEDeviceSeenEvent` on the first observation of an anonymous advert (vendor + RSSI only, no `name`). Instead the identifier enters PENDING and must be observed for at least `presence_gate_s` seconds before graduating to PRESENT and emitting `seen`. An identifier that ages out via TTL before the gate matures emits NOTHING — no `seen`, no `left` — eliminating the single-packet `seen_for=0s` ghost flicker that dominates dense RF environments. Named adverts (`Magic Keyboard`, `Z-GM0YXG5J`, `ccy iPhone 15 Pro Max`) and connected peripherals (`_connected` snapshot) bypass the gate and still fire `seen` on the first observation — only anonymous adverts are gated. New CLI flag `--ble-presence-gate DURATION` (`5s` / `30s` / `2m`, or `0` to disable) plus `DITING_BLE_PRESENCE_GATE` env var; CLI wins over env, defaults to 5 s. `0` restores the A1 record-everything contract for users who want to catch every ephemeral advert (security research, AirTag-spotting, brief-advertiser debugging).

### Fixed
- **Events panel said "joined" but the events are `*_seen`.** EN UI rendered `ble_device_seen` / `bonjour_service_seen` / `lan_host_seen` as `[BLE] device joined:` / `[BJ] service joined:` / `[LAN] host joined:`. ZH already said `设备出现 / 服务出现 / 主机出现` (appeared / seen). The JSONL `type` field always said `*_seen`. The EN wording also lied about semantics — "joined" reads like *paired / associated*, but the event fires on the first passive observation, including strangers' phones walking past. Renamed three EN i18n keys to `device seen: ` / `service seen: ` / `host seen: `; ZH unchanged.
- **Events filter footer claimed `1/2/3/4/0`; A1 added `5/6/7`.** The EventsScreen filter cycle has eight buckets since A1 (`ble` / `bonjour` / `lan` on keys `5` / `6` / `7`), the bindings work, but four user-facing strings — events-modal footer and help-modal "Events modal (m)" paragraph, EN + ZH — still listed only the legacy five keys. Extended to `1/2/3/4/5/6/7/0` in both locales so the new filters are discoverable from the TUI alone.
Expand Down
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,12 @@ BSSID. Same path as menu-off-then-on, in one keystroke.
- **Find Bluetooth things around you.** What IoT is in this room?
Where's that AirTag? The BLE list resolves vendor + protocol on
every advertising device; the detail modal's RSSI sparkline lets
you walk a target down by signal strength.
you walk a target down by signal strength. Anonymous adverts
(vendor + RSSI only, no name) are gated by a 5-second presence
window before they fire a `seen` event, which kills single-packet
ghost flicker in dense RF environments while preserving real
walk-bys; tune with `--ble-presence-gate DURATION` (`0` to
capture every ephemeral advert).
- **See who's on your Wi-Fi.** The fourth panel (cycle to it via
`n`) lists every host on your local subnet — IP, MAC, vendor,
hostname, Bonjour name. ARP cache + ICMP sweep; no router
Expand Down
4 changes: 4 additions & 0 deletions docs/zh/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@

## [Unreleased]

### 变更
- **`diting --help` 重新排版。** 子命令和全局选项分到两个独立段落(之前是混在一起的扁平列表)。`--notify` 提升为顶层条目,不再只在「(无参数)」那段被一笔带过。`analyze` 子命令的 flag(`--since` / `--for-llm` / `--anonymize`)现在在顶层 help 里列出来 —— 之前必须读 README 才能发现。每条说明压缩到 2 行(之前最长 5 行),长篇文档留在 README。EN 与 ZH 两份目录同步更新。
- **BLE 匿名广播 presence gate,默认 5 s。** `BLEPoller` 不再在匿名广播(只有 vendor + RSSI,没有 `name`)首次出现时就发 `BLEDeviceSeenEvent`。identifier 进入 PENDING 状态,必须被持续观察至少 `presence_gate_s` 秒才会 graduate 到 PRESENT、发 `seen`。如果 identifier 在 gate 成熟之前就被 TTL 清掉了,**什么事件都不发** —— 既无 `seen` 也无 `left`,干掉密集 RF 环境里那种 `seen_for=0s` 单包 ghost 闪现。带 `name` 的广播(`Magic Keyboard`、`Z-GM0YXG5J`、`ccy iPhone 15 Pro Max`)和已连接外设(`_connected` 快照)不走 gate —— 第一次观察就发 `seen`,只对匿名广播加门。新增 CLI flag `--ble-presence-gate DURATION`(`5s` / `30s` / `2m`,或 `0` 关闭)和 `DITING_BLE_PRESENCE_GATE` 环境变量;CLI 优先于 env,默认 5 s。`0` 恢复 A1 「记一切」语义,给想抓住每一个瞬态广播的用户(安全研究、AirTag 寻找、短广播调试)用。

### 修复
- **事件面板英文写「joined」、但事件其实是 `*_seen`。** EN UI 把 `ble_device_seen` / `bonjour_service_seen` / `lan_host_seen` 渲染成 `[BLE] device joined:` / `[BJ] service joined:` / `[LAN] host joined:`,但 ZH 一直是 `设备出现 / 服务出现 / 主机出现`,JSONL `type` 字段一直是 `*_seen`。"joined" 还会让人误以为「配对 / 关联」—— 实际事件触发的是首次被动观察到,包括路过的陌生人手机。把三个 EN i18n key 改为 `device seen: ` / `service seen: ` / `host seen: `;ZH 不变。
- **事件过滤页脚仍写 `1/2/3/4/0`,A1 早就加了 `5/6/7`。** EventsScreen 过滤循环自 A1 起已经是八桶(`ble` / `bonjour` / `lan` 绑定到 `5` / `6` / `7`),按键也接好了,但事件弹窗页脚 + 帮助弹窗里的「Events modal (m)」段落(EN + ZH 共四处)还在只列旧的五个键。统一改为 `1/2/3/4/5/6/7/0`,让新加的过滤桶在 TUI 里就能被发现。
Expand Down
6 changes: 5 additions & 1 deletion docs/zh/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,11 @@ Apple 的面板不会告诉你你连在哪台 AP;谛听会,而且按 `c` 一
问题缩小到具体原因,你不用读原始包。
- **找身边的蓝牙设备。** 这个房间里有哪些 IoT 设备?我那只 AirTag
在哪?BLE 列表对每个广播设备解析厂商 + 协议;详情模态里的 RSSI
sparkline 让你按信号强度走过去找。
sparkline 让你按信号强度走过去找。匿名广播(只有 vendor + RSSI、
没有名字)需要先被持续观察 5 秒才会触发 `seen` 事件,能压掉
密集 RF 环境里那种单包 ghost 闪现,又保留真实的路过接触;可以
通过 `--ble-presence-gate DURATION`(填 `0` 抓住每一个瞬态广播)
调整。
- **看谁在用你的 Wi-Fi。** 第四个面板(按 `n` 切到 LAN 视图)列出
本地子网上每一台主机 —— IP、MAC、厂商、主机名、Bonjour 名字。
靠 ARP cache + ICMP sweep 实现,不用登路由器后台。默认扫 /24,
Expand Down
3 changes: 2 additions & 1 deletion docs/zh/TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,8 @@
| Categories 诊断行排除协议工具类 GATT 服务 | `test_ble.py::test_service_category_category_only_excludes_protocol_services` |
| Vendors 诊断行标注 RPA 轮换折叠数 | `test_tui_helpers.py::test_ble_vendors_line_annotates_folded_rotation_count`、`::test_ble_vendors_line_skips_annotation_when_nothing_folded` |
| BLE 行 Name 列在 helper 没给名字时依次回落到 `type` / `device_class`,最后才显示 `(未知)`;Services 列只保留 service-category(不再重复展示 `type` / `device_class`) | `test_tui_helpers.py::test_ble_row_line_name_uses_helper_name_when_present`、`::test_ble_row_line_name_falls_back_to_type`、`::test_ble_row_line_name_falls_back_to_device_class`、`::test_ble_row_line_name_unknown_when_no_signal`、`::test_ble_label_summary_services_only` |
| `BLEPoller` 首次看到 identifier 时发 `BLEDeviceSeenEvent`;TTL 失效时发 `BLEDeviceLeftEvent`;不防抖、后续观察不再 re-emit;identifier 在发完 left 之后再次 flap 进 `_devices` 又被 TTL 清掉,本会话内不再发任何事件 | `test_ble.py::test_poller_emits_seen_event_on_first_observation`、`::test_poller_does_not_re_emit_seen_for_known_identifier`、`::test_poller_emits_left_event_on_ttl_eviction`、`::test_poller_connected_peripheral_does_not_re_emit_seen`、`::test_poller_does_not_re_emit_left_after_identifier_returns_and_evicts_again` |
| `BLEPoller` 在 identifier graduate 到 PRESENT 时发 `BLEDeviceSeenEvent`(带 name 的广播和已连接外设不走 gate;匿名广播必须被持续观察至少 `presence_gate_s` 秒,默认 5s);只有 graduate 过的 identifier 在 TTL 失效时才发 `BLEDeviceLeftEvent`;`presence_gate_s=0` 恢复「不防抖」契约;identifier 发完 left 之后再次 flap 进 `_devices` 又被 TTL 清掉,本会话内不再发任何事件 | `test_ble.py::test_poller_emits_seen_event_on_first_observation`、`::test_poller_does_not_re_emit_seen_for_known_identifier`、`::test_poller_emits_left_event_on_ttl_eviction`、`::test_poller_connected_peripheral_does_not_re_emit_seen`、`::test_poller_does_not_re_emit_left_after_identifier_returns_and_evicts_again`、`::test_poller_anonymous_advert_below_gate_emits_no_seen_no_left`、`::test_poller_anonymous_advert_graduates_after_gate_elapses`、`::test_poller_named_first_advert_bypasses_gate`、`::test_poller_connected_peripheral_bypasses_gate`、`::test_poller_presence_gate_zero_restores_no_debounce`、`::test_poller_pending_identifier_graduates_when_name_appears_in_later_advert` |
| `--ble-presence-gate D` CLI flag + `DITING_BLE_PRESENCE_GATE` 环境变量控制 `presence_gate_s`;CLI 优先于 env;空 env 回退到默认 5s;非法 env 警告后回退;`0` 是 `0s` 的快捷写法 | `test_cli.py::test_extract_ble_presence_gate_arg_parses_seconds_form`、`::test_extract_ble_presence_gate_arg_parses_equals_form`、`::test_extract_ble_presence_gate_arg_accepts_zero_shortcut`、`::test_extract_ble_presence_gate_arg_absent_returns_none`、`::test_extract_ble_presence_gate_arg_invalid_unit_exits`、`::test_resolve_ble_presence_gate_cli_wins`、`::test_resolve_ble_presence_gate_env_fallback`、`::test_resolve_ble_presence_gate_default_5s`、`::test_resolve_ble_presence_gate_blank_env_is_default`、`::test_resolve_ble_presence_gate_invalid_env_warns_and_defaults` |

### `cli`

Expand Down
142 changes: 142 additions & 0 deletions openspec/changes/add-ble-presence-gate/design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
# design — add-ble-presence-gate

## State machine

Per-identifier, per-session:

```
INIT ── first advert, anonymous ──> PENDING
INIT ── first advert, named OR _connected ──> PRESENT (immediate seen)
PENDING ── (now - first_seen) ≥ gate, still in _devices ──> PRESENT
└─ emit BLEDeviceSeenEvent with original first_seen timestamp
PENDING ── identifier evicted before gate elapses ──> INIT-silent
└─ no seen, no left; drop quietly
PRESENT ── TTL eviction ──> DEPARTED
└─ emit BLEDeviceLeftEvent
DEPARTED ── advert re-arrives OR re-evicts ──> DEPARTED (silent)
└─ handled by fix-ble-left-dedup (#107)
```

PENDING is the new state. The transition INIT-silent is the new
"silent drop": an identifier heard once that vanishes before the
gate matures leaves no trace in the events log.

## Why named devices bypass

A first-packet advert that arrives with `name` set is almost
always a real device with stable identity (`Magic Keyboard`,
`AirPods Pro`, `Z-GM0YXG5J`, `ccy iPhone 15 Pro Max`). Suppressing
its `seen` event for 5 s would make the events panel feel laggy
on familiar peripherals — the user does see them in the BLE list
immediately (snapshot path), but the event log would only register
them on the second tick. Inconsistency between the two surfaces
is a worse user experience than a slightly noisier log on rare
events.

Anonymous adverts (just `vendor` + RSSI, no name, no service
UUIDs) are the noisy ones. Apply the gate there.

Connected peripherals — entries from the helper's
`retrieveConnectedPeripherals` snapshot — are by definition
already bonded. They've passed every reasonable confidence check.
Bypass.

## Timestamp choice on graduated seen events

When a PENDING identifier graduates, the emitted
`BLEDeviceSeenEvent.timestamp` SHALL be the **original first-seen
time**, not the gate-elapsed wall-clock time. Rationale:

- The JSONL `ts` field claims to be "when did the device appear".
Lying about that breaks `diting analyze`'s hour-of-day and
day×hour aggregations.
- The events panel sorts by event timestamp; the seen line
lands in its chronological position even though we emit
~5 s later than wall-clock.
- `seen_for_seconds` on the eventual left event is computed
from `last_seen - first_seen` on the BLEDevice — already
honest, already separate from emission time.

## Why not graduate by advert count instead of time

Two reasons elapsed time wins:

1. **User-comprehensible config.** `5s` and `15s` map to a user's
intuition of "how long was the device around". `3 adverts` does
not — the user has to know typical advert intervals.
2. **Time is sensor-invariant.** Different BLE devices advertise
at different rates (1 Hz typical, but Apple Nearby fires
bursts then quiets down). A time gate cuts cleanly across
advertise-rate variance; a count gate would favour
high-rate-spammy advertisers.

## Where to plumb the option

CLI flag + env var, no config file. Three reasons:

- Matches the project's existing pattern (`DITING_LANG`, `DITING_LOG`).
- Config files imply a config-loading subsystem diting doesn't
have today; adding one for a single knob is over-engineering.
- Users with a persistent preference can `export
DITING_BLE_PRESENCE_GATE=0` in their shell rc; one-off runs
can pass `--ble-presence-gate 30s`.

CLI flag wins over env var (per `i18n.detect_default_lang`'s
precedence — CLI explicit > env > default).

## Edge cases

**A pending identifier whose entry's `name` becomes populated
later (later adverts with scan-response that carries the name).**
The BLEPoller's `_apply_helper_line` rebuilds the BLEDevice via
`_build_device`, which preserves name from `prior` if present.
If the *first* helper line had no name but the *second* did,
`_devices[ident].name` is now non-null. On the next tick's
`_detect_transitions`, the gate sees a name and graduates the
identifier immediately. Good.

**An identifier in PENDING when the user stops diting.** The
device exited PENDING via INIT-silent (no events emitted). No
JSONL line written. The user's interpretation: "diting didn't
see this device" — which matches the gate's intent.

**A long-running session where an identifier flap-bounces.**
First advert at t=0 → PENDING. TTL-evicted at t=35 (no seen
emitted, no left emitted, drops from _pending_seen). At t=120
the same identifier shows up again. Treated as a brand-new
INIT (it's no longer in any of {pending, seen, departed}).
Gate restarts from t=120. Consistent.

## Default value rationale

5 s = 2-3 standard 1-2 Hz BLE advert intervals. Kills:

- Single-packet detection (the user's exact complaint)
- 1-2 packet bursts from edge-of-range identifiers
- Apple Continuity RPA edge flickers (which typically need
several seconds of audibility before staying in range)

Preserves:

- Walk-bys (5-30 s of contact, multi-packet)
- Static nearby devices (constantly broadcasting)
- AirPods passing through the room (8-15 s typical)
- The user's own pocketed iPhone (15-min-stable identifier)

Configurable up to user's environment. A home audit might use
`0s` for completeness; an office in a tower would benefit from
`30s` or higher.

## What we explicitly do NOT change

- The BLE list / `_devices` map. PENDING identifiers still
appear in the BLE panel (snapshot rendering is unchanged).
Only the events stream is gated.
- The connected-peripheral path. Unchanged.
- The `BLEDeviceLeftEvent` dedup from fix-ble-left-dedup.
Still correct: once seen has fired (post-graduation), the
first left fires on TTL eviction; subsequent re-evictions
are silent.
- The JSONL schema. No new fields, no new types.
- The analyzer. Aggregations consume the same JSONL records;
fewer records = cleaner ranking, but no API change.
Loading
Loading