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

804 lines (727 loc) 27.9 kB
import { BaseModel } from "./../models/base.model.js" import type { IDevice, Service } from "../interfaces/device.interface.js" import type { ActiveCallback, massObject, NotifyCallback, WriteCallback } from "../interfaces/callback.interface.js" import type { DownloadPacket } from "../interfaces/download.interface.js" import type { Commands } from "../interfaces/command.interface.js" export abstract class Device extends BaseModel implements IDevice { /** * Filters to identify the device during Bluetooth scanning. * Used to match devices that meet specific criteria such as name, service UUIDs, etc. * @type {BluetoothLEScanFilter[]} * @public * @readonly */ readonly filters: BluetoothLEScanFilter[] /** * Array of services provided by the device. * Services represent functionalities that the device supports, such as weight measurement, battery information, or custom services. * @type {Service[]} * @public * @readonly */ readonly services: Service[] /** * Reference to the `BluetoothDevice` object representing this device. * This is the actual device object obtained from the Web Bluetooth API after a successful connection. * @type {BluetoothDevice | undefined} * @public */ bluetooth?: BluetoothDevice /** * Object representing the set of commands available for this device. * These commands allow communication with the device to perform various operations such as starting measurements, retrieving data, or calibrating the device. * @type {Commands} * @public * @readonly */ readonly commands: Commands /** * The BluetoothRemoteGATTServer interface of the Web Bluetooth API represents a GATT Server on a remote device. * @type {BluetoothRemoteGATTServer | undefined} * @private */ private server: BluetoothRemoteGATTServer | undefined /** * The last message written to the device. * @type {string | Uint8Array | null} * @protected */ protected writeLast: string | Uint8Array | null = null /** * Indicates whether the device is currently active. * @type {boolean} */ protected isActive = false /** * Configuration for threshold and duration. */ private activeConfig: { threshold: number; duration: number } = { threshold: 2.5, duration: 1000, } /** * Maximum mass recorded from the device, initialized to "0". * @type {string} * @protected */ protected massMax: string /** * Average mass calculated from the device data, initialized to "0". * @type {string} * @protected */ protected massAverage: string /** * Total sum of all mass data points recorded from the device. * Used to calculate the average mass. * @type {number} * @protected */ protected massTotalSum: number /** * Number of data points received from the device. * Used to calculate the average mass. * @type {number} * @protected */ protected dataPointCount: number /** * Array of DownloadPacket entries. * This array holds packets that contain data downloaded from the device. * @type {DownloadPacket[]} * @protected */ protected downloadPackets: DownloadPacket[] = [] // Initialize an empty array of DownloadPacket entries /** * Represents the current tare value for calibration. * @type {number} */ private tareCurrent = 0 /** * Indicates whether the tare calibration process is active. * @type {boolean} */ private tareActive = false /** * Timestamp when the tare calibration process started. * @type {number | null} */ private tareStartTime: number | null = null /** * Array holding the samples collected during tare calibration. * @type {number[]} */ private tareSamples: number[] = [] /** * Duration time for the tare calibration process. * @type {number} */ private tareDuration = 5000 /** * Optional callback for handling write operations. * @callback NotifyCallback * @param {massObject} data - The data passed to the callback. * @type {NotifyCallback | undefined} * @protected */ protected notifyCallback: NotifyCallback = (data: massObject) => console.log(data) /** * Optional callback for handling write operations. * @callback WriteCallback * @param {string} data - The data passed to the callback. * @type {WriteCallback | undefined} * @protected */ protected writeCallback: WriteCallback = (data: string) => console.log(data) /** * Optional callback for handling write operations. * @callback ActiveCallback * @param {string} data - The data passed to the callback. * @type {ActiveCallback | undefined} * @protected */ protected activeCallback: ActiveCallback = (data: boolean) => console.log(data) /** * Event listener for handling the 'gattserverdisconnected' event. * This listener delegates the event to the `onDisconnected` method. * * @private * @type {(event: Event) => void} */ private onDisconnectedListener = (event: 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>} */ private notificationListeners = new Map<string, EventListener>() constructor(device: Partial<IDevice>) { super(device) this.filters = device.filters || [] this.services = device.services || [] this.commands = device.commands || {} this.bluetooth = device.bluetooth this.massMax = "0" this.massAverage = "0" this.massTotalSum = 0 this.dataPointCount = 0 this.createdAt = new Date() this.updatedAt = new Date() } /** * 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 }); */ active = (callback: ActiveCallback, options?: { threshold?: number; duration?: number }): void => { this.activeCallback = callback // Update the config values only if provided, otherwise use defaults this.activeConfig = { threshold: options?.threshold ?? this.activeConfig.threshold, // Use new threshold if provided, else use default duration: options?.duration ?? 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); */ protected activityCheck = async (input: number): Promise<void> => { 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 this.activeCallback?.(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) * ); */ connect = async ( onSuccess: () => void = () => console.log("Connected successfully"), onError: (error: Error) => void = (error) => console.error(error), ): Promise<void> => { try { // Request device and set up connection const deviceServices = this.getAllServiceUUIDs() const bluetooth = await this.getBluetooth() 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 as 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(); */ disconnect = (): void => { if (this.isConnected()) { this.updateTimestamp() // Remove all notification listeners this.services.forEach((service) => { service.characteristics.forEach((char) => { // Look for the "rx" characteristic that accepts notifications if (char.characteristic && char.id === "rx") { char.characteristic.stopNotifications() const listener = this.notificationListeners.get(char.uuid) if (listener) { char.characteristic.removeEventListener("characteristicvaluechanged", listener) this.notificationListeners.delete(char.uuid) } } }) }) // Remove disconnect listener this.bluetooth?.removeEventListener("gattserverdisconnected", this.onDisconnectedListener) // Safely attempt to disconnect the device's GATT server, if available 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); */ protected downloadToCSV = (): string => { const packets = [...this.downloadPackets] if (packets.length === 0) { return "" } return packets .map((packet) => [ packet.received.toString(), packet.sampleNum.toString(), packet.battRaw.toString(), ...packet.samples.map(String), ...packet.masses.map(String), ] .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); */ protected downloadToJSON = (): string => { // 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); */ protected downloadToXML = (): string => { const xmlPackets = this.downloadPackets .map((packet) => { const samples = packet.samples.map((sample) => `<sample>${sample}</sample>`).join("") const masses = packet.masses.map((mass) => `<mass>${mass}</mass>`).join("") return ` <packet> <received>${packet.received}</received> <sampleNum>${packet.sampleNum}</sampleNum> <battRaw>${packet.battRaw}</battRaw> <samples>${samples}</samples> <masses>${masses}</masses> </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'); */ download = async (format: "csv" | "json" | "xml" = "csv"): Promise<void> => { 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); */ protected getAllServiceUUIDs = (): string[] => { return this.services.filter((service) => service?.uuid).map((service) => service.uuid) } /** * 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. */ protected async getBluetooth(): Promise<Bluetooth> { // 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.") } /** * Handles notifications received from a characteristic. * @param {DataView} value - The notification event. * * @example * device.handleNotifications(someCharacteristic); */ protected handleNotifications = (value: DataView): void => { 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'); * } */ isConnected = (): boolean => { // Check if the device is defined and available if (!this.bluetooth) { return false } // Check if the device is connected return !!this.bluetooth.gatt?.connected } /** * Sets the callback function to be called when notifications are received. * @param {NotifyCallback} callback - The callback function to be set. * @returns {void} * @public * * @example * device.notify((data) => { * console.log('Received notification:', data); * }); */ notify = (callback: NotifyCallback): void => { 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'); * }); */ protected onConnected = async (onSuccess: () => void): Promise<void> => { this.updateTimestamp() if (!this.server) { throw new Error("GATT server is not available") } // Connect to GATT server and set up characteristics const services: BluetoothRemoteGATTService[] = 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 === service.uuid) 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 === characteristic.uuid) if (matchingCharacteristic) { // Find the corresponding characteristic descriptor in the service's characteristics array const descriptor = matchingService.characteristics.find((char) => char.uuid === matchingCharacteristic.uuid) 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: Event) => { // Cast the event's target to a BluetoothRemoteGATTCharacteristic to access its properties const target = event.target as BluetoothRemoteGATTCharacteristic 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); */ protected onDisconnected = (event: Event): void => { console.warn(`Device ${(event.target as BluetoothDevice).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); */ read = async (serviceId: string, characteristicId: string, duration = 0): Promise<string | undefined> => { if (!this.isConnected()) { return undefined } // Get the characteristic from the service const characteristic = this.services .find((service) => service.id === serviceId) ?.characteristics.find((char) => char.id === characteristicId)?.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: string 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 } /** * 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): boolean { if (this.tareActive) return false this.updateTimestamp() this.tareActive = true this.tareDuration = duration this.tareSamples = [] this.tareStartTime = Date.now() return true } /** * 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); */ protected applyTare(sample: number): number { 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 } /** * 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); */ protected updateTimestamp = (): void => { 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_BATT_VLTG, 250, (data) => { * console.log(`Battery voltage: ${data}`); * }); */ write = async ( serviceId: string, characteristicId: string, message: string | Uint8Array | undefined, duration = 0, callback: WriteCallback = this.writeCallback, ): Promise<void> => { // 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 = this.services .find((service) => service.id === serviceId) ?.characteristics.find((char) => char.id === characteristicId)?.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: Uint8Array = typeof message === "string" ? new TextEncoder().encode(message) : 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<void>((resolve) => setTimeout(resolve, duration)) } } }