lavva.webbluetooth
Version:
Library implementing WebBluetooth custom functionality if underlying platform does support it
586 lines • 27.8 kB
JavaScript
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