web.ts (16828B)
1 import { err_msg, type ResolveErrorMsg } from "@radroots/utils"; 2 import { BLE_ERROR, type BleErrorMessage } from "./error.js"; 3 import { ble_message_buffer_source, ble_message_bytes } from "./messages.js"; 4 import type { 5 BleAvailability, 6 BleAvailabilityState, 7 BleConnectOptions, 8 BleDeviceInfo, 9 BleDisconnectHandler, 10 BleMessageHandler, 11 BleMessageInput, 12 BlePermissionState, 13 BleServiceProfile, 14 BleWriteOptions, 15 IBle, 16 IBleSession 17 } from "./types.js"; 18 import { BLE_MESSAGE_PROFILE } from "./types.js"; 19 20 type BlePermissionName = PermissionName | "bluetooth" | "bluetooth-le"; 21 22 type BlePermissionDescriptor = { 23 name: BlePermissionName; 24 }; 25 26 interface PermissionsBle { 27 query(permission_desc: BlePermissionDescriptor): Promise<PermissionStatus>; 28 } 29 30 interface NavigatorWithPermissions extends Navigator { 31 permissions: PermissionsBle; 32 } 33 34 interface BleBluetooth { 35 requestDevice(options: BleRequestDeviceOptions): Promise<BleDevice>; 36 getAvailability?(): Promise<boolean>; 37 } 38 39 interface NavigatorWithBluetooth extends Navigator { 40 bluetooth: BleBluetooth; 41 } 42 43 type BleRequestDeviceFilter = { 44 services?: string[]; 45 namePrefix?: string; 46 }; 47 48 type BleRequestDeviceOptions = { 49 filters?: BleRequestDeviceFilter[]; 50 acceptAllDevices?: boolean; 51 optionalServices?: string[]; 52 }; 53 54 interface BleDevice { 55 id: string; 56 name?: string; 57 gatt?: BleGattServer; 58 addEventListener(type: "gattserverdisconnected", listener: () => void, options?: AddEventListenerOptions): void; 59 removeEventListener(type: "gattserverdisconnected", listener: () => void, options?: EventListenerOptions): void; 60 } 61 62 interface BleGattServer { 63 connect(): Promise<BleGattServer>; 64 disconnect(): void; 65 getPrimaryService(uuid: string): Promise<BleGattService>; 66 connected: boolean; 67 device: BleDevice; 68 } 69 70 interface BleGattService { 71 getCharacteristic(uuid: string): Promise<BleGattCharacteristic>; 72 } 73 74 interface BleGattCharacteristic { 75 startNotifications(): Promise<BleGattCharacteristic>; 76 stopNotifications?(): Promise<void>; 77 writeValue(value: BufferSource): Promise<void>; 78 writeValueWithoutResponse?(value: BufferSource): Promise<void>; 79 value?: DataView | null; 80 addEventListener(type: "characteristicvaluechanged", listener: (event: Event) => void, options?: AddEventListenerOptions): void; 81 removeEventListener(type: "characteristicvaluechanged", listener: (event: Event) => void): void; 82 } 83 84 type BleWaitResult<T> = { value: T } | { timeout: true }; 85 86 const has_permissions_api = (nav: Navigator): nav is NavigatorWithPermissions => "permissions" in nav; 87 88 const has_bluetooth = (nav: Navigator): nav is NavigatorWithBluetooth => "bluetooth" in nav; 89 90 const read_permission_state = async (nav: Navigator): Promise<BlePermissionState> => { 91 if (!has_permissions_api(nav)) return "unknown"; 92 try { 93 const status = await nav.permissions.query({ name: "bluetooth" }); 94 return status.state; 95 } catch { 96 try { 97 const status = await nav.permissions.query({ name: "bluetooth-le" }); 98 return status.state; 99 } catch { 100 return "unknown"; 101 } 102 } 103 }; 104 105 const read_availability = (): BleAvailability => { 106 const window_available = typeof window !== "undefined"; 107 const navigator_available = typeof navigator !== "undefined"; 108 const secure_context = window_available && window.isSecureContext === true; 109 const bluetooth_available = navigator_available && has_bluetooth(navigator); 110 return { 111 supported: window_available && navigator_available && secure_context && bluetooth_available, 112 secure_context, 113 window_available, 114 navigator_available, 115 bluetooth_available 116 }; 117 }; 118 119 const read_adapter_availability = async (bluetooth: BleBluetooth): Promise<boolean | "unknown"> => { 120 if (!bluetooth.getAvailability) return "unknown"; 121 try { 122 return await bluetooth.getAvailability(); 123 } catch { 124 return "unknown"; 125 } 126 }; 127 128 const read_availability_state = async (): Promise<BleAvailabilityState> => { 129 const availability = read_availability(); 130 let adapter_available: boolean | "unknown" = "unknown"; 131 if (typeof navigator !== "undefined" && has_bluetooth(navigator)) { 132 adapter_available = await read_adapter_availability(navigator.bluetooth); 133 } 134 return { ...availability, adapter_available }; 135 }; 136 137 const resolve_request_options = (profile: BleServiceProfile, opts?: BleConnectOptions): BleRequestDeviceOptions => { 138 const optional_services = [profile.service_uuid]; 139 if (opts?.accept_all_devices) { 140 return { 141 acceptAllDevices: true, 142 optionalServices: optional_services 143 }; 144 } 145 const filter: BleRequestDeviceFilter = { 146 services: [profile.service_uuid] 147 }; 148 if (opts?.name_prefix) filter.namePrefix = opts.name_prefix; 149 return { 150 filters: [filter], 151 optionalServices: optional_services 152 }; 153 }; 154 155 const map_ble_error = (err: unknown, fallback: BleErrorMessage): BleErrorMessage => { 156 if (typeof DOMException !== "undefined" && err instanceof DOMException) { 157 if (err.name === "AbortError") return BLE_ERROR.abort; 158 if (err.name === "NotAllowedError") return BLE_ERROR.permission_denied; 159 if (err.name === "NotSupportedError") return BLE_ERROR.unsupported; 160 if (err.name === "SecurityError") return BLE_ERROR.secure_context_required; 161 if (err.name === "NotFoundError") return BLE_ERROR.device_not_found; 162 if (err.name === "NetworkError") return BLE_ERROR.connect_failed; 163 if (err.name === "InvalidStateError") return BLE_ERROR.connect_failed; 164 if (err.name === "NotReadableError") return BLE_ERROR.read_failed; 165 } 166 return fallback; 167 }; 168 169 const link_abort_signal = (controller: AbortController, signal?: AbortSignal): void => { 170 if (!signal) return; 171 if (signal.aborted) { 172 controller.abort(); 173 return; 174 } 175 signal.addEventListener("abort", () => controller.abort(), { once: true }); 176 }; 177 178 const wait_with_timeout = async <T>(promise: Promise<T>, timeout_ms?: number): Promise<BleWaitResult<T>> => { 179 if (!timeout_ms || timeout_ms <= 0) return { value: await promise }; 180 let timeout_id: ReturnType<typeof setTimeout> | undefined; 181 const timeout_promise: Promise<{ timeout: true }> = new Promise(resolve => { 182 timeout_id = setTimeout(() => resolve({ timeout: true }), timeout_ms); 183 }); 184 const guarded = promise.then(value => ({ value })); 185 const result = await Promise.race([guarded, timeout_promise]); 186 if (timeout_id) clearTimeout(timeout_id); 187 if ("timeout" in result) { 188 void guarded.catch(() => undefined); 189 return result; 190 } 191 return result; 192 }; 193 194 const is_timeout = <T>(value: BleWaitResult<T>): value is { timeout: true } => "timeout" in value; 195 196 const is_error = <T>(value: ResolveErrorMsg<T, BleErrorMessage>): value is { err: BleErrorMessage } => { 197 return typeof value === "object" && value !== null && "err" in value; 198 }; 199 200 const build_device_info = (device: BleDevice, gatt: BleGattServer): BleDeviceInfo => { 201 return { 202 id: device.id, 203 name: device.name, 204 connected: gatt.connected 205 }; 206 }; 207 208 class BleSession implements IBleSession { 209 private active = true; 210 private on_message?: BleMessageHandler; 211 private on_disconnect?: BleDisconnectHandler; 212 private readonly notify_handler: (event: Event) => void; 213 private readonly disconnect_handler: () => void; 214 215 private constructor( 216 private readonly device: BleDevice, 217 private readonly gatt: BleGattServer, 218 private readonly write_characteristic: BleGattCharacteristic, 219 private readonly notify_characteristic: BleGattCharacteristic 220 ) { 221 this.notify_handler = () => this.handle_notify(); 222 this.disconnect_handler = () => this.stop_with_reason(BLE_ERROR.disconnected, true); 223 this.device.addEventListener("gattserverdisconnected", this.disconnect_handler); 224 this.notify_characteristic.addEventListener("characteristicvaluechanged", this.notify_handler); 225 } 226 227 public static async create( 228 device: BleDevice, 229 gatt: BleGattServer, 230 write_characteristic: BleGattCharacteristic, 231 notify_characteristic: BleGattCharacteristic, 232 timeout_ms?: number 233 ): Promise<ResolveErrorMsg<BleSession, BleErrorMessage>> { 234 const session = new BleSession(device, gatt, write_characteristic, notify_characteristic); 235 try { 236 const result = await wait_with_timeout(notify_characteristic.startNotifications(), timeout_ms); 237 if (is_timeout(result)) { 238 session.stop_with_reason(BLE_ERROR.timeout, false); 239 return err_msg(BLE_ERROR.timeout); 240 } 241 return session; 242 } catch (e) { 243 session.stop_with_reason(BLE_ERROR.notify_failed, false); 244 return err_msg(map_ble_error(e, BLE_ERROR.notify_failed)); 245 } 246 } 247 248 public get_active(): boolean { 249 return this.active; 250 } 251 252 public get_device_info(): BleDeviceInfo { 253 return build_device_info(this.device, this.gatt); 254 } 255 256 public set_on_message(handler?: BleMessageHandler): void { 257 this.on_message = handler; 258 } 259 260 public set_on_disconnect(handler?: BleDisconnectHandler): void { 261 this.on_disconnect = handler; 262 } 263 264 public async send_message(message: BleMessageInput, opts?: BleWriteOptions): Promise<ResolveErrorMsg<void, BleErrorMessage>> { 265 if (!this.active) return err_msg(BLE_ERROR.disconnected); 266 if (!this.gatt.connected) return err_msg(BLE_ERROR.disconnected); 267 268 const resolved = ble_message_bytes(message); 269 if (is_error(resolved)) return resolved; 270 const payload = ble_message_buffer_source(resolved); 271 272 const controller = new AbortController(); 273 link_abort_signal(controller, opts?.signal); 274 if (controller.signal.aborted) return err_msg(BLE_ERROR.abort); 275 276 const write_action = opts?.without_response && this.write_characteristic.writeValueWithoutResponse 277 ? this.write_characteristic.writeValueWithoutResponse(payload) 278 : this.write_characteristic.writeValue(payload); 279 280 try { 281 const result = await wait_with_timeout(write_action, opts?.timeout_ms); 282 if (is_timeout(result)) return err_msg(BLE_ERROR.timeout); 283 if (controller.signal.aborted) return err_msg(BLE_ERROR.abort); 284 return; 285 } catch (e) { 286 if (controller.signal.aborted) return err_msg(BLE_ERROR.abort); 287 return err_msg(map_ble_error(e, BLE_ERROR.write_failed)); 288 } 289 } 290 291 public async disconnect(): Promise<void> { 292 this.stop_with_reason(BLE_ERROR.disconnected, true); 293 } 294 295 private handle_notify(): void { 296 if (!this.active) return; 297 const value = this.notify_characteristic.value; 298 if (!value) return; 299 const bytes = new Uint8Array(value.buffer, value.byteOffset, value.byteLength); 300 if (this.on_message) this.on_message({ bytes, timestamp_ms: Date.now() }); 301 } 302 303 private stop_with_reason(reason: BleErrorMessage, notify_disconnect: boolean): void { 304 if (!this.active) return; 305 this.active = false; 306 this.device.removeEventListener("gattserverdisconnected", this.disconnect_handler); 307 this.notify_characteristic.removeEventListener("characteristicvaluechanged", this.notify_handler); 308 if (this.notify_characteristic.stopNotifications) void this.notify_characteristic.stopNotifications(); 309 if (this.gatt.connected) this.gatt.disconnect(); 310 if (notify_disconnect && this.on_disconnect) this.on_disconnect(reason); 311 } 312 } 313 314 export interface IWebBle extends IBle { } 315 316 export class WebBle implements IWebBle { 317 public availability(): BleAvailability { 318 return read_availability(); 319 } 320 321 public async availability_state(): Promise<BleAvailabilityState> { 322 return read_availability_state(); 323 } 324 325 public async permission_state(): Promise<BlePermissionState> { 326 if (typeof navigator === "undefined") return "unknown"; 327 return read_permission_state(navigator); 328 } 329 330 public async connect(opts?: BleConnectOptions): Promise<ResolveErrorMsg<IBleSession, BleErrorMessage>> { 331 const availability = read_availability(); 332 if (!availability.window_available) return err_msg(BLE_ERROR.window_undefined); 333 if (!availability.navigator_available) return err_msg(BLE_ERROR.navigator_undefined); 334 if (!availability.secure_context) return err_msg(BLE_ERROR.secure_context_required); 335 if (!availability.bluetooth_available) return err_msg(BLE_ERROR.unsupported); 336 337 if (!has_bluetooth(navigator)) return err_msg(BLE_ERROR.unsupported); 338 const bluetooth = navigator.bluetooth; 339 const adapter_available = await read_adapter_availability(bluetooth); 340 if (adapter_available === false) return err_msg(BLE_ERROR.unavailable); 341 const permission_state = await read_permission_state(navigator); 342 if (permission_state === "denied") return err_msg(BLE_ERROR.permission_denied); 343 344 const controller = new AbortController(); 345 link_abort_signal(controller, opts?.signal); 346 if (controller.signal.aborted) return err_msg(BLE_ERROR.abort); 347 348 const profile = opts?.profile ?? BLE_MESSAGE_PROFILE; 349 const request_options = resolve_request_options(profile, opts); 350 351 let device: BleDevice; 352 try { 353 device = await bluetooth.requestDevice(request_options); 354 } catch (e) { 355 return err_msg(map_ble_error(e, BLE_ERROR.connect_failed)); 356 } 357 358 if (controller.signal.aborted) return err_msg(BLE_ERROR.abort); 359 if (!device.gatt) return err_msg(BLE_ERROR.connect_failed); 360 361 let server: BleGattServer; 362 try { 363 const result = await wait_with_timeout(device.gatt.connect(), opts?.timeout_ms); 364 if (is_timeout(result)) return err_msg(BLE_ERROR.timeout); 365 server = result.value; 366 } catch (e) { 367 return err_msg(map_ble_error(e, BLE_ERROR.connect_failed)); 368 } 369 370 if (controller.signal.aborted) { 371 server.disconnect(); 372 return err_msg(BLE_ERROR.abort); 373 } 374 375 let service: BleGattService; 376 try { 377 const result = await wait_with_timeout(server.getPrimaryService(profile.service_uuid), opts?.timeout_ms); 378 if (is_timeout(result)) { 379 server.disconnect(); 380 return err_msg(BLE_ERROR.timeout); 381 } 382 service = result.value; 383 } catch (e) { 384 server.disconnect(); 385 return err_msg(map_ble_error(e, BLE_ERROR.service_not_found)); 386 } 387 if (controller.signal.aborted) { 388 server.disconnect(); 389 return err_msg(BLE_ERROR.abort); 390 } 391 392 let write_characteristic: BleGattCharacteristic; 393 try { 394 const result = await wait_with_timeout(service.getCharacteristic(profile.client_write_uuid), opts?.timeout_ms); 395 if (is_timeout(result)) { 396 server.disconnect(); 397 return err_msg(BLE_ERROR.timeout); 398 } 399 write_characteristic = result.value; 400 } catch (e) { 401 server.disconnect(); 402 return err_msg(map_ble_error(e, BLE_ERROR.characteristic_not_found)); 403 } 404 if (controller.signal.aborted) { 405 server.disconnect(); 406 return err_msg(BLE_ERROR.abort); 407 } 408 409 let notify_characteristic: BleGattCharacteristic; 410 try { 411 const result = await wait_with_timeout(service.getCharacteristic(profile.client_notify_uuid), opts?.timeout_ms); 412 if (is_timeout(result)) { 413 server.disconnect(); 414 return err_msg(BLE_ERROR.timeout); 415 } 416 notify_characteristic = result.value; 417 } catch (e) { 418 server.disconnect(); 419 return err_msg(map_ble_error(e, BLE_ERROR.characteristic_not_found)); 420 } 421 if (controller.signal.aborted) { 422 server.disconnect(); 423 return err_msg(BLE_ERROR.abort); 424 } 425 426 const session = await BleSession.create( 427 device, 428 server, 429 write_characteristic, 430 notify_characteristic, 431 opts?.timeout_ms 432 ); 433 if (is_error(session)) { 434 server.disconnect(); 435 return session; 436 } 437 if (controller.signal.aborted) { 438 await session.disconnect(); 439 return err_msg(BLE_ERROR.abort); 440 } 441 return session; 442 } 443 }