feat: bluetooth and magnetometer channels — two more real instruments

Both measure genuine physics rather than dressing up a random number.

Bluetooth (lib/bluetooth.ts): BLE advertises in the 2.4GHz ISM band, and
the human body is mostly water, which absorbs 2.4GHz strongly — the same
physics that makes a microwave work and that degrades your wifi when
someone stands between you and the router. So RSSI genuinely drops when a
body crosses the path. That makes signal-strength variance a real,
physically-grounded movement signal. The module reports exactly that and
nothing more: it never claims a drop *is* a presence, only that the field
changed. Threshold is 6dB — above the 2-4dB of idle multipath wander, and
inside the 3-10dB a real body actually causes.

Magnetometer (lib/magnetometer.ts): the existing EMF mode infers field
disturbance from DeviceMotion/DeviceOrientation, which is a real
measurement but measures *movement*, not magnetism — a phone sitting still
beside a running motor reads nothing. This reads the actual magnetometer,
so the EMF meter measures what an EMF meter is supposed to. Real
ghost-hunting EMF meters are just magnetometers, and the spikes they pick
up come from mains wiring, motors and moving ferrous mass — all of which
this picks up, for the same real reasons. 3uT threshold clears the ~0.5-1uT
sensor noise while still catching household sources. Earth's constant
25-65uT background is explicitly what the rolling baseline exists to
subtract.

Both are additive: neither replaces the existing motion-based EMF, which
stays the fallback because it works on iOS where neither of these do
(no Web Bluetooth, no Generic Sensor API in any iOS browser). Both reuse
the time-aware EMA baseline shape from coldSpot.ts, since advertisement
and sensor intervals are irregular and a fixed per-sample alpha would
weight a burst and a long gap identically.

Web Bluetooth types are declared locally rather than pulling in
@types/web-bluetooth for three shapes — same approach lib/emf.ts already
takes with its non-standard sensor types.

Also renders unprompted 'manifest' utterances as an intrusion: violet edge,
full opacity (unlike the faded ambient/fragment murmurs), and a brief
blur-in. The unsettling part is that it is perfectly clear and completely
unbidden.

355 frontend tests pass (26 new); i18n parity gate passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Indiana
2026-07-28 03:46:48 +00:00
parent bbcbaa0a36
commit 30694a954a
7 changed files with 944 additions and 1 deletions

View File

@@ -0,0 +1,249 @@
// 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
}
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 mean: number | null = null
private lastAt: number | null = null
private samples = 0
get baseline(): number | null {
return this.mean
}
get warm(): boolean {
return this.samples >= MIN_SAMPLES
}
/**
* Fold one reading in. Returns a disturbance when the deviation clears
* the threshold and the baseline is warm, else null. Non-finite input is
* a no-op rather than a crash or a poisoned baseline.
*/
push(rssi: number, atMs: number): BleDisturbance | null {
if (!Number.isFinite(rssi) || !Number.isFinite(atMs)) return null
if (this.mean === null) {
this.mean = rssi
this.lastAt = atMs
this.samples = 1
return null
}
const deviation = rssi - this.mean
const warm = this.warm
const dt = this.lastAt === null ? 0 : atMs - this.lastAt
const alpha = dt > 0 ? 1 - Math.exp(-dt / BASELINE_TAU_MS) : 0.15
this.mean = this.mean + alpha * deviation
this.lastAt = atMs
this.samples++
if (!warm || Math.abs(deviation) < DISTURBANCE_DB) return null
return {
deviation,
severity: Math.min(1, Math.abs(deviation) / (DISTURBANCE_DB * 3)),
rssi,
at: atMs,
}
}
reset(): void {
this.mean = null
this.lastAt = null
this.samples = 0
}
}
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()
}
}