// src/backend-contract/advertisement.ts import { contractError } from './errors' import { canonicalBleAddress } from './primitives' import type { BackendInstanceId, BorrowedBytes, Capacity, Deadline, LeaseId, MonotonicTimestamp, OwnedBytes, PeerId, ScanSessionId, ScanShareToken, Uuid } from './primitives' import type { PeerReference } from './peer-reference' import type { ScanPlan } from './scan-planning' import type { NormalizedScanQuery } from './scan-query' /** How the backend obtained this observation, independent of individual field provenance. */ export type ObservationSource = 'platform-raw' | 'platform-derived' | 'core-merged' export type FieldProvenance = 'observed' | 'derived' | 'synthesized' | 'not-provided' export interface PresentField { readonly state: 'present' readonly value: Value readonly provenance: Exclude } export interface AbsentField { readonly state: 'absent' | 'unavailable' readonly reason: string readonly provenance: FieldProvenance } export type AdvertisementField = PresentField | AbsentField /** Addresses are optional because several platforms expose only opaque device identities. */ export interface DeviceAddress { readonly value: string readonly type: 'public' | 'random' | 'opaque' } /** * Backend-scoped observation identity. It deliberately does not claim that a privacy-rotating * address or host-local ID identifies a physical device across backend lifetimes. */ export interface DeviceIdentity { readonly id: PeerId readonly backendInstanceId: BackendInstanceId readonly scope: 'session' | 'application' | 'backend' readonly stableAcrossRestarts: boolean | null readonly address: DeviceAddress | null } /** Creates an immutable backend-scoped identity without making a global-physical-ID claim. */ export function deviceIdentity( id: PeerId, backendInstanceId: BackendInstanceId, address: DeviceAddress | null ): DeviceIdentity { return Object.freeze({ id, backendInstanceId, scope: 'backend', stableAcrossRestarts: false, address: address === null ? null : Object.freeze({ value: address.value, type: address.type }) }) } /** Monotonic source time and its clock provenance; receipt time remains separate on the observation. */ export interface SourceTimestamp { readonly monotonicMs: MonotonicTimestamp readonly origin: 'platform' | 'backend' } export interface ServiceDataEntry { readonly serviceUuid: Uuid readonly value: OwnedBytes } export interface ManufacturerData { readonly companyIdentifier: number readonly value: OwnedBytes } export interface AdvertisementObservation { readonly device: DeviceIdentity /** Present only when the instantiated backend can issue a truthful scoped reference. */ readonly peerReference?: PeerReference readonly provenance: ObservationSource readonly sourceTimestamp: AdvertisementField readonly receivedAtMonotonicMs: MonotonicTimestamp readonly ingressOrdinal: number readonly scanSessionId: ScanSessionId readonly localName: AdvertisementField readonly rssi: AdvertisementField readonly txPower: AdvertisementField readonly connectable: AdvertisementField readonly appearance: AdvertisementField readonly serviceUuids: AdvertisementField readonly solicitedServiceUuids: AdvertisementField readonly overflowServiceUuids: AdvertisementField readonly serviceData: AdvertisementField readonly manufacturerData: AdvertisementField readonly rawRecord: AdvertisementField readonly scanResponseRecord: AdvertisementField } export interface ManufacturerDataFilter { readonly companyIdentifier: number /** Null matches every payload for the company; a present prefix is matched byte-for-byte. */ readonly dataPrefix: Readonly | null } export interface ScanFilter { readonly serviceUuids: readonly Uuid[] readonly manufacturerData: readonly ManufacturerDataFilter[] readonly localNamePrefix: string | null readonly deviceAddresses?: readonly string[] } export interface OwnerScanSharing { readonly mode: 'owner' readonly allowSharing: boolean } export interface JoinScanSharing { readonly mode: 'join' readonly sharedLeaseId: LeaseId readonly token: ScanShareToken } export type ScanSharing = | OwnerScanSharing | JoinScanSharing export interface ScanOptions { readonly query?: NormalizedScanQuery readonly plan?: ScanPlan readonly filter: ScanFilter readonly duplicatePolicy: 'all' | 'first' | 'merged' readonly timestampPolicy: 'receipt-monotonic' | 'source-then-receipt' readonly delivery: { readonly itemCapacity: Capacity readonly byteCapacity: Capacity readonly reservedControlCapacity: Capacity readonly overflowPolicy: import('./streams').OverflowPolicy } readonly deadline: Deadline | null readonly signal: AbortSignal | null readonly sharing: ScanSharing readonly platform?: { readonly kind: 'android' | 'corebluetooth' | 'winrt' | 'web' | 'electron' | 'tauri' readonly mode?: 'low-power' | 'balanced' | 'low-latency' | 'opportunistic' readonly callbackType?: 'all-matches' | 'first-match' | 'match-lost' readonly reportDelayMs?: number readonly legacy?: boolean readonly phy?: 'all-supported' | '1m' | 'coded' } } export type OwnerScanOptions = Omit< ScanOptions, 'sharing' > & { readonly sharing: OwnerScanSharing } export interface AdvertisementInput { readonly bytes: BorrowedBytes } /** Rejects malformed manufacturer criteria before radio work begins. */ export function assertScanFilter(filter: ScanFilter, operation: string): void { if (filter.localNamePrefix !== null && filter.localNamePrefix.length === 0) { throw contractError('scan.filter-invalid', 'scan', operation) } if (filter.deviceAddresses !== undefined) { if (filter.deviceAddresses.length === 0) { throw contractError('scan.filter-invalid', 'scan', operation) } for (const address of filter.deviceAddresses) { try { canonicalBleAddress(address) } catch { throw contractError('scan.filter-invalid', 'scan', operation) } } } for (const manufacturer of filter.manufacturerData) { if ( !Number.isSafeInteger(manufacturer.companyIdentifier) || manufacturer.companyIdentifier < 0 || manufacturer.companyIdentifier > 0xffff ) { throw contractError('scan.filter-invalid', 'scan', operation) } } } /** One canonical software predicate used whenever a platform cannot install all filters natively. */ export function advertisementMatchesFilter( filter: ScanFilter, observation: AdvertisementObservation ): boolean { assertScanFilter(filter, 'advertisement.matches-filter') if ( filter.localNamePrefix !== null && (observation.localName.state !== 'present' || !observation.localName.value.startsWith(filter.localNamePrefix)) ) { return false } if (filter.deviceAddresses !== undefined && filter.deviceAddresses.length > 0) { const observedAddress = observation.device.address if (observedAddress === null || !filter.deviceAddresses.includes(observedAddress.value)) { return false } } if (filter.serviceUuids.length > 0) { const observedServices = observation.serviceUuids if ( observedServices.state !== 'present' || !filter.serviceUuids.every(uuid => observedServices.value.includes(uuid)) ) { return false } } if (filter.manufacturerData.length === 0) { return true } const observedManufacturerData = observation.manufacturerData if (observedManufacturerData.state !== 'present') { return false } return filter.manufacturerData.every(filterEntry => observedManufacturerData.value.some( entry => entry.companyIdentifier === filterEntry.companyIdentifier && (filterEntry.dataPrefix === null || hasBytePrefix(entry.value, filterEntry.dataPrefix)) ) ) } function hasBytePrefix(value: Readonly, prefix: Readonly): boolean { if (prefix.byteLength > value.byteLength) { return false } for (let index = 0; index < prefix.byteLength; index += 1) { if (value[index] !== prefix[index]) { return false } } return true }