@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
JavaScript
;
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