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>
233 lines
7.9 KiB
TypeScript
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()
|
|
}
|
|
}
|