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
385 lines (332 loc) • 12.4 kB
JavaScript
const BTSensor = require("../BTSensor");
/**
* HSC14F Battery Management System Sensor Class
*
* Manufacturer: BMC (HumsiENK branded)
* Protocol: Custom BLE protocol using AA prefix commands
*
* Discovered protocol details:
* - TX Handle: 0x000c (write commands to battery)
* - RX Handle: 0x000e (receive notifications from battery)
* - Command format: aa [CMD] 00 [CMD] 00
* - Multi-part responses (some commands send 2-3 notifications)
*
* Key Commands:
* - 0x00: Handshake
* - 0x21: Real-time battery data (voltage, current, SOC, temps) - PRIMARY
* - 0x22: Individual cell voltages
* - 0x23: Battery status/warnings
* - 0x20: Configuration data
* - 0x10: Manufacturer name
* - 0x11: Model number
*/
class HumsienkBMS extends BTSensor {
static Domain = BTSensor.SensorDomains.electrical;
// Discovered actual UUIDs from device
static TX_RX_SERVICE = "00000001-0000-1000-8000-00805f9b34fb";
static WRITE_CHAR_UUID = "00000002-0000-1000-8000-00805f9b34fb";
static NOTIFY_CHAR_UUID = "00000003-0000-1000-8000-00805f9b34fb";
static identify(device) {
// HSC14F batteries advertise with this service UUID
// Further identification would require GATT connection to read manufacturer/model
return null;
}
static ImageFile = "JBDBMS.webp"; // Using similar BMS image for now
static Manufacturer = "BMC (HumsiENK)";
static Description = "HSC14F LiFePO4 Battery Management System";
/**
* Create HSC14F command
* Format: aa [CMD] 00 [CMD] 00
*/
hsc14fCommand(command) {
return [0xaa, command, 0x00, command, 0x00];
}
/**
* Send command to battery
* HSC14F requires Write Request (0x12) not Write Command (0x52)
*/
async sendCommand(command) {
this.debug(`Sending command 0x${command.toString(16)} to ${this.getName()}`);
return await this.txChar.writeValue(
Buffer.from(this.hsc14fCommand(command))
);
}
async initSchema() {
super.initSchema();
this.addDefaultParam("batteryID");
// Number of cells parameter - configurable (default 4 for LiFePO4)
if (this.numberOfCells === undefined) {
this.numberOfCells = 4;
}
this.addParameter("numberOfCells", {
title: "Number of cells",
description: "Number of cells in the battery (typically 4 for 12V LiFePO4)",
type: "number",
isRequired: true,
default: this.numberOfCells,
minimum: 1,
maximum: 16,
multipleOf: 1
});
// Voltage
this.addDefaultPath("voltage", "electrical.batteries.voltage").read = (
buffer
) => {
// Bytes 3-4: voltage in mV, little-endian
return buffer.readUInt16LE(3) / 1000;
};
// Current - CORRECTED based on buffer analysis
this.addDefaultPath("current", "electrical.batteries.current").read = (
buffer
) => {
// Bytes 7-8: current in milliamps, signed little-endian
// Negative = charging, Positive = discharging
return buffer.readInt16LE(7) / 1000;
};
// State of Charge
this.addDefaultPath(
"SOC",
"electrical.batteries.capacity.stateOfCharge"
).read = (buffer) => {
// Byte 11: SOC percentage (0-100)
return buffer.readUInt8(11) / 100;
};
// Temperature 1 (ENV temperature) - CORRECTED byte position
this.addMetadatum("temp1", "K", "Battery Environment Temperature", (buffer) => {
// Byte 27: Environment temperature in °C
const tempC = buffer.readUInt8(27);
return 273.15 + tempC; // Convert to Kelvin
}).default = "electrical.batteries.{batteryID}.temperature";
// Temperature 2 (MOS temperature) - CORRECTED byte position
this.addMetadatum("temp2", "K", "Battery MOS Temperature", (buffer) => {
// Byte 28: MOS (MOSFET) temperature in °C
const tempC = buffer.readUInt8(28);
return 273.15 + tempC;
}).default = "electrical.batteries.{batteryID}.mosfetTemperature";
// Temperature 3 (Sensor) - CORRECTED byte position
this.addMetadatum("temp3", "K", "Battery Sensor Temperature", (buffer) => {
// Byte 29: Additional temperature sensor in °C
const tempC = buffer.readUInt8(29);
return 273.15 + tempC;
}).default = "electrical.batteries.{batteryID}.sensorTemperature";
// Manufacturer (from command 0x10)
this.addMetadatum(
"manufacturer",
"",
"Battery Manufacturer",
(buffer) => {
// Response: aa 10 03 42 4d 43 ...
// ASCII bytes starting at position 3
const len = buffer.readUInt8(2);
return buffer.toString("ascii", 3, 3 + len);
}
).default = "electrical.batteries.{batteryID}.manufacturer";
// Model (from command 0x11)
this.addMetadatum("model", "", "Battery Model", (buffer) => {
// Response: aa 11 0a 42 4d 43 2d 30 34 53 30 30 31 ...
const len = buffer.readUInt8(2);
return buffer.toString("ascii", 3, 3 + len);
}).default = "electrical.batteries.{batteryID}.model";
// Cell voltages (from command 0x22)
// Number of cells is configurable via numberOfCells parameter
for (let i = 0; i < this.numberOfCells; i++) {
this.addMetadatum(
`cell${i}Voltage`,
"V",
`Cell ${i + 1} voltage`,
(buffer) => {
// Cell voltages: aa 22 30 6a 0d 58 0d 8f 0d 34 0d ...
// Starting at byte 3, each cell is 2 bytes little-endian in mV
return buffer.readUInt16LE(3 + i * 2) / 1000;
}
).default = `electrical.batteries.{batteryID}.cell${i}.voltage`;
}
}
hasGATT() {
return true;
}
usingGATT() {
return true;
}
async initGATTNotifications() {
// Don't use notifications for polling mode
// The parent class initGATTInterval will handle periodic connections
}
async emitGATT() {
try {
await this.getAndEmitBatteryInfo();
} catch (e) {
this.debug(`Failed to emit battery info for ${this.getName()}: ${e}`);
throw e; // Re-throw so parent can handle reconnection
}
// Get cell voltages after a short delay
await new Promise(resolve => setTimeout(resolve, 2000));
try {
await this.getAndEmitCellVoltages();
} catch (e) {
this.debug(`Failed to emit cell voltages for ${this.getName()}: ${e}`);
throw e; // Re-throw so parent can handle reconnection
}
}
/**
* Get buffer response from battery command
* HSC14F sends multi-part responses for some commands
*/
async getBuffer(command) {
let result = Buffer.alloc(256);
let offset = 0;
let lastPacketTime = Date.now();
// Set up listener first
const responsePromise = new Promise((resolve, reject) => {
const timer = setTimeout(() => {
clearTimeout(timer);
if (completionTimer) clearTimeout(completionTimer);
this.rxChar.removeAllListeners("valuechanged");
reject(
new Error(
`Response timed out (+10s) from HSC14F device ${this.getName()}.`
)
);
}, 10000);
let completionTimer = null;
const valChanged = (buffer) => {
// HSC14F responses start with 0xaa followed by command byte
if (offset === 0 && (buffer[0] !== 0xaa || buffer[1] !== command)) {
this.debug(
`Invalid buffer from ${this.getName()}, expected command 0x${command.toString(
16
)}, got 0x${buffer[0].toString(16)} 0x${buffer[1].toString(16)}`
);
return;
}
buffer.copy(result, offset);
offset += buffer.length;
lastPacketTime = Date.now();
// Clear any existing completion timer
if (completionTimer) clearTimeout(completionTimer);
// Wait 200ms after last packet to consider response complete
// This allows multi-packet responses to assemble properly
completionTimer = setTimeout(() => {
result = Uint8Array.prototype.slice.call(result, 0, offset);
this.rxChar.removeAllListeners("valuechanged");
clearTimeout(timer);
resolve(result);
}, 200);
};
// Set up listener BEFORE sending command
this.rxChar.on("valuechanged", valChanged);
});
// Small delay to ensure listener is attached
await new Promise(r => setTimeout(r, 100));
// Send the command
try {
await this.sendCommand(command);
} catch (err) {
this.rxChar.removeAllListeners("valuechanged");
throw err;
}
// Wait for response
return responsePromise;
}
async initGATTConnection(isReconnecting = false) {
await super.initGATTConnection(isReconnecting);
// Set up GATT characteristics
const gattServer = await this.device.gatt();
const txRxService = await gattServer.getPrimaryService(
this.constructor.TX_RX_SERVICE
);
this.rxChar = await txRxService.getCharacteristic(
this.constructor.NOTIFY_CHAR_UUID
);
this.txChar = await txRxService.getCharacteristic(
this.constructor.WRITE_CHAR_UUID
);
await this.rxChar.startNotifications();
return this;
}
/**
* Get and emit main battery data (voltage, current, SOC, temp)
* Uses command 0x21
*/
async getAndEmitBatteryInfo() {
return this.getBuffer(0x21).then((buffer) => {
// Debug logging to verify buffer received
this.debug(`Command 0x21 response: ${buffer.length} bytes, hex: ${buffer.slice(0, 30).toString('hex')}`);
["voltage", "current", "SOC", "temp1", "temp2", "temp3"].forEach((tag) => {
this.emitData(tag, buffer);
});
});
}
/**
* Get and emit individual cell voltages
* Uses command 0x22
*/
async getAndEmitCellVoltages() {
return this.getBuffer(0x22).then((buffer) => {
// Debug logging to verify buffer received
this.debug(`Command 0x22 response: ${buffer.length} bytes, hex: ${buffer.slice(0, 30).toString('hex')}`);
for (let i = 0; i < this.numberOfCells; i++) {
this.emitData(`cell${i}Voltage`, buffer);
}
});
}
async initGATTInterval() {
// Get static info once (manufacturer, model) at first connection
try {
const mfgBuffer = await this.getBuffer(0x10);
this.emitData("manufacturer", mfgBuffer);
const modelBuffer = await this.getBuffer(0x11);
this.emitData("model", modelBuffer);
} catch (e) {
this.debug(`Failed to get device info: ${e.message}`);
}
// Get first poll data before disconnecting
try {
await this.emitGATT();
} catch (error) {
this.debug(`Initial poll failed: ${error.message}`);
}
// Disconnect after initial data collection
await this.deactivateGATT().catch((e) => {
this.debug(`Error deactivating GATT Connection: ${e.message}`);
});
this.setState("WAITING");
// Set up polling interval - reconnect, poll, disconnect
this.intervalID = setInterval(async () => {
try {
this.setState("CONNECTING");
await this.initGATTConnection(true);
await this.emitGATT();
} catch (error) {
// Check if device has been removed from BlueZ cache
if (error.message && error.message.includes("interface not found in proxy object")) {
this.debug(`Device removed from BlueZ cache. Clearing stale connection state.`);
this.setError(`Device out of range or removed from Bluetooth cache. Waiting for rediscovery...`);
// Clear the interval to stop futile reconnection attempts
if (this.intervalID) {
clearInterval(this.intervalID);
this.intervalID = null;
}
// Set state to indicate waiting for rediscovery
this.setState("OUT_OF_RANGE");
return;
}
this.debug(error);
this.setError(`Unable to emit values for device: ${error.message}`);
} finally {
await this.deactivateGATT().catch((e) => {
// Suppress errors when device is already removed from BlueZ
if (!e.message || !e.message.includes("interface not found")) {
this.debug(`Error deactivating GATT Connection: ${e.message}`);
}
});
this.setState("WAITING");
}
}, this.pollFreq * 1000);
}
async deactivateGATT() {
await this.stopGATTNotifications(this.rxChar);
await super.deactivateGATT();
}
}
module.exports = HumsienkBMS;