Reading from a Liontron LiFePO4 Smart Bluetooth BMS
When converting my van, I've integrated quite some electrical components. To cover their needs, I've bought a LiFePO4 battery with 2.5kWh.
The battery apparently goes by a couple of names:
- Liontron LiFePO4 Smart Bluetooth BMS (12.8V, 200Ah)
- SolarFam Rechargeable Lithium Iron Phosphate Battery
- and probably more.
This battery comes with an app that provides information about the current charge, the number of cycles, and so on. However, this app was crashing quite a lot, but as it is the only way of knowing the charge, I kept checking it often.
Update: As I just learnt, they've updated the app so it can connect to several batteries at the same time. This app may or may not be better, I don't know. But you can now have multiple batteries on a single dashboard.
Decompile the APK
First, I had to get hold of the APK of the application. As it turned out, it was still present on my phone, so that was an easy one. Next I had to decompile it. But as I am not terribly familiar with recent Java development, I was not too keen on installing the tools required. So I've turned to an online version, which absolutely did the trick.
Judging from the namespace, it was obvious that the BMS was manufactured by the chinese company Jiabaida, which apparently offers white label BMS and corresponding apps.
The decompiled code was quite readable. I've searched for function calls that relate to Bluetooth Low Energy, and quickly found the service IDs and message structures involved. However, there's quite a lot going on in the app, and I guess they just removed the UI elements they did not need for the Liontron app. There's e.g. a camera image viewer in it, stuff that handles GPS trackers, and so on.
Functional prototype
If you happen to have a Liontron battery of your own and would like to connect to it: Here's some source code for you.
The code assumes the node-ble
package to be installed, and it starts a state machine that opens the BLE connection and tries to keep it alive.
To start it, you need to provide the device ID of your battery as well as a callback that is executed once device data has been received. The device ID resembles a MAC adress and looks something like this: 12:34:56:78:9A:BC
.
To obtain the device ID, you could either use your mobile (I believe I've used the LightBlue app back in the day on my Android device), or use the device scanning functions of node-ble
just as fine.
You can use the code as follows:
const myBms = new JiabaidaBms("12:34:56:78:9A:BC", (info) => {
console.log(`Data received: current: ${info.current}A, voltage: ${info.voltage}V, remaining power: ${info.remainingPower}Ah, nominal power: ${info.nominalPower}Ah, cycles: ${info.cycles}`);
});
And without further ado, this is what drives my BMS integration. It's MIT licensed, please report bugs.
import NodeBle, { createBluetooth } from "node-ble";
export type BmsData = {
/**
* Voltage
*/
voltage: number;
/**
* Current in Amps
*/
current: number;
/**
* Remaining power in Ah
*/
remainingPower: number;
/**
* Nominal power in Ah
*/
nominalPower: number;
/**
* Number of battery cycles
*/
cycles: number;
}
export class JiabaidaBms {
private adapter?: NodeBle.Adapter;
private device?: NodeBle.Device;
private destroyBt?: () => void;
/**
* The following UUIDs are apparently hard-coded in the BMS and the app
*/
private static serviceUuid = "0000ff00-0000-1000-8000-00805f9b34fb";
private static readCharacteristicUuid = "0000ff01-0000-1000-8000-00805f9b34fb";
private static writeCharacteristicUuid = "0000ff02-0000-1000-8000-00805f9b34fb";
private readCharacteristic?: NodeBle.GattCharacteristic;
private writeCharacteristic?: NodeBle.GattCharacteristic;
private isStarted = false;
private timeout?: NodeJS.Timeout;
private lastSuccessfulPoll: Date = new Date();
constructor(private deviceAddress: string, private onDataReceived: async (date: BmsData) => void | undefined) {
// bind methods. This is necessary because the methods are called from the event handlers.
// If we don't bind them, the 'this' context will be lost and the methods will fail.
// This is a common pattern in JavaScript.
// I have to admit that it's a bit ugly, but it's the way it is.
this.start = this.start.bind(this);
this.destroy = this.destroy.bind(this);
this.stateMachine = this.stateMachine.bind(this);
// Kick off state machine
queueMicrotask(async () => {
await this.stateMachine();
});
}
public async start() {
try {
const { bluetooth, destroy } = createBluetooth();
this.adapter = await bluetooth.defaultAdapter()
this.device = await this.adapter.waitDevice(this.deviceAddress)
await this.device.connect();
console.log(`Connected to ${this.deviceAddress}`);
const gattServer = await this.device.gatt();
const service = await gattServer.getPrimaryService(JiabaidaBms.serviceUuid);
console.log(`Service: UUID: ${await service.getUUID()}, Characteristics: ${(await service.characteristics()).join(", ")}`);
this.readCharacteristic = await service.getCharacteristic(JiabaidaBms.readCharacteristicUuid);
this.writeCharacteristic = await service.getCharacteristic(JiabaidaBms.writeCharacteristicUuid);
// register callback that digests the BMS data we'll be polling
this.readCharacteristic.on('valuechanged', buffer => {
try {
// Check for the magic word
if (buffer[0] !== 0xdd || buffer[1] === 0x03)
return;
const info = this.decodeBaseInfo(buffer);
if (info !== undefined) {
// we received BMS data
if (this.onDataReceived)
await this.onDataReceived(info);
// Reset the watchdog
this.lastSuccessfulPoll = new Date();
}
} catch (e) {
this.isStarted = false;
console.error(`Receiving BMS data failed: ${e.toString()}`);
}
});
// start listening for notifications
await this.readCharacteristic.startNotifications()
// Periodically poll data from the BMS
this.timeout = setInterval(async () => {
if (!this.isStarted)
return;
// dispatch a BMSBaseInfoCMDEntity
try {
if (this.writeCharacteristic) {
const buffer = Buffer.from([0xdd, 0xa5, 0x03, 0x00, 0xff, 0xfd, 0x77]);
await this.writeCharacteristic.writeValue(buffer);
}
} catch (e) {
this.isStarted = false;
console.error(`Requesting BMS data failed: ${e.toString()}`);
}
}, 1000);
this.isStarted = true;
this.lastSuccessfulPoll = new Date();
console.log("Successfully initialized BMS communication");
} catch(e) {
this.isStarted = false;
await this.destroy();
console.error(`Initializing BMS failed: ${e.toString()}`);
}
}
/**
* State machine that ensures the BMS communication is running. Issues it's
* next run 5 seconds after the last one finished.
*/
private async stateMachine() {
try {
if (!this.isStarted)
await this.start();
if (this.isStarted) {
// Check if last communication was too long ago
const delta = new Date().getTime() - this.lastSuccessfulPoll?.getTime();
if (delta > 60000) {
console.error("Resetting BMS communication because of missing heartbeat");
await this.destroy();
// Reset this so the watchdog doesn't trigger again immediately
this.lastSuccessfulPoll = new Date();
};
}
} catch (e) {
console.error(`State machine failed: ${e.toString()}`);
} finally {
// Schedule next state machine run
setTimeout(this.stateMachine, 5000);
}
}
/**
* Tear down the BMS communication
*/
private async destroy() {
try {
if (this.timeout) {
clearInterval(this.timeout);
delete this.timeout;
}
if (this.readCharacteristic) {
await this.readCharacteristic.stopNotifications();
this.readCharacteristic.removeAllListeners();
delete this.readCharacteristic;
}
if (this.writeCharacteristic) {
this.writeCharacteristic.removeAllListeners();
delete this.writeCharacteristic;
}
if (this.device) {
this.device?.removeAllListeners();
await this.device?.disconnect();
delete this.device;
}
if (this.destroyBt) {
await this.destroyBt();
delete this.destroyBt;
}
console.log("BMS communication teared down");
} catch (e) {
console.error(`Tearing down BMS communication failed: ${e.toString()}`);
} finally {
this.isStarted = false;
}
}
private decodeBaseInfo(buffer: Buffer) {
if (buffer.length < 14)
return;
return {
voltage: this.shortToFloat(buffer[4], buffer[5], 100),
current: this.shortToFloat(buffer[6], buffer[7], 100),
remainingPower: this.shortToFloat(buffer[8], buffer[9], 100),
nominalPower: this.shortToFloat(buffer[10], buffer[11], 100),
cycles: this.shortToFloat(buffer[12], buffer[13], 1),
} as BmsData;
}
/**
* Converts fixed-point 16-bit integer to float
*/
private shortToFloat(high: number, low: number, scale = 100) {
const value = ((high & 255) << 8) + (low & 255);
if ((high >> 7) === 0)
return value / scale;
// negative value
return (value - 0x10000) / scale;
}
}