UNPKG

lavva.webbluetooth

Version:

Library implementing WebBluetooth custom functionality if underlying platform does support it

586 lines 27.8 kB
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } return new (P || (P = Promise))(function (resolve, reject) { function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } step((generator = generator.apply(thisArg, _arguments || [])).next()); }); }; import { NativeTypedEvent } from "./ExtendedWebBluetooth"; import { Task } from "./Task"; /* "Lavva-Double" "Lavva-Gate" "Lavva-Led" "Lavva-Power" "Lavva-Switch16A" "Lavva-Plug" "Lavva-Therm" "Lavva-Blind" "Lavva-Switch" "smartAWSC" "smartAW-Multi" "smartAW-Gate" "smartAW-Led" "Lavva-Eko-Oze-Pv" */ export class LavvaBluetooth { constructor() { this.receiveBuffer = ""; this._maxCharacteristicValueLength = 244; // Default MTU size for Bluetooth Low Energy this.serviceUuid = 0xffe0; this.characteristicUuid = 0xffe1; this.receiveSeparator = "\n"; this.sendSeparator = "\n"; // Per-instance characteristic this.characteristic = null; this.notificationsStarted = false; this.pendingResponses = []; // Events this.OnGattServiceDisconnectedEvent = new NativeTypedEvent("OnGattServiceDisconnectedEvent"); this.OnCharacteristicValueChangedEvent = new NativeTypedEvent("OnCharacteristicValueChangedEvent"); this.OnConnectedEvent = new NativeTypedEvent("OnConnectedEvent"); this.OnDisconnectedEvent = new NativeTypedEvent("OnDisconnectedEvent"); this.filter = [ { namePrefix: "Lavva-", services: [this.serviceUuid] }, { namePrefix: "smartAW", services: [this.serviceUuid] }, ]; // ------------------------- utils ------------------------- this.WHITESPACE_NAMES = { 0x20: "SPACE", 0x09: "TAB", 0x0a: "LF", 0x0d: "CR", 0x0b: "VT", 0x0c: "FF", 0xa0: "NBSP", 0x2028: "LINE SEPARATOR", 0x2029: "PARA SEPARATOR", }; // ------------------------- notifications ------------------------- this.handleCharacteristicValueChanged = (event) => { var _a, _b; try { if (!this.characteristic) { console.warn("handleCharacteristicValueChanged: this.characteristic is null, ignoring"); return; } const characteristic = event.target; // hard guard: ignore events from other characteristics (important with multiple instances) if (characteristic !== this.characteristic) { console.warn(`handleCharacteristicValueChanged: event from foreign characteristic ${characteristic.uuid}, expected ${this.characteristic.uuid}`); return; } // optional safety check (uuid compare) if (!this.isExpectedCharacteristic(characteristic)) { return; } const chunk = new TextDecoder().decode(characteristic.value); console.warn(this.visualizeTrailingWhitespace(`Received characteristic value change: ${chunk}`)); this.receiveBuffer += chunk; const parts = this.receiveBuffer.split(this.receiveSeparator); this.receiveBuffer = (_a = parts.pop()) !== null && _a !== void 0 ? _a : ""; for (const part of parts) { const data = this.normalizeFrame(part); if (data) { console.warn(`Received data: ${data}`); this.dispatchPendingResponse(data); (_b = this.OnCharacteristicValueChangedEvent) === null || _b === void 0 ? void 0 : _b.Invoke(data); } } } catch (error) { console.error("Error handling characteristic value changed:", error); } }; LavvaBluetooth.instances.add(this); } /** Call when instance is no longer needed (prevents registry leak). */ Dispose() { LavvaBluetooth.instances.delete(this); } visualizeTrailingWhitespace(str) { const trailing = str.match(/\s+$/u); if (!trailing) return str; return (str.slice(0, -trailing[0].length) + [...trailing[0]] .map((ch) => { switch (ch) { case "\u0020": return "·"; // space case "\u0009": return "→"; // tab case "\u000D": return "␍"; // CR case "\u000A": return "␊"; // LF case "\u00A0": return "⍽"; // NBSP default: return "□"; } }) .join("")); } logTrailingWhitespaceDetail(str) { const trailing = str.match(/\s+$/u); if (!trailing) { console.log("🚫 Brak białych znaków na końcu łańcucha."); return; } [...trailing[0]].forEach((ch, idx) => { var _a; const code = ch.codePointAt(0); const name = (_a = this.WHITESPACE_NAMES[code]) !== null && _a !== void 0 ? _a : "UNKNOWN"; console.log(`${idx}: U+${code.toString(16).toUpperCase().padStart(4, "0")} (${code}) — ${name}`); }); } updateMaxPayload(mtu) { const len = Math.min(Math.max(mtu - 3, 20), 244); this._maxCharacteristicValueLength = len; } SetFilter(filters) { this.filter = filters; } GetConnectedDevice() { return LavvaBluetooth.selectedDevice; } // UUID compare: expected number -> canonical 128-bit string (BLE base UUID) expectedUuidFrom16Bit(num) { const expectedHex = num.toString(16).padStart(4, "0").toLowerCase(); return `0000${expectedHex}-0000-1000-8000-00805f9b34fb`; } isExpectedCharacteristic(c) { const expected = this.expectedUuidFrom16Bit(this.characteristicUuid); const actual = c.uuid.toLowerCase(); const isMatch = actual === expected; console.warn(`UUID compare: actual=${actual} expected=${expected} rawExpected=${this.characteristicUuid} => ${isMatch}`); return isMatch; } // ------------------------- connect ------------------------- /** * Connects to device + primary service (shared statically). * DOES NOT automatically connect this instance to a different characteristic than its default (0xffe1), * but will connect this instance to its current characteristicUuid. */ RequestDeviceAndConnectAsync() { return __awaiter(this, void 0, void 0, function* () { var _a, _b; if (LavvaBluetooth.selectedDevice !== null) { return DeviceConnectionError.AnotherDeviceIsAlreadyConnected; } let dev = null; try { console.log(`Requesting Bluetooth Device, filter: ${JSON.stringify(this.filter)}`); dev = yield navigator.bluetooth.requestDevice({ filters: this.filter }); if (!dev) { console.warn("No device selected"); return DeviceConnectionError.NoDeviceHasBeenSelected; } } catch (error) { console.error("Error requesting device: ", error); return DeviceConnectionError.NoDeviceHasBeenSelected; } try { console.log(`Selected device: ${dev.name} with ID: ${dev.id}`); console.warn(`Connecting to GATT Server of device: ${dev.name}`); LavvaBluetooth.selectedDevice = dev; // install global device disconnect handler once if (!LavvaBluetooth.globalGattDisconnectedHandlerInstalled) { dev.addEventListener("gattserverdisconnected", LavvaBluetooth.globalGattDisconnectedHandler); LavvaBluetooth.globalGattDisconnectedHandlerInstalled = true; } LavvaBluetooth.gattServer = yield ((_a = dev.gatt) === null || _a === void 0 ? void 0 : _a.connect()); if (!LavvaBluetooth.gattServer) { console.error("Failed to connect to GATT Server"); return DeviceConnectionError.FailedToConnectToGattServer; } } catch (error) { console.error("Error connecting to GATT Server: ", error); return DeviceConnectionError.FailedToConnectToGattServer; } try { console.warn(`Getting primary service: ${this.serviceUuid}`); LavvaBluetooth.gattService = yield LavvaBluetooth.gattServer.getPrimaryService(this.serviceUuid); if (!LavvaBluetooth.gattService) { console.error("Failed to get primary service"); return DeviceConnectionError.FailedToGetPrimaryService; } } catch (error) { console.error("Error getting primary service:", error); return DeviceConnectionError.FailedToGetPrimaryService; } // Optional: attach this instance to its characteristicUuid (default 0xffe1) const attachRes = yield this.AttachToCharacteristicInternal(this.characteristicUuid); if (typeof attachRes === "number") { return attachRes; } console.warn("Connected to device successfully!"); if (((_b = LavvaBluetooth.gattServer) === null || _b === void 0 ? void 0 : _b.mtu) !== undefined) { this.updateMaxPayload(LavvaBluetooth.gattServer.mtu); console.warn(`MTU: ${LavvaBluetooth.gattServer.mtu}`); } this.OnConnectedEvent.Invoke(dev); return dev; }); } /** * Connects THIS instance to a given characteristic (notifications + listener). * Use separate instance per characteristic. */ ConnectToGattCharacteristicAsync(characteristicUuid) { return __awaiter(this, void 0, void 0, function* () { var _a; if (this.characteristic) { console.warn("Already connected to a characteristic in this instance. Use a new instance."); return DeviceConnectionError.AnotherDeviceIsAlreadyConnected; } if (!LavvaBluetooth.selectedDevice) { console.warn("No device selected/connected"); return DeviceConnectionError.NoDeviceHasBeenSelected; } if (!LavvaBluetooth.gattServer) { console.error("No GATT server"); return DeviceConnectionError.FailedToConnectToGattServer; } if (!LavvaBluetooth.gattService) { console.error("No primary service"); return DeviceConnectionError.FailedToGetPrimaryService; } const attachRes = yield this.AttachToCharacteristicInternal(characteristicUuid); if (typeof attachRes === "number") { return attachRes; } console.warn("Connected to characteristic successfully!"); if (((_a = LavvaBluetooth.gattServer) === null || _a === void 0 ? void 0 : _a.mtu) !== undefined) { this.updateMaxPayload(LavvaBluetooth.gattServer.mtu); console.warn(`MTU: ${LavvaBluetooth.gattServer.mtu}`); } return attachRes; }); } AttachToCharacteristicInternal(characteristicUuid) { return __awaiter(this, void 0, void 0, function* () { this.characteristicUuid = characteristicUuid; try { console.warn(`Getting characteristic: ${this.characteristicUuid}`); this.characteristic = yield LavvaBluetooth.gattService.getCharacteristic(this.characteristicUuid); if (!this.characteristic) { console.error("Failed to get characteristic"); return DeviceConnectionError.FailedToGetCharacteristic; } } catch (error) { console.error("Error getting characteristic:", error); return DeviceConnectionError.FailedToGetCharacteristic; } try { console.warn("Starting notifications"); const notif = yield this.characteristic.startNotifications(); if (!notif) { console.error("Failed to start notifications"); return DeviceConnectionError.FailedToStartNotifications; } notif.addEventListener("characteristicvaluechanged", this.handleCharacteristicValueChanged); this.notificationsStarted = true; } catch (error) { console.error("Error starting notifications:", error); return DeviceConnectionError.FailedToStartNotifications; } return this.characteristic; }); } // ------------------------- disconnect / cleanup ------------------------- /** * Disconnects the shared device connection AND cleans up ALL instance listeners/notifications. * You can call it from any instance. */ DisconnectDeviceAsync(device) { return __awaiter(this, void 0, void 0, function* () { var _a, _b; try { if (!device) return DeviceDisconnectionStatus.NoDeviceConnected; // 1) Detach all characteristics (all instances) yield Promise.all([...LavvaBluetooth.instances].map((i) => i.detachCharacteristic())); // 2) Remove global gatt disconnect handler (if installed) try { (_a = LavvaBluetooth.selectedDevice) === null || _a === void 0 ? void 0 : _a.removeEventListener("gattserverdisconnected", LavvaBluetooth.globalGattDisconnectedHandler); } catch (_c) { } LavvaBluetooth.globalGattDisconnectedHandlerInstalled = false; // 3) Disconnect GATT try { (_b = device.gatt) === null || _b === void 0 ? void 0 : _b.disconnect(); yield Task.Delay(200); } catch (_d) { } // 4) Optional: forget device (may throw in some browsers) try { yield device.forget(); } catch (_e) { } // 5) Clear shared state this.rejectPendingResponses(new Error("Device disconnected")); LavvaBluetooth.selectedDevice = null; LavvaBluetooth.gattServer = null; LavvaBluetooth.gattService = null; // Notify only this instance (if you want broadcast: loop instances and Invoke) this.OnDisconnectedEvent.Invoke(device); return DeviceDisconnectionStatus.DisconnectedSuccesfully; } catch (e) { console.error("Error disconnecting device:", e); return DeviceDisconnectionStatus.FailedToDisconnect; } }); } detachCharacteristic() { return __awaiter(this, void 0, void 0, function* () { if (!this.characteristic) return; try { this.characteristic.removeEventListener("characteristicvaluechanged", this.handleCharacteristicValueChanged); } catch (_a) { } try { // stopNotifications can throw if not started or already disconnected; ignore yield this.characteristic.stopNotifications(); } catch (_b) { } this.notificationsStarted = false; this.receiveBuffer = ""; this.rejectPendingResponses(new Error("Characteristic detached")); this.characteristic = null; }); } // ------------------------- write ------------------------- SendDataAsync(data) { return __awaiter(this, void 0, void 0, function* () { const payload = this.serializeOutgoingData(data); if (!payload) return WriteDataStatus.DataIsEmpty; try { const chunks = this.SplitByLength(payload + this.sendSeparator, this._maxCharacteristicValueLength); if (!chunks) return WriteDataStatus.FailedToWriteValue; if (!this.characteristic) return WriteDataStatus.NoDeviceConnected; // Write all chunks for (let i = 0; i < chunks.length; i++) { const ok = yield this.WriteValueAsync(chunks[i]); if (!ok) return WriteDataStatus.FailedToWriteValue; } return WriteDataStatus.Sucess; } catch (error) { console.error("Error sending data:", error); return WriteDataStatus.FailedToWriteValue; } }); } SendAndWaitForResponseAsync(data_1) { return __awaiter(this, arguments, void 0, function* (data, timeout = 5000) { if (!this.characteristic) throw new Error("Connection is not established"); return new Promise((resolve, reject) => __awaiter(this, void 0, void 0, function* () { const matcher = this.createResponseMatcher(data); const timeoutId = window.setTimeout(() => { this.receiveBuffer = ""; this.pendingResponses = this.pendingResponses.filter((pending) => pending.onFrame !== onRx); reject(new Error("Response timeout")); }, timeout); const onRx = (frame) => __awaiter(this, void 0, void 0, function* () { clearTimeout(timeoutId); this.pendingResponses = this.pendingResponses.filter((pending) => pending.onFrame !== onRx); try { yield Task.Delay(30); resolve(JSON.parse(frame)); } catch (_a) { this.receiveBuffer = ""; reject(new Error("Failed to parse response")); } }); this.pendingResponses.push({ matcher, onFrame: onRx, reject }); const res = yield this.SendDataAsync(data); switch (res) { case WriteDataStatus.Sucess: console.info("Data sent successfully"); break; case WriteDataStatus.DataIsEmpty: clearTimeout(timeoutId); this.pendingResponses = this.pendingResponses.filter((pending) => pending.onFrame !== onRx); reject(new Error("The data is empty")); break; case WriteDataStatus.NoDeviceConnected: clearTimeout(timeoutId); this.pendingResponses = this.pendingResponses.filter((pending) => pending.onFrame !== onRx); reject(new Error("Failed to send request, device is disconnected")); break; case WriteDataStatus.FailedToWriteValue: clearTimeout(timeoutId); this.pendingResponses = this.pendingResponses.filter((pending) => pending.onFrame !== onRx); reject(new Error("Failed to send request")); break; } })); }); } WriteValueAsync(value) { return __awaiter(this, void 0, void 0, function* () { if (!this.characteristic) return false; const writer = new TextEncoder().encode(value); try { console.warn(`Writing characteristic [${this.characteristic.uuid}] value: ${value}`); yield this.characteristic.writeValue(writer); yield Task.Delay(10); return true; } catch (error) { // one retry try { console.warn(`Retry writing characteristic [${this.characteristic.uuid}] value: ${value}`); yield this.characteristic.writeValue(writer); yield Task.Delay(10); return true; } catch (error2) { console.error(`Error writing value: ${value} error:`, error2); return false; } } }); } SplitByLength(text, length) { return text.match(new RegExp(`(.|[\r\n]){1,${length}}`, "g")); } serializeOutgoingData(data) { if (typeof data === "string") { return data; } if (data == null) { return ""; } return JSON.stringify(data); } normalizeFrame(frame) { return frame.endsWith("\r") ? frame.slice(0, -1) : frame; } dispatchPendingResponse(frame) { for (let i = 0; i < this.pendingResponses.length; i++) { const pending = this.pendingResponses[i]; if (!pending.matcher(frame)) { continue; } this.pendingResponses.splice(i, 1); pending.onFrame(frame); return true; } return false; } rejectPendingResponses(error) { const pending = this.pendingResponses.splice(0, this.pendingResponses.length); this.receiveBuffer = ""; pending.forEach((entry) => entry.reject(error)); console.warn(`Rejecting pending responses: ${error.message}`); } createResponseMatcher(request) { const requestFrame = this.tryParseJsonFrame(typeof request === "string" ? request : this.serializeOutgoingData(request)); const requestCorrelationId = this.getCorrelationId(requestFrame); const requestResource = this.getStringField(requestFrame, "Resource"); return (frame) => { const responseFrame = this.tryParseJsonFrame(frame); if (responseFrame == null) { return requestFrame == null; } const responseCorrelationId = this.getCorrelationId(responseFrame); if (requestCorrelationId != null || responseCorrelationId != null) { return requestCorrelationId != null && requestCorrelationId === responseCorrelationId; } const responseResource = this.getStringField(responseFrame, "Resource"); if (requestResource != null || responseResource != null) { return requestResource != null && requestResource === responseResource; } return requestFrame == null || requestResource == null; }; } tryParseJsonFrame(frame) { try { const parsed = JSON.parse(frame); if (parsed != null && typeof parsed === "object" && !Array.isArray(parsed)) { return parsed; } } catch (_a) { return null; } return null; } getCorrelationId(frame) { var _a, _b, _c; if (frame == null) { return null; } return (_c = (_b = (_a = this.getStringField(frame, "TransactionId")) !== null && _a !== void 0 ? _a : this.getStringField(frame, "transactionId")) !== null && _b !== void 0 ? _b : this.getStringField(frame, "RequestId")) !== null && _c !== void 0 ? _c : this.getStringField(frame, "requestId"); } getStringField(frame, key) { const value = frame === null || frame === void 0 ? void 0 : frame[key]; return typeof value === "string" && value.length > 0 ? value : null; } } // Shared connection for all instances (one device at a time) LavvaBluetooth.selectedDevice = null; LavvaBluetooth.gattServer = null; LavvaBluetooth.gattService = null; // ---- instance registry (for global cleanup) ---- LavvaBluetooth.instances = new Set(); // global device disconnect handler (installed once per device) LavvaBluetooth.globalGattDisconnectedHandlerInstalled = false; LavvaBluetooth.globalGattDisconnectedHandler = (event) => { var _a, _b; console.warn("Gatt Server disconnected (global handler)"); for (const inst of LavvaBluetooth.instances) { try { inst.rejectPendingResponses(new Error("GATT server disconnected")); (_a = inst.OnGattServiceDisconnectedEvent) === null || _a === void 0 ? void 0 : _a.Invoke((_b = LavvaBluetooth.gattServer) !== null && _b !== void 0 ? _b : undefined); } catch (e) { console.warn("Error invoking OnGattServiceDisconnectedEvent:", e); } } }; export var DeviceConnectionError; (function (DeviceConnectionError) { DeviceConnectionError[DeviceConnectionError["NoDeviceHasBeenSelected"] = 0] = "NoDeviceHasBeenSelected"; DeviceConnectionError[DeviceConnectionError["AnotherDeviceIsAlreadyConnected"] = 1] = "AnotherDeviceIsAlreadyConnected"; DeviceConnectionError[DeviceConnectionError["FailedToConnectToGattServer"] = 2] = "FailedToConnectToGattServer"; DeviceConnectionError[DeviceConnectionError["FailedToGetPrimaryService"] = 3] = "FailedToGetPrimaryService"; DeviceConnectionError[DeviceConnectionError["FailedToDiscoverService"] = 4] = "FailedToDiscoverService"; DeviceConnectionError[DeviceConnectionError["FailedToGetCharacteristic"] = 5] = "FailedToGetCharacteristic"; DeviceConnectionError[DeviceConnectionError["FailedToStartNotifications"] = 6] = "FailedToStartNotifications"; })(DeviceConnectionError || (DeviceConnectionError = {})); export var DeviceDisconnectionStatus; (function (DeviceDisconnectionStatus) { DeviceDisconnectionStatus[DeviceDisconnectionStatus["NoDeviceConnected"] = 0] = "NoDeviceConnected"; DeviceDisconnectionStatus[DeviceDisconnectionStatus["FailedToForgetDevice"] = 1] = "FailedToForgetDevice"; DeviceDisconnectionStatus[DeviceDisconnectionStatus["FailedToDisconnect"] = 2] = "FailedToDisconnect"; DeviceDisconnectionStatus[DeviceDisconnectionStatus["FailedToStopNotifications"] = 3] = "FailedToStopNotifications"; DeviceDisconnectionStatus[DeviceDisconnectionStatus["DisconnectedSuccesfully"] = 4] = "DisconnectedSuccesfully"; })(DeviceDisconnectionStatus || (DeviceDisconnectionStatus = {})); export var WriteDataStatus; (function (WriteDataStatus) { WriteDataStatus[WriteDataStatus["Sucess"] = 0] = "Sucess"; WriteDataStatus[WriteDataStatus["DataIsEmpty"] = 1] = "DataIsEmpty"; WriteDataStatus[WriteDataStatus["NoDeviceConnected"] = 2] = "NoDeviceConnected"; WriteDataStatus[WriteDataStatus["FailedToWriteValue"] = 3] = "FailedToWriteValue"; })(WriteDataStatus || (WriteDataStatus = {})); //# sourceMappingURL=LavvaBluetooth.js.map