bt-sensors-plugin-sk
Version:
Bluetooth Sensors for Signalk - see https://www.npmjs.com/package/bt-sensors-plugin-sk#supported-sensors for a list of supported sensors
365 lines (340 loc) • 11.3 kB
JavaScript
const BTSensor = require("../../BTSensor");
const BTHomeServiceData = require("./BTHomeServiceData");
const KaitaiStream = require("kaitai-struct/KaitaiStream");
/**
* @typedef ButtonPressEvent {string}
*/
/**
* Shelly Blu devices support single press and hold press options.
* @type {Readonly<{PRESS: string, HOLD_PRESS: string}>}
*/
const ButtonPressEvent = Object.freeze({
PRESS: "press",
HOLD_PRESS: "hold_press",
});
/**
* Base class for sensors publishing BTHome data.
*
* BTHome is an open standard for broadcasting sensor data and button presses over Bluetooth LE.
*
* @abstract
* @see https://bthome.io/
*/
class AbstractBTHomeSensor extends BTSensor {
/**
* Offset from Celsius to Kelvin, used to convert between these units.
* 273.15 degrees Kelvin correspond to 0 degrees Celsius.
*/
static KELVIN_OFFSET = 273.15;
static batteryStrengthTag="battery"
/**
* BTHome Service identifier
*
* @type {string} The Service UUID
* @see https://bthome.io/images/License_Statement_-_BTHOME.pdf
*/
static BTHOME_SERVICE_ID = "0000fcd2-0000-1000-8000-00805f9b34fb";
/**
* Returns the `AbstractBTHomeSensor` sensor class if the specified device has been identified as Shelly BLU H&T.
*
* @param device The Bluetooth device to be identified.
* @returns {Promise<AbstractBTHomeSensor|null>} Returns the sensor class if the device has been identified, or null.
*/
static async identify(device) {
if (
(await this.hasBtHomeServiceData(device)) &&
((await this.hasName(
device,
this.SHORTENED_LOCAL_NAME,
)) || (await this.hasNameAndAddress(
device,
this.SHORTENED_LOCAL_NAME,
)) || (await this.hasName(
device,
this.LOCAL_NAME,
)))
) {
return this;
}
return null;
}
/**
* Returns whether the specified device's advertisement contains BTHome service data or not.
*
* This method should be included in the {@link BTSensor#identify} method of inheriting sensor classes.
*
* @example
* static async identify(device) {
* if (await this.hasBtHomeServiceData(device)) {
* // Additional checks to distinguish from other BTHome devices
* return YourSensorClass;
* }
* return null;
* }
* @see BTSensor#identify
* @param device The Bluetooth device to check for BTHome data.
* @returns {Promise<boolean>} Returns `true`, if the device exposes BTHome service data, or `false`,
* if not.
*/
static async hasBtHomeServiceData(device) {
const serviceData = await this.getDeviceProp(
device,
"ServiceData",
);
if (serviceData) {
if (this.BTHOME_SERVICE_ID in serviceData) {
return true;
}
}
return false;
}
/**
* Returns whether the specified device has a given name.
*
* This method can be included in the {@link BTSensor#identify} method of inheriting sensor classes.
*
* @example
* static async identify(device) {
* if (await this.hasName(device)) {
* // Additional checks, if required
* return YourSensorClass;
* }
* return null;
* }
* @see BTSensor#identify
* @param device The Bluetooth device to check the name.
* @param name {string} The name to be checked for.
* @returns {Promise<boolean>} Returns `true`, if the device exposes BTHome service data, or `false`,
* if not.
*/
static async hasName(device, name) {
const deviceName = await this.getDeviceProp(device, "Name");
if (deviceName) {
if (deviceName === name) {
return true;
}
}
return false;
}
/**
* Returns whether the specified device has a given name plus the last two bytes of its address.
*
* This method can be included in the {@link BTSensor#identify} method of inheriting sensor classes.
*
* @example
* static async identify(device) {
* if (await this.hasNameAndAddress(device)) {
* // Additional checks, if required
* return YourSensorClass;
* }
* return null;
* }
* @see BTSensor#identify
* @param device The Bluetooth device to check the name.
* @param name {string} The name to be checked for.
* @returns {Promise<boolean>} Returns `true`, if the device exposes BTHome service data, or `false`,
* if not.
*/
static async hasNameAndAddress(device, name) {
let deviceName = await this.getDeviceProp(device, "Name");
const address = (await this.getDeviceProp(device, "Address")).split(":")
if (deviceName) {
if (`${name}-${address[4]}${address[5]}`.localeCompare(deviceName, undefined, { sensitivity: 'base' } )==0)
return true;
}
return false;
}
/**
* Returns measurement data for the given object ID from the given BTHomeData.
*
* @param btHomeData {BTHomeServiceData.BthomeServiceData}
* @param objectId {number}
* @return {any|null} Returns the measurement data for the given object ID, or `null`, if the BTHomeData does not
* contain the measurement.
*/
getSensorDataByObjectId(btHomeData, objectId) {
return btHomeData.measurement.find((m) => m.objectId === objectId)?.data;
}
/**
* Extracts battery level from the given BTHome data.
*
* @param btHomeData {BTHomeServiceData.BthomeServiceData} The BTHome data provided by the device.
* @returns {number|null} The device's battery level as ratio (0‒1).
*/
parseBatteryLevel(btHomeData) {
const batteryLevel = this.getSensorDataByObjectId(
btHomeData,
BTHomeServiceData.BthomeObjectId.SENSOR_BATTERY,
)?.battery;
if (batteryLevel) {
return Number.parseFloat((batteryLevel / 100.0).toFixed(2));
}
return null;
}
/**
* Extracts temperature from the given BTHome data and converts it to Kelvin for Signal-K.
*
* @param btHomeData {BTHomeServiceData.BthomeServiceData} The BTHome data provided by the device.
* @returns {number|null} The temperature in Kelvin.
*/
parseTemperature(btHomeData) {
const tempCelsius = this.getSensorDataByObjectId(
btHomeData,
BTHomeServiceData.BthomeObjectId.SENSOR_TEMPERATURE_0_1,
)?.temperature;
if (tempCelsius) {
return Number.parseFloat(
(this.constructor.KELVIN_OFFSET + tempCelsius).toFixed(2),
);
}
return null;
}
/**
* Extracts humidity from the given BTHome data.
*
* @param btHomeData {BTHomeServiceData.BthomeServiceData} The BTHome data provided by the device.
* @returns {number|null} The relative humidity as ratio (0‒1).
*/
parseHumidity(btHomeData) {
const humidity = this.getSensorDataByObjectId(
btHomeData,
BTHomeServiceData.BthomeObjectId.SENSOR_HUMIDITY,
)?.humidity;
if (humidity) {
return Number.parseFloat((humidity / 100.0).toFixed(2));
}
return null;
}
/**
* Extracts luminance from the given BTHome data.
*
* @param btHomeData {BTHomeServiceData.BthomeServiceData} The BTHome data provided by the device.
* @returns {number|null} The luminance as float.
*/
parseIlluminance(btHomeData) {
const illuminance = this.getSensorDataByObjectId(
btHomeData,
BTHomeServiceData.BthomeObjectId.SENSOR_ILLUMINANCE_0_01,
)?.illuminance;
return illuminance;
}
/**
* Extracts motion detection from the given BTHome data.
*
* @param btHomeData {BTHomeServiceData.BthomeServiceData} The BTHome data provided by the device.
* @returns {Boolean|null} Wether the device has detected motion.
*/
parseMotion(btHomeData) {
const motion = this.getSensorDataByObjectId(
btHomeData,
BTHomeServiceData.BthomeObjectId.BINARY_MOTION
)?.motion;
return motion.intValue==1
}
/**
* Extracts packet ID from the given BTHome data.
*
* @param btHomeData {BTHomeServiceData.BthomeServiceData} The BTHome data provided by the device.
* @returns {Number|null} The packet ID.
*/
parsePacketID(btHomeData) {
return this.getSensorDataByObjectId(
btHomeData,
BTHomeServiceData.BthomeObjectId.MISC_PACKET_ID,
).packetId
}
/**
* Extracts button press event from the given BTHome data.
*
* @param btHomeData {BTHomeServiceData.BthomeServiceData} The BTHome data provided by the device.
* @returns {BTHomeServiceData.ButtonEventType|null} The device's button press state.
*/
parseButton(btHomeData) {
const buttonEvent = this.getSensorDataByObjectId(
btHomeData,
BTHomeServiceData.BthomeObjectId.EVENT_BUTTON,
)?.event;
if (buttonEvent) {
return buttonEvent;
}
return null;
}
/**
* Parses the relevant button press events for the Shelly BLU H&T from the specified BTHome data. This device only
* uses a subset of all available BTHome button press events.
*
* @param btHomeData {BTHomeServiceData.BthomeServiceData} The BTHome data provided by the device.
* @returns {ButtonPressEvent|null} The device's button state.
* @see https://shelly-api-docs.shelly.cloud/docs-ble/Devices/ht/#button-press-events
* @see https://bthome.io/format/
*/
parseShellyButton(btHomeData) {
const buttonEvent = this.parseButton(btHomeData);
if (buttonEvent) {
if (buttonEvent === BTHomeServiceData.ButtonEventType.PRESS) {
return ButtonPressEvent.PRESS;
/*
* Prior to firmware version 1.0.20, the hold press event is indicated by 0xFE, which does
* not conform with the BTHome standard.
*/
}
if (
buttonEvent === BTHomeServiceData.ButtonEventType.HOLD_PRESS ||
buttonEvent === 0xfe
) {
return ButtonPressEvent.HOLD_PRESS;
}
}
return null;
}
/**
* Parses the opening (window/door) state from BTHome service data.
* @param {BTHomeServiceData.BthomeServiceData} btHomeData - The parsed BTHome data object.
* @returns {string|null} "open", "closed", or null if not present.
*/
parseWindowState(btHomeData) {
const state = this.getSensorDataByObjectId(btHomeData, BTHomeServiceData.BthomeObjectId.BINARY_WINDOW)?.window;
if (state.intValue === 1) return "open";
if (state.intValue === 0) return "closed";
return null;
}
/**
* Parses the rotation from BTHome service data.
* @param {BTHomeServiceData.BthomeServiceData} btHomeData - The parsed BTHome data object.
* @returns {number|null} rotation in degrees from the closed position, or null if not present.
*/
parseRotation(btHomeData) {
// Use the correct object ID for window event (0x2D, which is 45 in decimal, which is already specified in BthomeObjectId).
const sensorRotation = this.getSensorDataByObjectId(btHomeData, BTHomeServiceData.BthomeObjectId.SENSOR_ROTATION_0_1)?.rotation;
if (sensorRotation) {
return Number.parseFloat(sensorRotation.toFixed(2));
}
return null;
}
propertiesChanged(props) {
super.propertiesChanged(props);
// Make sure the advertisement contains service data, which is not the case for, e.g., RSSI events
if (!Object.hasOwn(props, "ServiceData")) {
return;
}
// Retrieve BTHome data
const buffer = this.getServiceData(this.constructor.BTHOME_SERVICE_ID);
if (!buffer) {
this.debug(
`ServiceData does not contain BTHome service, which is unexpected for ${this.getDisplayName()}`,
);
return;
}
try {
// Parse ServiceData according to the BTHome v2 format (https://bthome.io/format/) and emit sensor data
const btHomeData = new BTHomeServiceData(new KaitaiStream(buffer));
this.emitValuesFrom(btHomeData);
} catch (e) {
this.debug(e)
throw new Error(
`Unable to parse BTHome data for ${this.getDisplayName()}`,
);
}
}
}
module.exports = AbstractBTHomeSensor;