UNPKG

@hangtime/grip-connect

Version:

Griptonite Motherboard, Tindeq Progressor, PitchSix Force Board, WHC-06, Entralpi, Climbro, mySmartBoard: Bluetooth API Force-Sensing strength analysis for climbers

918 lines 42.9 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.Device = void 0; const base_model_js_1 = require("./../models/base.model.js"); const utils_js_1 = require("../utils.js"); class Device extends base_model_js_1.BaseModel { /** * Call at the start of each BLE notification (packet). Updates notify interval and packet count. * @protected */ recordPacketReceived() { const now = Date.now(); this.currentNotifyIntervalMs = this.lastPacketTimestamp > 0 ? now - this.lastPacketTimestamp : undefined; this.lastPacketTimestamp = now; this.packetCount += 1; } /** * Reset packet tracking (call when starting a new stream). * @protected */ resetPacketTracking() { this.lastPacketTimestamp = 0; this.packetCount = 0; this.currentNotifyIntervalMs = undefined; this.currentSamplesPerPacket = undefined; } constructor(device) { super(device); /** * The last message written to the device. * @type {string | Uint8Array | null} * @protected */ this.writeLast = null; /** * Indicates whether the device is currently active. * @type {boolean} */ this.isActive = false; /** * Configuration for threshold and duration. */ this.activeConfig = { threshold: 2.5, duration: 1000, }; /** * Unit of the values streamed by the device (kg for most devices, lbs for ForceBoard). * @type {ForceUnit} * @protected */ this.streamUnit = "kg"; /** * Timestamp (ms) of the previous BLE notification for notify-interval calculation. * @protected */ this.lastPacketTimestamp = 0; /** * Count of data packets received this session (one BLE notification = one packet). * @protected */ this.packetCount = 0; /** * Notify interval in ms for the current packet (set by recordPacketReceived). * @protected */ this.currentNotifyIntervalMs = undefined; /** * Samples in the current packet (set by device before buildForceMeasurement). * @protected */ this.currentSamplesPerPacket = undefined; /** * Start time of the current rate measurement interval. * @type {number} * @private */ this.rateIntervalStart = 0; /** * Number of samples in the current rate measurement interval. * @type {number} * @private */ this.rateIntervalSamples = 0; /** * Array of DownloadPacket entries. * This array holds packets that contain data downloaded from the device. * @type {DownloadPacket[]} * @protected */ this.downloadPackets = []; // Initialize an empty array of DownloadPacket entries /** * Represents the current tare value for calibration. * @type {number} */ this.tareCurrent = 0; /** * Indicates whether the tare calibration process is active. * @type {boolean} */ this.tareActive = false; /** * Timestamp when the tare calibration process started. * @type {number | null} */ this.tareStartTime = null; /** * Array holding the samples collected during tare calibration. * @type {number[]} */ this.tareSamples = []; /** * Duration time for the tare calibration process. * @type {number} */ this.tareDuration = 5000; /** * Optional callback for handling mass/force data notifications. * @callback NotifyCallback * @param {ForceMeasurement} data - The force measurement passed to the callback. * @type {NotifyCallback | undefined} * @protected */ this.notifyCallback = (data) => console.log(data); /** * Optional callback for handling write operations. * @callback WriteCallback * @param {string} data - The data passed to the callback. * @type {WriteCallback | undefined} * @protected */ this.writeCallback = (data) => console.log(data); /** * Optional callback for handling write operations. * @callback ActiveCallback * @param {string} data - The data passed to the callback. * @type {ActiveCallback | undefined} * @protected */ this.activeCallback = (data) => console.log(data); /** * Event listener for handling the 'gattserverdisconnected' event. * This listener delegates the event to the `onDisconnected` method. * * @private * @type {(event: Event) => void} */ this.onDisconnectedListener = (event) => this.onDisconnected(event); /** * A map that stores notification event listeners keyed by characteristic UUIDs. * This allows for proper addition and removal of event listeners associated with each characteristic. * * @private * @type {Map<string, EventListener>} */ this.notificationListeners = new Map(); /** * Sets the callback function to be called when the activity status changes, * and optionally sets the configuration for threshold and duration. * * This function allows you to specify a callback that will be invoked whenever * the activity status changes, indicating whether the device is currently active. * It also allows optionally configuring the threshold and duration used to determine activity. * * @param {ActiveCallback} callback - The callback function to be set. This function * receives a boolean value indicating the new activity status. * @param {object} [options] - Optional configuration object containing the threshold and duration. * @param {number} [options.threshold=2.5] - The threshold value for determining activity. * @param {number} [options.duration=1000] - The duration (in milliseconds) to monitor the input for activity. * @returns {void} * @public * * @example * device.active((isActive) => { * console.log(`Device is ${isActive ? 'active' : 'inactive'}`); * }, { threshold: 3.0, duration: 1500 }); */ this.active = (callback, options) => { var _a, _b; this.activeCallback = callback; // Update the config values only if provided, otherwise use defaults this.activeConfig = { threshold: (_a = options === null || options === void 0 ? void 0 : options.threshold) !== null && _a !== void 0 ? _a : this.activeConfig.threshold, // Use new threshold if provided, else use default duration: (_b = options === null || options === void 0 ? void 0 : options.duration) !== null && _b !== void 0 ? _b : this.activeConfig.duration, // Use new duration if provided, else use default }; }; /** * Checks if a dynamic value is active based on a threshold and duration. * * This function assesses whether a given dynamic value surpasses a specified threshold * and remains active for a specified duration. If the activity status changes from * the previous state, the callback function is called with the updated activity status. * * @param {number} input - The dynamic value to check for activity status. * @returns {Promise<void>} A promise that resolves once the activity check is complete. * * @example * await device.activityCheck(5.0); */ this.activityCheck = async (input) => { var _a; const startValue = input; const { threshold, duration } = this.activeConfig; // After waiting for `duration`, check if still active. await new Promise((resolve) => setTimeout(resolve, duration)); const activeNow = startValue > threshold; if (this.isActive !== activeNow) { this.isActive = activeNow; (_a = this.activeCallback) === null || _a === void 0 ? void 0 : _a.call(this, activeNow); } }; /** * Connects to a Bluetooth device. * @param {Function} [onSuccess] - Optional callback function to execute on successful connection. Default logs success. * @param {Function} [onError] - Optional callback function to execute on error. Default logs the error. * @public * * @example * device.connect( * () => console.log("Connected successfully"), * (error) => console.error("Connection failed:", error) * ); */ this.connect = async (onSuccess = () => console.log("Connected successfully"), onError = (error) => console.error(error)) => { try { // Request device and set up connection const deviceServices = this.getAllServiceUUIDs(); const bluetooth = await this.getBluetooth(); // Experiment: Reconnect to known devices, enable these Chrome flags: // - chrome://flags/#enable-experimental-web-platform-features → enables getDevices() API // - chrome://flags/#enable-web-bluetooth-new-permissions-backend → ensures it returns all permitted devices, not just connected ones // let reconnectDevice: BluetoothDevice | undefined // if (typeof bluetooth.getDevices === "function") { // const devices: BluetoothDevice[] = await bluetooth.getDevices() // if (devices.length > 0 && this.filters.length > 0) { // reconnectDevice = devices.find((device) => { // if (!device.name) return false // const d = device // return this.filters.some( // (f) => (f.name && d.name === f.name) || (f.namePrefix && d.name?.startsWith(f.namePrefix)), // ) // }) // } // if (reconnectDevice) { // this.bluetooth = reconnectDevice // // It's currently impossible to call this.bluetooth.gatt.connect() here. // // After restarting the Browser, it will always give: "Bluetooth Device is no longer in range." // } // } this.bluetooth = await bluetooth.requestDevice({ filters: this.filters, optionalServices: deviceServices, }); if (!this.bluetooth.gatt) { throw new Error("GATT is not available on this device"); } this.bluetooth.addEventListener("gattserverdisconnected", this.onDisconnectedListener); this.server = await this.bluetooth.gatt.connect(); if (this.server.connected) { await this.onConnected(onSuccess); } } catch (error) { onError(error); } }; /** * Disconnects the device if it is currently connected. * - Removes all notification listeners from the device's characteristics. * - Removes the 'gattserverdisconnected' event listener. * - Attempts to gracefully disconnect the device's GATT server. * - Resets relevant properties to their initial states. * @returns {void} * @public * * @example * device.disconnect(); */ this.disconnect = () => { var _a, _b, _c; const isConnected = this.isConnected(); if (isConnected) { this.updateTimestamp(); } // Remove all notification listeners and stop notifications if possible. this.services.forEach((service) => { service.characteristics.forEach((char) => { if (!char.characteristic || char.id !== "rx") return; if (isConnected) { // Best effort only: avoid unhandled rejections when the device already disconnected. void char.characteristic.stopNotifications().catch(() => undefined); } const listener = this.notificationListeners.get(char.uuid); if (listener) { char.characteristic.removeEventListener("characteristicvaluechanged", listener); this.notificationListeners.delete(char.uuid); } }); }); // Remove disconnect listener (_a = this.bluetooth) === null || _a === void 0 ? void 0 : _a.removeEventListener("gattserverdisconnected", this.onDisconnectedListener); // Safely attempt to disconnect the device's GATT server, if available if ((_c = (_b = this.bluetooth) === null || _b === void 0 ? void 0 : _b.gatt) === null || _c === void 0 ? void 0 : _c.connected) { this.bluetooth.gatt.disconnect(); } // Reset properties this.server = undefined; this.writeLast = null; this.isActive = false; }; /** * Converts the `downloadPackets` array into a CSV formatted string. * @returns {string} A CSV string representation of the `downloadPackets` data, with each packet on a new line. * @private * * @example * const csvData = device.downloadToCSV(); * console.log(csvData); */ this.downloadToCSV = () => { const packets = [...this.downloadPackets]; if (packets.length === 0) { return ""; } return packets .map((packet) => { var _a, _b, _c, _d, _e, _f, _g, _h, _j; const forceValues = packet.distribution != null ? [ (_b = (_a = packet.distribution.left) === null || _a === void 0 ? void 0 : _a.current) !== null && _b !== void 0 ? _b : "", (_d = (_c = packet.distribution.center) === null || _c === void 0 ? void 0 : _c.current) !== null && _d !== void 0 ? _d : "", (_f = (_e = packet.distribution.right) === null || _e === void 0 ? void 0 : _e.current) !== null && _f !== void 0 ? _f : "", ].map((v) => (v !== "" ? String(v) : "")) : [packet.current.toString()]; return [ packet.timestamp.toString(), packet.current.toString(), packet.peak.toString(), packet.mean.toString(), packet.min.toString(), ((_h = (_g = packet.performance) === null || _g === void 0 ? void 0 : _g.sampleIndex) !== null && _h !== void 0 ? _h : "").toString(), ((_j = packet.battRaw) !== null && _j !== void 0 ? _j : "").toString(), ...packet.samples.map(String), ...forceValues, ] .map((v) => v.replace(/"/g, '""')) .map((v) => `"${v}"`) .join(","); }) .join("\r\n"); }; /** * Converts an array of DownloadPacket objects to a JSON string. * @returns {string} JSON string representation of the data. * @private * * @example * const jsonData = device.downloadToJSON(); * console.log(jsonData); */ this.downloadToJSON = () => { // Pretty print JSON with 2-space indentation return JSON.stringify(this.downloadPackets, null, 2); }; /** * Converts an array of DownloadPacket objects to an XML string. * @returns {string} XML string representation of the data. * @private * * @example * const xmlData = device.downloadToXML(); * console.log(xmlData); */ this.downloadToXML = () => { const xmlPackets = this.downloadPackets .map((packet) => { var _a, _b, _c, _d; const samples = packet.samples.map((sample) => `<sample>${sample}</sample>`).join(""); const distributionElements = packet.distribution != null ? [ ((_a = packet.distribution.left) === null || _a === void 0 ? void 0 : _a.current) != null ? `<left>${packet.distribution.left.current}</left>` : "", ((_b = packet.distribution.center) === null || _b === void 0 ? void 0 : _b.current) != null ? `<center>${packet.distribution.center.current}</center>` : "", ((_c = packet.distribution.right) === null || _c === void 0 ? void 0 : _c.current) != null ? `<right>${packet.distribution.right.current}</right>` : "", ].join("") : ""; return ` <packet> <timestamp>${packet.timestamp}</timestamp> <current>${packet.current}</current> <peak>${packet.peak}</peak> <mean>${packet.mean}</mean> <min>${packet.min}</min> ${((_d = packet.performance) === null || _d === void 0 ? void 0 : _d.sampleIndex) != null ? `<sampleIndex>${packet.performance.sampleIndex}</sampleIndex>` : ""} ${packet.battRaw != null ? `<battRaw>${packet.battRaw}</battRaw>` : ""} <samples>${samples}</samples> ${distributionElements} </packet> `; }) .join(""); return `<DownloadPackets>${xmlPackets}</DownloadPackets>`; }; /** * Exports the data in the specified format (CSV, JSON, XML) with a filename format: * 'data-export-YYYY-MM-DD-HH-MM-SS.{format}'. * * @param {('csv' | 'json' | 'xml')} [format='csv'] - The format in which to download the data. * Defaults to 'csv'. Accepted values are 'csv', 'json', and 'xml'. * * @returns {Promise<void>} Resolves when the data has been downloaded/written * @public * * @example * await device.download('json'); */ this.download = async (format = "csv") => { let content = ""; if (format === "csv") { content = this.downloadToCSV(); } else if (format === "json") { content = this.downloadToJSON(); } else if (format === "xml") { content = this.downloadToXML(); } const now = new Date(); // YYYY-MM-DD const date = now.toISOString().split("T")[0]; // HH-MM-SS const time = now.toTimeString().split(" ")[0].replace(/:/g, "-"); const fileName = `data-export-${date}-${time}.${format}`; const mimeTypes = { csv: "text/csv", json: "application/json", xml: "application/xml", }; // Create a Blob object containing the data const blob = new Blob([content], { type: mimeTypes[format] }); // Create a URL for the Blob const url = globalThis.URL.createObjectURL(blob); // Create a link element const link = document.createElement("a"); // Set link attributes link.href = url; link.setAttribute("download", fileName); // Append link to document body document.body.appendChild(link); // Programmatically click the link to trigger the download link.click(); // Clean up: remove the link and revoke the URL document.body.removeChild(link); globalThis.URL.revokeObjectURL(url); }; /** * Returns UUIDs of all services associated with the device. * @returns {string[]} Array of service UUIDs. * @protected * * @example * const serviceUUIDs = device.getAllServiceUUIDs(); * console.log(serviceUUIDs); */ this.getAllServiceUUIDs = () => { return this.services.filter((service) => service === null || service === void 0 ? void 0 : service.uuid).map((service) => service.uuid); }; /** * Handles notifications received from a characteristic. * @param {DataView} value - The notification event. * * @example * device.handleNotifications(someCharacteristic); */ this.handleNotifications = (value) => { if (!value) return; this.updateTimestamp(); // Received notification data console.log(value); }; /** * Checks if a Bluetooth device is connected. * @returns {boolean} A boolean indicating whether the device is connected. * @public * * @example * if (device.isConnected()) { * console.log('Device is connected'); * } else { * console.log('Device is not connected'); * } */ this.isConnected = () => { var _a; // Check if the device is defined and available if (!this.bluetooth) { return false; } // Check if the device is connected return !!((_a = this.bluetooth.gatt) === null || _a === void 0 ? void 0 : _a.connected); }; /** * Sets the callback function to be called when notifications are received. * @param {NotifyCallback} callback - The callback function to be set. * @param {ForceUnit} [unit="kg"] - Optional display unit for force values in the callback payload. * @returns {void} * @public * * @example * device.notify((data) => { * console.log('Received notification:', data); * }); * device.notify((data) => { ... }, 'lbs'); */ this.notify = (callback, unit) => { this.unit = unit !== null && unit !== void 0 ? unit : "kg"; this.notifyCallback = callback; }; /** * Handles the 'connected' event. * @param {Function} onSuccess - Callback function to execute on successful connection. * @public * * @example * device.onConnected(() => { * console.log('Device connected successfully'); * }); */ this.onConnected = async (onSuccess) => { this.updateTimestamp(); if (!this.server) { throw new Error("GATT server is not available"); } // Connect to GATT server and set up characteristics const services = await this.server.getPrimaryServices(); if (!services || services.length === 0) { throw new Error("No services found"); } for (const service of services) { const matchingService = this.services.find((boardService) => boardService.uuid.toLowerCase() === service.uuid.toLowerCase()); if (matchingService) { // Android bug: Add a small delay before getting characteristics await new Promise((resolve) => setTimeout(resolve, 100)); const characteristics = await service.getCharacteristics(); for (const characteristic of matchingService.characteristics) { const matchingCharacteristic = characteristics.find((char) => char.uuid.toLowerCase() === characteristic.uuid.toLowerCase()); if (matchingCharacteristic) { // Find the corresponding characteristic descriptor in the service's characteristics array const descriptor = matchingService.characteristics.find((char) => char.uuid.toLowerCase() === matchingCharacteristic.uuid.toLowerCase()); if (descriptor) { // Assign the actual Bluetooth characteristic object to the descriptor so it can be used later descriptor.characteristic = matchingCharacteristic; // Look for the "rx" characteristic id that accepts notifications if (descriptor.id === "rx") { // Start receiving notifications for changes on this characteristic matchingCharacteristic.startNotifications(); // Triggered when the characteristic's value changes const listener = (event) => { // Cast the event's target to a BluetoothRemoteGATTCharacteristic to access its properties const target = event.target; if (target && target.value) { // Delegate the data to handleNotifications method this.handleNotifications(target.value); } }; // Attach the event listener to listen for changes in the characteristic's value matchingCharacteristic.addEventListener("characteristicvaluechanged", listener); // Store the listener so it can be referenced (for later removal) this.notificationListeners.set(descriptor.uuid, listener); } } } else { throw new Error(`Characteristic ${characteristic.uuid} not found in service ${service.uuid}`); } } } } // Call the onSuccess callback after successful connection and setup onSuccess(); }; /** * Handles the 'disconnected' event. * @param {Event} event - The 'disconnected' event. * @public * * @example * device.onDisconnected(event); */ this.onDisconnected = (event) => { console.warn(`Device ${event.target.name} is disconnected.`); this.disconnect(); }; /** * Reads the value of the specified characteristic from the device. * @param {string} serviceId - The service ID where the characteristic belongs. * @param {string} characteristicId - The characteristic ID to read from. * @param {number} [duration=0] - The duration to wait before resolving the promise, in milliseconds. * @returns {Promise<string | undefined>} A promise that resolves when the read operation is completed. * @public * * @example * const value = await device.read('battery', 'level', 1000); * console.log('Battery level:', value); */ this.read = async (serviceId, characteristicId, duration = 0) => { var _a, _b; if (!this.isConnected()) { return undefined; } // Get the characteristic from the service const characteristic = (_b = (_a = this.services .find((service) => service.id === serviceId)) === null || _a === void 0 ? void 0 : _a.characteristics.find((char) => char.id === characteristicId)) === null || _b === void 0 ? void 0 : _b.characteristic; if (!characteristic) { throw new Error(`Characteristic "${characteristicId}" not found in service "${serviceId}"`); } this.updateTimestamp(); // Decode the value based on characteristicId and serviceId let decodedValue; const decoder = new TextDecoder("utf-8"); // Read the value from the characteristic const value = await characteristic.readValue(); if ((serviceId === "battery" || serviceId === "humidity" || serviceId === "temperature") && characteristicId === "level") { // This is battery-specific; return the first byte as the level decodedValue = value.getUint8(0).toString(); } else { // Otherwise use a UTF-8 decoder decodedValue = decoder.decode(value); } // Wait for the specified duration before returning the result if (duration > 0) { await new Promise((resolve) => setTimeout(resolve, duration)); } return decodedValue; }; /** * Updates the timestamp of the last device interaction. * This method sets the updatedAt property to the current date and time. * @protected * * @example * device.updateTimestamp(); * console.log('Last updated:', device.updatedAt); */ this.updateTimestamp = () => { this.updatedAt = new Date(); }; /** * Writes a message to the specified characteristic of a Bluetooth device and optionally provides a callback to handle responses. * @param {string} serviceId - The service UUID of the Bluetooth device containing the target characteristic. * @param {string} characteristicId - The characteristic UUID where the message will be written. * @param {string | Uint8Array | undefined} message - The message to be written to the characteristic. It can be a string or a Uint8Array. * @param {number} [duration=0] - Optional. The time in milliseconds to wait before resolving the promise. Defaults to 0 for immediate resolution. * @param {WriteCallback} [callback=writeCallback] - Optional. A custom callback to handle the response after the write operation is successful. * @returns {Promise<void>} A promise that resolves once the write operation is complete. * @public * @throws {Error} Throws an error if the characteristic is undefined. * * @example * // Example usage of the write function with a custom callback * await Progressor.write("progressor", "tx", ProgressorCommands.GET_BATTERY_VOLTAGE, 250, (data) => { * console.log(`Battery voltage: ${data}`); * }); */ this.write = async (serviceId, characteristicId, message, duration = 0, callback = this.writeCallback) => { var _a, _b; // Check if not connected or no message is provided if (!this.isConnected() || message === undefined) { return Promise.resolve(); } // Get the characteristic from the service const characteristic = (_b = (_a = this.services .find((service) => service.id === serviceId)) === null || _a === void 0 ? void 0 : _a.characteristics.find((char) => char.id === characteristicId)) === null || _b === void 0 ? void 0 : _b.characteristic; if (!characteristic) { throw new Error(`Characteristic "${characteristicId}" not found in service "${serviceId}"`); } this.updateTimestamp(); // Convert the message to Uint8Array if it's a string const valueToWrite = typeof message === "string" ? new Uint8Array(new TextEncoder().encode(message)) : new Uint8Array(message); // Write the value to the characteristic await characteristic.writeValue(valueToWrite); // Update the last written message this.writeLast = message; // Assign the provided callback to `writeCallback` this.writeCallback = callback; // If a duration is specified, resolve the promise after the duration if (duration > 0) { await new Promise((resolve) => setTimeout(resolve, duration)); } }; this.filters = device.filters || []; this.services = device.services || []; this.commands = device.commands || {}; if (device.bluetooth !== undefined) { this.bluetooth = device.bluetooth; } this.peak = Number.NEGATIVE_INFINITY; this.mean = 0; this.min = Number.POSITIVE_INFINITY; this.sum = 0; this.dataPointCount = 0; this.unit = "kg"; // Reset sampling rate calculation state this.rateIntervalStart = 0; this.rateIntervalSamples = 0; this.createdAt = new Date(); this.updatedAt = new Date(); } /** * Builds a ForceMeasurement for a single zone (e.g. left/center/right). * With one argument, current/peak/mean are all set to that value. * With three arguments, uses the given current, peak, and mean for the zone. * @param valueOrCurrent - Force value, or current force for this zone * @param peak - Optional peak for this zone (required if mean is provided) * @param mean - Optional mean for this zone * @returns ForceMeasurement (no nested distribution) * @protected */ buildZoneMeasurement(valueOrCurrent, peak, mean) { const useFullStats = peak !== undefined && mean !== undefined; const current = valueOrCurrent; const zonePeak = useFullStats ? (peak === 0 && current < 0 ? current : peak) : valueOrCurrent; const zoneMean = useFullStats ? mean : valueOrCurrent; const zoneMin = useFullStats ? Math.min(zonePeak, current) : current; return { unit: this.unit, timestamp: Date.now(), current, peak: zonePeak, mean: zoneMean, min: zoneMin, }; } /** * Calculates sampling rate: samples per second. * Uses fixed intervals to avoid sliding window edge effects. * @private */ updateSamplingRate() { const now = Date.now(); if (this.rateIntervalStart === 0) { this.rateIntervalStart = now; } this.rateIntervalSamples++; const elapsed = now - this.rateIntervalStart; if (elapsed >= Device.RATE_INTERVAL_MS) { this.samplingRateHz = Math.round((this.rateIntervalSamples / elapsed) * 1000); this.rateIntervalStart = now; this.rateIntervalSamples = 0; } } /** * Shared base for ForceMeasurement/DownloadPacket payload construction. * @private */ buildForcePayload(current, overrides) { var _a; const timestamp = (_a = overrides === null || overrides === void 0 ? void 0 : overrides.timestamp) !== null && _a !== void 0 ? _a : Date.now(); const payload = { unit: this.unit, timestamp, current: (0, utils_js_1.convertForce)(current, this.streamUnit, this.unit), peak: (0, utils_js_1.convertForce)(this.peak, this.streamUnit, this.unit), mean: (0, utils_js_1.convertForce)(this.mean, this.streamUnit, this.unit), min: Number.isFinite(this.min) ? (0, utils_js_1.convertForce)(this.min, this.streamUnit, this.unit) : (0, utils_js_1.convertForce)(0, this.streamUnit, this.unit), performance: { packetIndex: this.packetCount, ...((overrides === null || overrides === void 0 ? void 0 : overrides.sampleIndex) != null && { sampleIndex: overrides.sampleIndex }), ...(this.currentNotifyIntervalMs != null && { notifyIntervalMs: this.currentNotifyIntervalMs }), ...(this.currentSamplesPerPacket != null && { samplesPerPacket: this.currentSamplesPerPacket }), ...(this.samplingRateHz != null && { samplingRateHz: this.samplingRateHz }), }, }; const distribution = overrides === null || overrides === void 0 ? void 0 : overrides.distribution; if (distribution && (distribution.left != null || distribution.center != null || distribution.right != null)) { payload.distribution = {}; if (distribution.left != null) { payload.distribution.left = (0, utils_js_1.convertForceMeasurement)(distribution.left, this.streamUnit, this.unit); } if (distribution.center != null) { payload.distribution.center = (0, utils_js_1.convertForceMeasurement)(distribution.center, this.streamUnit, this.unit); } if (distribution.right != null) { payload.distribution.right = (0, utils_js_1.convertForceMeasurement)(distribution.right, this.streamUnit, this.unit); } } return payload; } /** * Builds a ForceMeasurement payload for notify callbacks. * @param current - Current force at this sample * @param distribution - Optional per-zone measurements (e.g. from buildZoneMeasurement) * @returns ForceMeasurement * @protected */ buildForceMeasurement(current, distribution) { this.updateSamplingRate(); return this.buildForcePayload(current, distribution != null ? { sampleIndex: this.dataPointCount, distribution } : { sampleIndex: this.dataPointCount }); } /** * Builds a DownloadPacket for export (CSV, JSON, XML). * Converts force values from streamUnit to display unit. * @param current - Current force at this sample (stream unit) * @param samples - Raw sensor/ADC values from device * @param options - Optional timestamp, battRaw, sampleIndex, distribution (for multi-zone) * @returns DownloadPacket * @protected */ buildDownloadPacket(current, samples, options) { const overrides = {}; if ((options === null || options === void 0 ? void 0 : options.timestamp) != null) overrides.timestamp = options.timestamp; if ((options === null || options === void 0 ? void 0 : options.sampleIndex) != null) overrides.sampleIndex = options.sampleIndex; if ((options === null || options === void 0 ? void 0 : options.distribution) != null) overrides.distribution = options.distribution; const packet = this.buildForcePayload(current, Object.keys(overrides).length > 0 ? overrides : undefined); packet.samples = samples; if ((options === null || options === void 0 ? void 0 : options.battRaw) != null) packet.battRaw = options.battRaw; return packet; } /** * Returns the Bluetooth instance available for the current environment. * In browsers, it returns the native Web Bluetooth API (i.e. `navigator.bluetooth`). * In a Node, Bun, or Deno environment, it dynamically imports the `webbluetooth` package. * {@link https://github.com/thegecko/webbluetooth} * * @returns {Promise<Bluetooth>} A promise that resolves to the Bluetooth instance. * @throws {Error} If Web Bluetooth is not available in the current environment. */ async getBluetooth() { // If running in a browser with native Web Bluetooth support: if (typeof navigator !== "undefined" && navigator.bluetooth) { return navigator.bluetooth; } // If none of the above conditions are met, throw an error. throw new Error("Bluetooth not available."); } /** * Initiates the tare calibration process. * @param {number} duration - The duration time for tare calibration. * @returns {boolean} A boolean indicating whether the tare calibration was successful. * @public * * @example * const success = device.tare(5000); * if (success) { * console.log('Tare calibration started'); * } else { * console.log('Tare calibration failed to start'); * } */ tare(duration = 5000) { if (this.tareActive) return false; this.updateTimestamp(); this.tareActive = true; this.tareDuration = duration; this.tareSamples = []; this.tareStartTime = Date.now(); return true; } /** * Clears the software tare offset and related state. * Used by devices that implement hardware tare so applyTare does not double-adjust. * @protected */ clearTareOffset() { this.tareCurrent = 0; this.tareActive = false; this.tareStartTime = null; this.tareSamples = []; } /** * Apply tare calibration to the provided sample. * @param {number} sample - The sample to calibrate. * @returns {number} The calibrated tare value. * @protected * * @example * const calibratedSample = device.applyTare(rawSample); * console.log('Calibrated sample:', calibratedSample); */ applyTare(sample) { if (this.tareActive && this.tareStartTime) { // Add current sample to the tare samples array this.tareSamples.push(sample); // Check if the tare calibration duration has passed if (Date.now() - this.tareStartTime >= this.tareDuration) { // Calculate the average of the tare samples const total = this.tareSamples.reduce((acc, sample) => acc + sample, 0); this.tareCurrent = total / this.tareSamples.length; // Reset the tare calibration process this.tareActive = false; this.tareStartTime = null; this.tareSamples = []; } } // Return the current tare-adjusted value return this.tareCurrent; } } exports.Device = Device; /** * Interval duration (ms) for sampling rate calculation. * @private * @readonly */ Device.RATE_INTERVAL_MS = 1000; //# sourceMappingURL=device.model.js.map