Files
qtalker---/frontend/src/lib/bluetooth.ts
Indiana 12e2d36527 refactor: extract the shared rolling-baseline core
BleFieldCore and MagFieldCore had converged on the same class: same
time-aware EMA, same warm-up gate, same "deviation past a threshold is an
event" shape, differing only in constants and field names. Three copies of
the alpha derivation existed across the sensor libs.

lib/baseline.ts now owns it. Both wrappers keep their own public types
(`.rssi`, `.magnitude`) so nothing downstream changed — which is what let
all 26 existing tests pass completely unmodified against the refactor.
That was the point of doing it this way: if the tests had needed editing,
the refactor would have been changing behaviour rather than removing
duplication.

coldSpot.ts deliberately does NOT adopt this. It threads immutable state
through pure functions so a whole session's narrative can be replayed in a
test without a clock — a different and equally valid shape. Collapsing the
two would force one into a style that doesn't fit it, which is how
deduplication turns into damage.

355 frontend tests pass unchanged; i18n parity gate passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 13:36:24 +00:00

233 lines
7.9 KiB
TypeScript

// 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<void>
}
type BluetoothLike = {
requestDevice(options: {
acceptAllDevices?: boolean
optionalServices?: string[]
}): Promise<BluetoothDeviceLike>
}
/** 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<void> {
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<void> {
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()
}
}