Skip to content

Commit 0b3498d

Browse files
chenchaoyiclaude
andauthored
chore(archive): apply add-ble-presence-gate spec delta (#112)
Applies the MODIFIED transition-events requirement from add-ble-presence-gate (#111) into canonical openspec/specs/bluetooth-scanning/spec.md. The "No debounce SHALL be applied" sentence is replaced with the PENDING / PRESENT / DEPARTED gated semantics + named-bypass + connected-bypass rules; three new scenarios cover the gate paths (anonymous-below-gate-is-silent, graduates-after-gate, gate-zero-restores-no-debounce). Moves the change dir to openspec/changes/archive/2026-05-21-add-ble-presence-gate/. All artifacts done, all tasks complete, validate --specs --strict green (21/21). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 4f3f63a commit 0b3498d

5 files changed

Lines changed: 29 additions & 8 deletions

File tree

openspec/changes/add-ble-presence-gate/design.md renamed to openspec/changes/archive/2026-05-21-add-ble-presence-gate/design.md

File renamed without changes.

openspec/changes/add-ble-presence-gate/proposal.md renamed to openspec/changes/archive/2026-05-21-add-ble-presence-gate/proposal.md

File renamed without changes.

openspec/changes/add-ble-presence-gate/specs/bluetooth-scanning/spec.md renamed to openspec/changes/archive/2026-05-21-add-ble-presence-gate/specs/bluetooth-scanning/spec.md

File renamed without changes.

openspec/changes/add-ble-presence-gate/tasks.md renamed to openspec/changes/archive/2026-05-21-add-ble-presence-gate/tasks.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,5 +54,5 @@
5454

5555
## 8. Merge + archive
5656

57-
- [ ] PR open, reviewed, merged
58-
- [ ] `/opsx:archive add-ble-presence-gate`
57+
- [x] PR open, reviewed, merged (#111)
58+
- [x] `/opsx:archive add-ble-presence-gate`

openspec/specs/bluetooth-scanning/spec.md

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -154,20 +154,41 @@ The exclusion is implemented via the `category_only=True` flag on `service_categ
154154
- **AND** the BLE detail modal SHALL show `Device Information` in the Services section
155155

156156
### Requirement: `BLEPoller` SHALL emit transition events when devices enter and leave its tracked state
157-
`BLEPoller` SHALL emit `BLEDeviceSeenEvent` the first time a device's `identifier` (the rotation-folded stable id) appears in its tracked state map. `BLEPoller` SHALL emit `BLEDeviceLeftEvent` when a tracked device's `last_seen` falls more than the existing TTL behind the latest snapshot AND the device is then removed from state.
157+
`BLEPoller` SHALL emit `BLEDeviceSeenEvent` when a device's `identifier` graduates from PENDING to PRESENT in its tracked state map. Graduation happens via one of two paths:
158158

159-
No debounce SHALL be applied — every first-seen identifier generates exactly one `BLEDeviceSeenEvent`, even for short-lived ghost MACs that disappear after a single advertisement. Subsequent observations of the same identifier in the same session SHALL NOT re-fire `BLEDeviceSeenEvent`.
159+
- **Bypass path** — the identifier's first observation carries a non-null `name` OR the identifier comes from the `_connected` snapshot. Graduates to PRESENT on the same tick, `BLEDeviceSeenEvent` fires with the original `first_seen` timestamp.
160+
- **Gated path** — the identifier's first observation is anonymous (no helper-given `name`, only `vendor` + RSSI). The identifier enters PENDING with a stored `first_seen` timestamp. On each subsequent tick, the poller checks whether `(now - first_seen) >= presence_gate_s`. When that elapses AND the identifier is still in `_devices`, the identifier graduates to PRESENT and `BLEDeviceSeenEvent` fires with `timestamp = first_seen` (NOT the wall-clock graduation time).
161+
162+
`presence_gate_s` is configurable via `BLEPoller(presence_gate_s=...)` and defaults to **5.0** seconds. A value of `0.0` restores the pre-gate "every first-seen identifier fires seen on its first observation" behaviour, including for anonymous adverts; in that case PENDING is bypassed entirely.
163+
164+
`BLEPoller` SHALL emit `BLEDeviceLeftEvent` when a PRESENT device's `last_seen` falls more than the existing TTL behind the latest snapshot AND the device is then removed from state.
165+
166+
If a PENDING identifier is evicted from `_devices` (TTL elapses) before its presence-gate matures, the poller SHALL emit NO transition events for it — no seen, no left. The identifier returns to INIT silently; a future re-appearance from the same identifier opens a fresh PENDING window.
167+
168+
Subsequent observations of the same identifier in the same session SHALL NOT re-fire `BLEDeviceSeenEvent`.
160169

161170
After a `BLEDeviceLeftEvent` has fired for a given identifier within a session, the poller SHALL emit no further transition events for that identifier in the same session — neither another `BLEDeviceLeftEvent` if the identifier flaps back into `_devices` and is evicted again, nor a fresh `BLEDeviceSeenEvent` if a new advertisement re-introduces it. The identifier is terminal-departed for the rest of the session.
162171

163172
The `BLEPoller.events()` async iterator's union return type SHALL include `BLEDeviceSeenEvent` and `BLEDeviceLeftEvent` alongside the existing `BLEScanUpdate`.
164173

165-
#### Scenario: First advertisement from a new MAC fires seen
166-
- **WHEN** an advertisement parses into a BLEDevice whose `identifier` is not in `_state`
167-
- **THEN** `BLEDeviceSeenEvent` is yielded; on the next observation of the same identifier no further seen event is emitted
174+
#### Scenario: Named first advert bypasses the presence gate
175+
- **WHEN** an advertisement parses into a BLEDevice with `name = "Magic Keyboard"`, `vendor = "Apple, Inc."`, `identifier` not in `_state`
176+
- **THEN** `BLEDeviceSeenEvent` is yielded on the same `_detect_transitions` tick; the identifier moves directly to PRESENT without entering PENDING
177+
178+
#### Scenario: Anonymous first advert below the gate is silent
179+
- **WHEN** an anonymous advertisement (no `name`, only `vendor`) populates `_devices[ident]` at t=0 with default `presence_gate_s = 5.0`, AND the identifier ages out via TTL at t=4
180+
- **THEN** no `BLEDeviceSeenEvent` is yielded; no `BLEDeviceLeftEvent` is yielded; the identifier leaves `_pending_seen` silently
181+
182+
#### Scenario: Anonymous first advert graduates after the gate elapses
183+
- **WHEN** an anonymous advertisement populates `_devices[ident]` at t=0 with `first_seen = t=0` and `presence_gate_s = 5.0`, AND the device is still in `_devices` at t=5.1 (subsequent adverts kept `last_seen` recent)
184+
- **THEN** `BLEDeviceSeenEvent` is yielded with `timestamp = t=0` (the original first_seen, NOT wall-clock at graduation); the identifier moves from PENDING to PRESENT
185+
186+
#### Scenario: `presence_gate_s = 0` restores no-debounce
187+
- **WHEN** `BLEPoller(presence_gate_s=0.0)` is constructed AND an anonymous advertisement populates `_devices[ident]` for the first time
188+
- **THEN** `BLEDeviceSeenEvent` is yielded on the same tick, with no PENDING state entered
168189

169190
#### Scenario: TTL eviction fires left
170-
- **WHEN** a tracked device's `last_seen` exceeds the BLE TTL relative to the latest snapshot's `now`
191+
- **WHEN** a tracked device's `last_seen` exceeds the BLE TTL relative to the latest snapshot's `now` AND the identifier had previously graduated to PRESENT
171192
- **THEN** `BLEDeviceLeftEvent` is yielded with `seen_for_seconds = last_seen - first_seen`; the entry is removed from `_state`
172193

173194
#### Scenario: Repeated TTL eviction of the same identifier is silent

0 commit comments

Comments
 (0)