// Web Bluetooth as a presence channel — real RF, real physics. // // Why Bluetooth is a legitimate instrument here rather than set dressing: // BLE advertises in the 2.4GHz ISM band, and the human body is mostly // water, which absorbs 2.4GHz strongly. That is not folklore — it is the // same physics that makes a microwave oven work, and the reason your wifi // gets worse when someone stands between you and the router. So the RSSI // of a nearby beacon genuinely drops when a body moves into the path, and // genuinely fluctuates as things move around the room. // // That makes signal-strength variance a real, physically-grounded // proximity/movement signal. We report exactly that and nothing more: this // module never claims a drop in RSSI *is* a presence, only that the field // changed. The interpretation belongs to the fiction upstairs; the // measurement down here stays honest. // // Platform reality: Web Bluetooth exists in Chromium (desktop + Android). // It does NOT exist in any iOS browser — Apple does not ship the API and // every iOS browser is WebKit underneath. `isSupported()` reflects that // rather than pretending otherwise. // Minimal local declarations for the Web Bluetooth surface we touch. // TypeScript's DOM lib doesn't ship these, and pulling in // @types/web-bluetooth for three shapes isn't worth the dependency — // lib/emf.ts already handles its own non-standard sensor types the same // way. Only what this module actually calls is declared, so the compiler // still catches a typo in any of it. type BluetoothDeviceLike = EventTarget & { readonly name?: string watchAdvertisements?: (opts?: { signal?: AbortSignal }) => Promise } type BluetoothLike = { requestDevice(options: { acceptAllDevices?: boolean optionalServices?: string[] }): Promise } /** An `advertisementreceived` event; `rssi` is optional because the spec * allows platforms to omit it. */ type AdvertisementEvent = Event & { rssi?: number } function bluetoothApi(): BluetoothLike | null { const nav = navigator as Navigator & { bluetooth?: BluetoothLike } return nav.bluetooth ?? null } import { ThresholdBaseline } from './baseline' export type BleReading = { /** Received signal strength, dBm. Typically -30 (touching) to -100 (far). */ rssi: number /** Milliseconds since epoch. */ at: number } export type BleDisturbance = { /** How far this reading deviated from the rolling baseline, in dB. */ deviation: number /** Absolute deviation normalised 0..1 against DISTURBANCE_DB * 3. */ severity: number rssi: number at: number } /** * dB of deviation from baseline before a change counts as a disturbance. * * Reasoning: BLE RSSI on a stationary link typically wanders +-2-4dB from * multipath and receiver noise alone. A human body crossing the path * attenuates 2.4GHz by roughly 3-10dB depending on geometry. 6dB sits * above the idle noise band but inside what a real body actually causes, * so it fires for movement without firing constantly for nothing. */ export const DISTURBANCE_DB = 6 /** EMA time constant for the baseline, ms. Long enough that a body walking * through registers as a deviation rather than being absorbed; short * enough that genuinely moving the phone to a new spot re-baselines within * a few seconds instead of screaming for a minute. */ export const BASELINE_TAU_MS = 8000 /** Readings folded in before deviations are trusted — the baseline needs * to mean something before a difference from it does. */ export const MIN_SAMPLES = 4 export function isSupported(): boolean { return typeof navigator !== 'undefined' && typeof bluetoothApi()?.requestDevice === 'function' } /** * Rolling baseline over RSSI, with the same time-aware EMA shape used by * coldSpot.ts — advertisement intervals are irregular (a beacon may * advertise every 100ms or every 2s, and the OS coalesces), so a * fixed-per-sample alpha would weight a burst and a long gap identically. * * Pure and separately testable: no Bluetooth objects appear in here. */ export class BleFieldCore { private readonly core = new ThresholdBaseline({ threshold: DISTURBANCE_DB, tauMs: BASELINE_TAU_MS, minSamples: MIN_SAMPLES, }) get baseline(): number | null { return this.core.baseline } get warm(): boolean { return this.core.warm } /** Fold one reading in; see ThresholdBaseline.push. Returns a * BLE-flavoured disturbance so callers keep reading `.rssi` rather than a * generic `.value`. */ push(rssi: number, atMs: number): BleDisturbance | null { const event = this.core.push(rssi, atMs) if (!event) return null return { deviation: event.deviation, severity: event.severity, rssi: event.value, at: event.at, } } reset(): void { this.core.reset() } } export type BleWatchCallbacks = { onReading?: (r: BleReading) => void onDisturbance?: (d: BleDisturbance) => void onError?: (err: Error) => void /** Fired when the device disconnects or the browser stops advertising * updates, so the UI can stop claiming a live link. */ onLost?: () => void } /** * Watches one user-chosen BLE device's signal strength. * * Requires an explicit device pick (browsers mandate a user gesture and a * chooser — there is deliberately no way to silently scan, which is a * privacy protection, not a limitation to work around). */ export class BleWatcher { private device: BluetoothDeviceLike | null = null private core = new BleFieldCore() private watching = false private abort: AbortController | null = null get isWatching(): boolean { return this.watching } get deviceName(): string | null { return this.device?.name ?? null } /** Opens the browser's device chooser. Throws if the seeker cancels. */ async requestDevice(): Promise { if (!isSupported()) { throw new Error('this vessel has no bluetooth sense') } const bt = bluetoothApi() if (!bt) throw new Error('this vessel has no bluetooth sense') // acceptAllDevices because we don't care what it is — any radio in the // room is a field to measure. No services are requested, so this grants // the minimum possible access: we never connect to read characteristics. this.device = await bt.requestDevice({ acceptAllDevices: true, optionalServices: [], }) } /** * Begin watching advertisements for RSSI. * * `watchAdvertisements()` is the only route to live RSSI without opening * a GATT connection, and it is still gated behind a flag in some Chrome * builds — so its absence is reported as a clear, actionable error * rather than a silent no-op that looks like a dead sensor. */ async start(cb: BleWatchCallbacks): Promise { const device = this.device if (!device) throw new Error('no device chosen') if (this.watching) return if (typeof device.watchAdvertisements !== 'function') { throw new Error( 'this browser cannot listen for bluetooth advertisements — ' + 'enable chrome://flags/#enable-experimental-web-platform-features', ) } this.core.reset() const abort = new AbortController() this.abort = abort device.addEventListener('advertisementreceived', ((event: Event) => { const adv = event as AdvertisementEvent if (typeof adv.rssi !== 'number') return const at = Date.now() cb.onReading?.({ rssi: adv.rssi, at }) const disturbance = this.core.push(adv.rssi, at) if (disturbance) cb.onDisturbance?.(disturbance) }) as EventListener, { signal: abort.signal }) device.addEventListener('gattserverdisconnected', () => cb.onLost?.(), { signal: abort.signal, }) try { await device.watchAdvertisements({ signal: abort.signal }) this.watching = true } catch (err) { this.abort = null throw err instanceof Error ? err : new Error(String(err)) } } stop(): void { this.abort?.abort() this.abort = null this.watching = false this.core.reset() } }