Una repositoy che integra un plugin custom su SignalK per ottenere tutti i dati del BMS della batteria
- Introdotta l'implementazione JavaScript per la comunicazione BMS in bmscore.js, inclusi i metodi per il recupero dati e la gestione degli errori. - Creato errors.js per mappare i codici di errore dal formato Python a quello JavaScript.
This commit is contained in:
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
.DS_Store
|
||||||
|
node_modules
|
||||||
|
|
||||||
|
.env
|
||||||
|
|
||||||
|
.claude
|
||||||
150
index.js
Normal file
150
index.js
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
// SignalK plugin — Daly BMS via USB/RS485 (solo USB).
|
||||||
|
// Polling periodico → delta SignalK. POST read-only on-demand.
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const DalyProtocol = require('./lib/daly-protocol');
|
||||||
|
|
||||||
|
const DEFAULTS = Object.freeze({
|
||||||
|
device: '/dev/ttyUSB0',
|
||||||
|
batteryId: 'house',
|
||||||
|
pollSocMs: 2000,
|
||||||
|
pollStatusMs: 30000,
|
||||||
|
pollCellsMs: 10000,
|
||||||
|
retries: 5
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = function (app) {
|
||||||
|
const plugin = {
|
||||||
|
id: 'signalk-daly-bms',
|
||||||
|
name: 'Daly BMS (USB)',
|
||||||
|
description: 'Legge un BMS Daly via seriale USB/RS485 e pubblica su SignalK'
|
||||||
|
};
|
||||||
|
|
||||||
|
plugin.schema = {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
device: { type: 'string', default: DEFAULTS.device, title: 'Serial device (USB)' },
|
||||||
|
batteryId: { type: 'string', default: DEFAULTS.batteryId, title: 'SignalK battery id' },
|
||||||
|
pollSocMs: { type: 'integer', default: DEFAULTS.pollSocMs, title: 'Polling SOC/V/A (ms)' },
|
||||||
|
pollStatusMs: { type: 'integer', default: DEFAULTS.pollStatusMs, title: 'Polling status (ms)' },
|
||||||
|
pollCellsMs: { type: 'integer', default: DEFAULTS.pollCellsMs, title: 'Polling celle/temperature (ms)' },
|
||||||
|
retries: { type: 'integer', default: DEFAULTS.retries, title: 'Retry per richiesta' }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let bms = null;
|
||||||
|
let opts = null;
|
||||||
|
const timers = new Set();
|
||||||
|
|
||||||
|
const sk = (path, value) => ({ path: `electrical.batteries.${opts.batteryId}.${path}`, value });
|
||||||
|
const sendDelta = values => values && values.length && app.handleMessage(plugin.id, { updates: [{ values }] });
|
||||||
|
|
||||||
|
const safe = async (label, fn) => {
|
||||||
|
try { return await fn(); }
|
||||||
|
catch (e) { app.error(`${label}: ${e.message}`); return null; }
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- pollers ---
|
||||||
|
async function pollSoc() {
|
||||||
|
const s = await safe('pollSoc', () => bms.getSoc());
|
||||||
|
if (!s) return;
|
||||||
|
sendDelta([
|
||||||
|
sk('voltage', s.total_voltage),
|
||||||
|
sk('current', s.current), // SignalK: + = uscita (scarica), come Daly
|
||||||
|
sk('capacity.stateOfCharge', s.soc_percent / 100)
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pollStatus() {
|
||||||
|
const s = await safe('pollStatus', () => bms.getStatus());
|
||||||
|
if (!s) return;
|
||||||
|
sendDelta([
|
||||||
|
sk('cycles', s.cycles),
|
||||||
|
sk('cellsCount', s.cells),
|
||||||
|
sk('chargerRunning', s.charger_running),
|
||||||
|
sk('loadRunning', s.load_running)
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pollCells() {
|
||||||
|
const [cells, temps, errs] = await Promise.all([
|
||||||
|
safe('getCellVoltages', () => bms.getCellVoltages()),
|
||||||
|
safe('getTemperatures', () => bms.getTemperatures()),
|
||||||
|
safe('getErrors', () => bms.getErrors())
|
||||||
|
]);
|
||||||
|
const values = [];
|
||||||
|
if (cells) for (const [k, v] of Object.entries(cells)) values.push(sk(`cells.${k}.voltage`, v));
|
||||||
|
if (temps) {
|
||||||
|
const list = Object.values(temps);
|
||||||
|
const toK = c => Math.round((c + 273.15) * 100) / 100; // °C → K, 2 decimali
|
||||||
|
if (list.length) values.push(sk('temperature', toK(list[0])));
|
||||||
|
for (const [k, v] of Object.entries(temps)) values.push(sk(`temperatures.${k}`, toK(v)));
|
||||||
|
}
|
||||||
|
if (errs) values.push(sk('errors', errs));
|
||||||
|
sendDelta(values);
|
||||||
|
}
|
||||||
|
|
||||||
|
const startTimer = (fn, ms) => {
|
||||||
|
const t = setInterval(() => { fn(); }, ms);
|
||||||
|
timers.add(t);
|
||||||
|
};
|
||||||
|
|
||||||
|
plugin.start = async function (options = {}) {
|
||||||
|
opts = { ...DEFAULTS, ...options };
|
||||||
|
app.setPluginStatus(`Apertura ${opts.device}…`);
|
||||||
|
|
||||||
|
bms = new DalyProtocol({
|
||||||
|
device: opts.device,
|
||||||
|
retries: opts.retries,
|
||||||
|
log: msg => app.debug(msg)
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await bms.open();
|
||||||
|
await bms.getStatus(); // necessario per cell/temp
|
||||||
|
app.setPluginStatus(`Connesso a ${opts.device}`);
|
||||||
|
} catch (e) {
|
||||||
|
app.setPluginError(`init failed: ${e.message}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// primo giro immediato + polling periodico
|
||||||
|
await Promise.all([pollStatus(), pollSoc(), pollCells()]);
|
||||||
|
startTimer(pollSoc, opts.pollSocMs);
|
||||||
|
startTimer(pollStatus, opts.pollStatusMs);
|
||||||
|
startTimer(pollCells, opts.pollCellsMs);
|
||||||
|
};
|
||||||
|
|
||||||
|
plugin.stop = async function () {
|
||||||
|
for (const t of timers) clearInterval(t);
|
||||||
|
timers.clear();
|
||||||
|
if (bms) { await bms.close().catch(() => {}); bms = null; }
|
||||||
|
app.setPluginStatus('stopped');
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- API REST: solo letture on-demand ---
|
||||||
|
plugin.registerWithRouter = function (router) {
|
||||||
|
const wrap = fn => async (_req, res) => {
|
||||||
|
if (!bms) return res.status(503).json({ error: 'plugin not started' });
|
||||||
|
try { res.json(await fn()); }
|
||||||
|
catch (e) { res.status(500).json({ error: e.message }); }
|
||||||
|
};
|
||||||
|
router.post('/soc', wrap(() => bms.getSoc()));
|
||||||
|
router.post('/status', wrap(() => bms.getStatus()));
|
||||||
|
router.post('/temperatures', wrap(() => bms.getTemperatures()));
|
||||||
|
router.post('/cell-voltages', wrap(() => bms.getCellVoltages()));
|
||||||
|
router.post('/cell-range', wrap(() => bms.getCellVoltageRange()));
|
||||||
|
router.post('/temp-range', wrap(() => bms.getTemperatureRange()));
|
||||||
|
router.post('/mosfet', wrap(() => bms.getMosfetStatus()));
|
||||||
|
router.post('/errors', wrap(() => bms.getErrors()));
|
||||||
|
router.post('/all', wrap(async () => {
|
||||||
|
const [soc, status, cell_voltages, temperatures, mosfet, errors] = await Promise.all([
|
||||||
|
bms.getSoc(), bms.getStatus(), bms.getCellVoltages(),
|
||||||
|
bms.getTemperatures(), bms.getMosfetStatus(), bms.getErrors()
|
||||||
|
]);
|
||||||
|
return { soc, status, cell_voltages, temperatures, mosfet, errors };
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
return plugin;
|
||||||
|
};
|
||||||
18
package.json
Normal file
18
package.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"name": "meb-battery",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Un plugin per il BMS della batteria",
|
||||||
|
"main": "index.js",
|
||||||
|
"keywords": [
|
||||||
|
"signalk-node-server-plugin",
|
||||||
|
"signalk-category-utility",
|
||||||
|
"daly",
|
||||||
|
"bms",
|
||||||
|
"battery"
|
||||||
|
],
|
||||||
|
"author": "MEB Team",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"serialport": "^10.2.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
7
python-reference/__init__.py
Normal file
7
python-reference/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
from .daly_bms import DalyBMS
|
||||||
|
from .daly_sinowealth import DalyBMSSinowealth
|
||||||
|
try:
|
||||||
|
from .daly_bms_bluetooth import DalyBMSBluetooth
|
||||||
|
except ImportError:
|
||||||
|
# Bluetooth is optional and requires bleak to be installed
|
||||||
|
pass
|
||||||
254
python-reference/daly-bms-cli
Executable file
254
python-reference/daly-bms-cli
Executable file
@@ -0,0 +1,254 @@
|
|||||||
|
#!/usr/bin/python3
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from dalybms import DalyBMS
|
||||||
|
from dalybms import DalyBMSSinowealth
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("-d", "--device",
|
||||||
|
help="RS485 device, e.g. /dev/ttyUSB0",
|
||||||
|
type=str, required=True)
|
||||||
|
parser.add_argument("--uart", help="UART instead of RS485", action="store_true")
|
||||||
|
parser.add_argument("--sinowealth", help="BMS with Sinowealth chip", action="store_true")
|
||||||
|
parser.add_argument("--status", help="show status", action="store_true")
|
||||||
|
parser.add_argument("--soc", help="show voltage, current, SOC", action="store_true")
|
||||||
|
parser.add_argument("--mosfet", help="show mosfet status", action="store_true")
|
||||||
|
parser.add_argument("--cell-voltages", help="show cell voltages", action="store_true")
|
||||||
|
parser.add_argument("--temperatures", help="show temperature sensor values", action="store_true")
|
||||||
|
parser.add_argument("--balancing", help="show cell balancing status", action="store_true")
|
||||||
|
parser.add_argument("--errors", help="show BMS errors", action="store_true")
|
||||||
|
parser.add_argument("--all", help="show all", action="store_true")
|
||||||
|
parser.add_argument("--check", help="Nagios style check", action="store_true")
|
||||||
|
parser.add_argument("--set-charge-mosfet", help="'on' or 'off'", type=str)
|
||||||
|
parser.add_argument("--set-discharge-mosfet", help="'on' or 'off'", type=str)
|
||||||
|
parser.add_argument("--set-soc", help="'0.0' to '100.0'", type=str)
|
||||||
|
parser.add_argument("--restart", help="restart bms", action="store_true")
|
||||||
|
parser.add_argument("--retry", help="retry X times if the request fails, default 5", type=int, default=5)
|
||||||
|
parser.add_argument("--verbose", help="Verbose output", action="store_true")
|
||||||
|
|
||||||
|
parser.add_argument("--mqtt", help="Write output to MQTT", action="store_true")
|
||||||
|
parser.add_argument("--mqtt-hass", help="MQTT Home Assistant Mode", action="store_true")
|
||||||
|
|
||||||
|
parser.add_argument("--mqtt-topic",
|
||||||
|
help="MQTT topic to write to. default daly_bms",
|
||||||
|
type=str,
|
||||||
|
default="daly_bms")
|
||||||
|
|
||||||
|
parser.add_argument("--mqtt-broker",
|
||||||
|
help="MQTT broker (server). default localhost",
|
||||||
|
type=str,
|
||||||
|
default="localhost")
|
||||||
|
|
||||||
|
parser.add_argument("--mqtt-port",
|
||||||
|
help="MQTT port. default 1883",
|
||||||
|
type=int,
|
||||||
|
default=1883)
|
||||||
|
|
||||||
|
parser.add_argument("--mqtt-user",
|
||||||
|
help="Username to authenticate MQTT with",
|
||||||
|
type=str)
|
||||||
|
|
||||||
|
parser.add_argument("--mqtt-password",
|
||||||
|
help="Password to authenticate MQTT with",
|
||||||
|
type=str)
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
log_format = '%(levelname)-8s [%(filename)s:%(lineno)d] %(message)s'
|
||||||
|
if args.verbose:
|
||||||
|
level = logging.DEBUG
|
||||||
|
else:
|
||||||
|
level = logging.WARNING
|
||||||
|
|
||||||
|
logging.basicConfig(level=level, format=log_format, datefmt='%H:%M:%S')
|
||||||
|
|
||||||
|
logger = logging.getLogger()
|
||||||
|
|
||||||
|
if args.uart:
|
||||||
|
address = 8
|
||||||
|
else:
|
||||||
|
address = 4
|
||||||
|
|
||||||
|
if args.sinowealth:
|
||||||
|
bms = DalyBMSSinowealth(request_retries=args.retry, logger=logger)
|
||||||
|
else:
|
||||||
|
bms = DalyBMS(request_retries=args.retry, address=address, logger=logger)
|
||||||
|
bms.connect(device=args.device)
|
||||||
|
|
||||||
|
result = False
|
||||||
|
|
||||||
|
mqtt_client = None
|
||||||
|
if args.mqtt:
|
||||||
|
import paho.mqtt.client as paho
|
||||||
|
|
||||||
|
mqtt_client = paho.Client()
|
||||||
|
mqtt_client.enable_logger(logger)
|
||||||
|
mqtt_client.username_pw_set(args.mqtt_user, args.mqtt_password)
|
||||||
|
mqtt_client.connect(args.mqtt_broker, port=args.mqtt_port)
|
||||||
|
|
||||||
|
|
||||||
|
def build_mqtt_hass_config_discovery(base):
|
||||||
|
# Instead of daly_bms should be here added a proper name (unique), like serial or something
|
||||||
|
# At this point it can be used only one daly_bms system with hass discovery
|
||||||
|
|
||||||
|
hass_config_topic = f'homeassistant/sensor/daly_bms/{base.replace("/", "_")}/config'
|
||||||
|
hass_config_data = {}
|
||||||
|
|
||||||
|
hass_config_data["unique_id"] = f'daly_bms_{base.replace("/", "_")}'
|
||||||
|
hass_config_data["name"] = f'Daly BMS {base.replace("/", " ")}'
|
||||||
|
|
||||||
|
if 'soc_percent' in base:
|
||||||
|
hass_config_data["device_class"] = 'battery'
|
||||||
|
hass_config_data["unit_of_measurement"] = '%'
|
||||||
|
elif 'voltage' in base and not ('lowest_cell' in base or 'highest_cell' in base):
|
||||||
|
hass_config_data["device_class"] = 'voltage'
|
||||||
|
hass_config_data["unit_of_measurement"] = 'V'
|
||||||
|
elif 'current' in base:
|
||||||
|
hass_config_data["device_class"] = 'current'
|
||||||
|
hass_config_data["unit_of_measurement"] = 'A'
|
||||||
|
elif 'temperatures' in base:
|
||||||
|
hass_config_data["device_class"] = 'temperature'
|
||||||
|
hass_config_data["unit_of_measurement"] = '°C'
|
||||||
|
elif 'capacity' in 'base':
|
||||||
|
hass_config_data["device_class"] = 'energy'
|
||||||
|
hass_config_data["unit_of_measurement"] = 'Ah'
|
||||||
|
else:
|
||||||
|
pass
|
||||||
|
|
||||||
|
hass_config_data["json_attributes_topic"] = f'{args.mqtt_topic}{base}'
|
||||||
|
hass_config_data["state_topic"] = f'{args.mqtt_topic}{base}'
|
||||||
|
|
||||||
|
hass_device = {
|
||||||
|
"identifiers": ['daly_bms'],
|
||||||
|
"manufacturer": 'Daly',
|
||||||
|
"model": 'Currently not available',
|
||||||
|
"name": 'Daly BMS',
|
||||||
|
"sw_version": 'Currently not available'
|
||||||
|
}
|
||||||
|
hass_config_data["device"] = hass_device
|
||||||
|
|
||||||
|
return hass_config_topic, json.dumps(hass_config_data)
|
||||||
|
|
||||||
|
|
||||||
|
def mqtt_single_out(topic, data, retain=False):
|
||||||
|
logger.debug(f'Send data: {data} on topic: {topic}, retain flag: {retain}')
|
||||||
|
mqtt_client.publish(topic, data, retain=retain)
|
||||||
|
|
||||||
|
|
||||||
|
def mqtt_iterator(result, base=''):
|
||||||
|
for key in result.keys():
|
||||||
|
if type(result[key]) == dict:
|
||||||
|
mqtt_iterator(result[key], f'{base}/{key}')
|
||||||
|
else:
|
||||||
|
if args.mqtt_hass:
|
||||||
|
logger.debug('Sending out hass discovery message')
|
||||||
|
topic, output = build_mqtt_hass_config_discovery(f'{base}/{key}')
|
||||||
|
mqtt_single_out(topic, output, retain=True)
|
||||||
|
|
||||||
|
if type(result[key]) == list:
|
||||||
|
val = json.dumps(result[key])
|
||||||
|
else:
|
||||||
|
val = result[key]
|
||||||
|
|
||||||
|
mqtt_single_out(f'{args.mqtt_topic}{base}/{key}', val)
|
||||||
|
|
||||||
|
|
||||||
|
def print_result(result):
|
||||||
|
if args.mqtt:
|
||||||
|
mqtt_iterator(result)
|
||||||
|
else:
|
||||||
|
print(json.dumps(result, indent=2))
|
||||||
|
|
||||||
|
|
||||||
|
if args.status:
|
||||||
|
result = bms.get_status()
|
||||||
|
print_result(result)
|
||||||
|
if args.soc:
|
||||||
|
result = bms.get_soc()
|
||||||
|
print_result(result)
|
||||||
|
if args.mosfet:
|
||||||
|
result = bms.get_mosfet_status()
|
||||||
|
print_result(result)
|
||||||
|
if args.cell_voltages:
|
||||||
|
if not args.status:
|
||||||
|
bms.get_status()
|
||||||
|
result = bms.get_cell_voltages()
|
||||||
|
print_result(result)
|
||||||
|
if args.temperatures:
|
||||||
|
result = bms.get_temperatures()
|
||||||
|
print_result(result)
|
||||||
|
if args.balancing:
|
||||||
|
result = bms.get_balancing_status()
|
||||||
|
print_result(result)
|
||||||
|
if args.errors:
|
||||||
|
result = bms.get_errors()
|
||||||
|
print_result(result)
|
||||||
|
if args.all:
|
||||||
|
result = bms.get_all()
|
||||||
|
print_result(result)
|
||||||
|
|
||||||
|
if args.check:
|
||||||
|
status = bms.get_status()
|
||||||
|
status_code = 0 # OK
|
||||||
|
status_codes = ('OK', 'WARNING', 'CRITICAL', 'UNKNOWN')
|
||||||
|
status_line = ''
|
||||||
|
|
||||||
|
data = bms.get_soc()
|
||||||
|
perfdata = []
|
||||||
|
if data:
|
||||||
|
for key, value in data.items():
|
||||||
|
perfdata.append('%s=%s' % (key, value))
|
||||||
|
|
||||||
|
# todo: read errors
|
||||||
|
|
||||||
|
if status_code == 0:
|
||||||
|
status_line = '%0.1f volt, %0.1f amper' % (data['total_voltage'], data['current'])
|
||||||
|
|
||||||
|
print("%s - %s | %s" % (status_codes[status_code], status_line, " ".join(perfdata)))
|
||||||
|
sys.exit(status_code)
|
||||||
|
|
||||||
|
if args.set_charge_mosfet:
|
||||||
|
if args.set_charge_mosfet == 'on':
|
||||||
|
on = True
|
||||||
|
elif args.set_charge_mosfet == 'off':
|
||||||
|
on = False
|
||||||
|
else:
|
||||||
|
print("invalid value '%s', expected 'on' or 'off'" % args.set_charge_mosfet)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
result = bms.set_charge_mosfet(on=on)
|
||||||
|
|
||||||
|
if args.set_discharge_mosfet:
|
||||||
|
if args.set_discharge_mosfet == 'on':
|
||||||
|
on = True
|
||||||
|
elif args.set_discharge_mosfet == 'off':
|
||||||
|
on = False
|
||||||
|
else:
|
||||||
|
print("invalid value '%s', expected 'on' or 'off'" % args.set_discharge_mosfet)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
result = bms.set_discharge_mosfet(on=on)
|
||||||
|
|
||||||
|
if args.set_soc:
|
||||||
|
try :
|
||||||
|
v = float(args.set_soc)
|
||||||
|
except :
|
||||||
|
print("invalid value '%s', expected float value betwen 0 and 100" % args.set_soc)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
result = bms.set_soc(v)
|
||||||
|
|
||||||
|
if args.restart:
|
||||||
|
result = bms.restart()
|
||||||
|
|
||||||
|
|
||||||
|
if mqtt_client:
|
||||||
|
mqtt_client.disconnect()
|
||||||
|
|
||||||
|
bms.disconnect()
|
||||||
|
|
||||||
|
if not result:
|
||||||
|
sys.exit(1)
|
||||||
403
python-reference/daly_bms.py
Normal file
403
python-reference/daly_bms.py
Normal file
@@ -0,0 +1,403 @@
|
|||||||
|
import serial
|
||||||
|
import struct
|
||||||
|
import time
|
||||||
|
import math
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from .error_codes import ERROR_CODES
|
||||||
|
|
||||||
|
|
||||||
|
class DalyBMS:
|
||||||
|
def __init__(self, request_retries=3, address=4, logger=None):
|
||||||
|
"""
|
||||||
|
|
||||||
|
:param request_retries: How often read requests should get repeated in case that they fail (Default: 3).
|
||||||
|
:param address: Source address for commands sent to the BMS (4 for RS485, 8 for UART/Bluetooth)
|
||||||
|
:param logger: Python Logger object for output (Default: None)
|
||||||
|
"""
|
||||||
|
self.status = None
|
||||||
|
if logger:
|
||||||
|
self.logger = logger
|
||||||
|
else:
|
||||||
|
self.logger = logging.getLogger(__name__)
|
||||||
|
self.request_retries = request_retries
|
||||||
|
self.address = address # 4 = USB, 8 = Bluetooth
|
||||||
|
|
||||||
|
def connect(self, device):
|
||||||
|
"""
|
||||||
|
Connect to a serial device
|
||||||
|
|
||||||
|
:param device: Serial device, e.g. /dev/ttyUSB0
|
||||||
|
"""
|
||||||
|
self.serial = serial.Serial(
|
||||||
|
port=device,
|
||||||
|
baudrate=9600,
|
||||||
|
bytesize=serial.EIGHTBITS,
|
||||||
|
parity=serial.PARITY_NONE,
|
||||||
|
stopbits=serial.STOPBITS_ONE,
|
||||||
|
timeout=0.5,
|
||||||
|
xonxoff=False,
|
||||||
|
writeTimeout=0.5
|
||||||
|
)
|
||||||
|
self.get_status()
|
||||||
|
|
||||||
|
def disconnect(self):
|
||||||
|
if self.serial and self.serial.is_open:
|
||||||
|
self.serial.close()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _calc_crc(message_bytes):
|
||||||
|
"""
|
||||||
|
Calculate the checksum of a message
|
||||||
|
|
||||||
|
:param message_bytes: Bytes for which the checksum should get calculated
|
||||||
|
:return: Checksum as bytes
|
||||||
|
"""
|
||||||
|
return bytes([sum(message_bytes) & 0xFF])
|
||||||
|
|
||||||
|
def _format_message(self, command, extra=""):
|
||||||
|
"""
|
||||||
|
Takes the command ID and formats a request message
|
||||||
|
|
||||||
|
:param command: Command ID ("90" - "98")
|
||||||
|
:return: Request message as bytes
|
||||||
|
"""
|
||||||
|
# 95 -> a58095080000000000000000c2
|
||||||
|
message = "a5%i0%s08%s" % (self.address, command, extra)
|
||||||
|
message = message.ljust(24, "0")
|
||||||
|
message_bytes = bytearray.fromhex(message)
|
||||||
|
message_bytes += self._calc_crc(message_bytes)
|
||||||
|
self.logger.debug("w %s" % message_bytes.hex())
|
||||||
|
return message_bytes
|
||||||
|
|
||||||
|
def _read_request(self, command, extra="", max_responses=1, return_list=False):
|
||||||
|
"""
|
||||||
|
Sends a read request to the BMS and reads the response. In case it fails, it retries 'max_responses' times.
|
||||||
|
|
||||||
|
:param command: Command ID ("90" - "98")
|
||||||
|
:param max_responses: For how many response packages it should wait (Default: 1).
|
||||||
|
:return: Request message as bytes or False
|
||||||
|
"""
|
||||||
|
response_data = None
|
||||||
|
x = None
|
||||||
|
for x in range(0, self.request_retries):
|
||||||
|
response_data = self._read(
|
||||||
|
command=command,
|
||||||
|
extra=extra,
|
||||||
|
max_responses=max_responses,
|
||||||
|
return_list=return_list)
|
||||||
|
if not response_data:
|
||||||
|
self.logger.debug("%x. try failed, retrying..." % (x + 1))
|
||||||
|
time.sleep(0.2)
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
if not response_data:
|
||||||
|
self.logger.error('%s failed after %s tries' % (command, x + 1))
|
||||||
|
return False
|
||||||
|
return response_data
|
||||||
|
|
||||||
|
def _read(self, command, extra="", max_responses=1, return_list=False):
|
||||||
|
self.logger.debug("-- %s ------------------------" % command)
|
||||||
|
if not self.serial.is_open:
|
||||||
|
self.serial.open()
|
||||||
|
message_bytes = self._format_message(command, extra=extra)
|
||||||
|
|
||||||
|
# clear all buffers, in case something is left from a previous command that failed
|
||||||
|
self.serial.reset_input_buffer()
|
||||||
|
self.serial.reset_output_buffer()
|
||||||
|
|
||||||
|
if not self.serial.write(message_bytes):
|
||||||
|
self.logger.error("serial write failed for command" % command)
|
||||||
|
return False
|
||||||
|
x = 0
|
||||||
|
response_data = []
|
||||||
|
while True:
|
||||||
|
b = self.serial.read(13)
|
||||||
|
if len(b) == 0:
|
||||||
|
self.logger.debug("%i empty response for command %s" % (x, command))
|
||||||
|
break
|
||||||
|
self.logger.debug("%i %s %s" % (x, b.hex(), len(b)))
|
||||||
|
x += 1
|
||||||
|
response_crc = self._calc_crc(b[:-1])
|
||||||
|
if response_crc != b[-1:]:
|
||||||
|
self.logger.debug("response crc mismatch: %s != %s" % (response_crc.hex(), b[-1:].hex()))
|
||||||
|
header = b[0:4].hex()
|
||||||
|
# todo: verify more header fields
|
||||||
|
if header[4:6] != command:
|
||||||
|
self.logger.debug("invalid header %s: wrong command (%s != %s)" % (header, header[4:6], command))
|
||||||
|
continue
|
||||||
|
data = b[4:-1]
|
||||||
|
response_data.append(data)
|
||||||
|
if x == max_responses:
|
||||||
|
break
|
||||||
|
|
||||||
|
if return_list or len(response_data) > 1:
|
||||||
|
return response_data
|
||||||
|
elif len(response_data) == 1:
|
||||||
|
return response_data[0]
|
||||||
|
else:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get_soc(self, response_data=None):
|
||||||
|
# SOC of Total Voltage Current
|
||||||
|
if not response_data:
|
||||||
|
response_data = self._read_request("90")
|
||||||
|
if not response_data:
|
||||||
|
return False
|
||||||
|
|
||||||
|
parts = struct.unpack('>h h h h', response_data)
|
||||||
|
data = {
|
||||||
|
"total_voltage": parts[0] / 10,
|
||||||
|
# "x_voltage": parts[1] / 10, # always 0
|
||||||
|
"current": (parts[2] - 30000) / 10, # negative=charging, positive=discharging
|
||||||
|
"soc_percent": parts[3] / 10
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
|
||||||
|
def get_cell_voltage_range(self, response_data=None):
|
||||||
|
# Cells with the maximum and minimum voltage
|
||||||
|
if not response_data:
|
||||||
|
response_data = self._read_request("91")
|
||||||
|
if not response_data:
|
||||||
|
return False
|
||||||
|
|
||||||
|
parts = struct.unpack('>h b h b 2x', response_data)
|
||||||
|
data = {
|
||||||
|
"highest_voltage": parts[0] / 1000,
|
||||||
|
"highest_cell": parts[1],
|
||||||
|
"lowest_voltage": parts[2] / 1000,
|
||||||
|
"lowest_cell": parts[3],
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
|
||||||
|
def get_temperature_range(self, response_data=None):
|
||||||
|
# Temperature in degrees celsius
|
||||||
|
if not response_data:
|
||||||
|
response_data = self._read_request("92")
|
||||||
|
if not response_data:
|
||||||
|
return False
|
||||||
|
parts = struct.unpack('>b b b b 4x', response_data)
|
||||||
|
data = {
|
||||||
|
"highest_temperature": parts[0] - 40,
|
||||||
|
"highest_sensor": parts[1],
|
||||||
|
"lowest_temperature": parts[2] - 40,
|
||||||
|
"lowest_sensor": parts[3],
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
|
||||||
|
def get_mosfet_status(self, response_data=None):
|
||||||
|
# Charge/discharge, MOS status
|
||||||
|
if not response_data:
|
||||||
|
response_data = self._read_request("93")
|
||||||
|
if not response_data:
|
||||||
|
return False
|
||||||
|
# todo: implement
|
||||||
|
self.logger.debug(response_data.hex())
|
||||||
|
|
||||||
|
parts = struct.unpack('>b ? ? B l', response_data)
|
||||||
|
|
||||||
|
if parts[0] == 0:
|
||||||
|
mode = "stationary"
|
||||||
|
elif parts[0] == 1:
|
||||||
|
mode = "charging"
|
||||||
|
else:
|
||||||
|
mode = "discharging"
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"mode": mode,
|
||||||
|
"charging_mosfet": parts[1],
|
||||||
|
"discharging_mosfet": parts[2],
|
||||||
|
# "bms_cycles": parts[3], unstable result
|
||||||
|
"capacity_ah": parts[4] / 1000,
|
||||||
|
}
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
def get_status(self, response_data=None):
|
||||||
|
if not response_data:
|
||||||
|
response_data = self._read_request("94")
|
||||||
|
if not response_data:
|
||||||
|
return False
|
||||||
|
|
||||||
|
parts = struct.unpack('>b b ? ? b h x', response_data)
|
||||||
|
state_bits = bin(parts[4])[2:]
|
||||||
|
state_names = ["DI1", "DI2", "DI3", "DI4", "DO1", "DO2", "DO3", "DO4"]
|
||||||
|
states = {}
|
||||||
|
state_index = 0
|
||||||
|
for bit in reversed(state_bits):
|
||||||
|
if len(state_bits) == state_index:
|
||||||
|
break
|
||||||
|
states[state_names[state_index]] = bool(int(bit))
|
||||||
|
state_index += 1
|
||||||
|
data = {
|
||||||
|
"cells": parts[0], # number of cells
|
||||||
|
"temperature_sensors": parts[1], # number of sensors
|
||||||
|
"charger_running": parts[2],
|
||||||
|
"load_running": parts[3],
|
||||||
|
# "state_bits": state_bits,
|
||||||
|
"states": states,
|
||||||
|
"cycles": parts[5], # number of charge/discharge cycles
|
||||||
|
}
|
||||||
|
self.status = data
|
||||||
|
return data
|
||||||
|
|
||||||
|
def _calc_num_responses(self, status_field, num_per_frame):
|
||||||
|
if not self.status:
|
||||||
|
self.logger.error("get_status has to be called at least once before calling get_cell_voltages")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# each response message includes 3 cell voltages
|
||||||
|
if self.address == 8:
|
||||||
|
# via Bluetooth the BMS returns all frames, even when they don't have data
|
||||||
|
if status_field == 'cell_voltages':
|
||||||
|
max_responses = 16
|
||||||
|
elif status_field == 'temperatures':
|
||||||
|
max_responses = 3
|
||||||
|
else:
|
||||||
|
self.logger.error("unkonwn status_field %s" % status_field)
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
# via UART/USB the BMS returns only frames that have data
|
||||||
|
max_responses = math.ceil(self.status[status_field] / num_per_frame)
|
||||||
|
return max_responses
|
||||||
|
|
||||||
|
def _split_frames(self, response_data, status_field, structure):
|
||||||
|
values = {}
|
||||||
|
x = 1
|
||||||
|
for response_bytes in response_data:
|
||||||
|
parts = struct.unpack(structure, response_bytes)
|
||||||
|
if parts[0] != x:
|
||||||
|
self.logger.warning("frame out of order, expected %i, got %i" % (x, response_bytes[0]))
|
||||||
|
continue
|
||||||
|
for value in parts[1:]:
|
||||||
|
values[len(values) + 1] = value
|
||||||
|
if len(values) == self.status[status_field]:
|
||||||
|
return values
|
||||||
|
x += 1
|
||||||
|
|
||||||
|
def get_cell_voltages(self, response_data=None):
|
||||||
|
if not response_data:
|
||||||
|
max_responses = self._calc_num_responses(status_field="cells", num_per_frame=3)
|
||||||
|
if not max_responses:
|
||||||
|
return
|
||||||
|
response_data = self._read_request("95", max_responses=max_responses, return_list=True)
|
||||||
|
if not response_data:
|
||||||
|
return False
|
||||||
|
|
||||||
|
cell_voltages = self._split_frames(response_data=response_data, status_field="cells", structure=">b 3h x")
|
||||||
|
for id in cell_voltages:
|
||||||
|
cell_voltages[id] = cell_voltages[id] / 1000
|
||||||
|
return cell_voltages
|
||||||
|
|
||||||
|
def get_temperatures(self, response_data=None):
|
||||||
|
# Sensor temperatures
|
||||||
|
if not response_data:
|
||||||
|
max_responses = self._calc_num_responses(status_field="temperature_sensors", num_per_frame=7)
|
||||||
|
if not max_responses:
|
||||||
|
return
|
||||||
|
response_data = self._read_request("96", max_responses=max_responses, return_list=True)
|
||||||
|
if not response_data:
|
||||||
|
return False
|
||||||
|
|
||||||
|
temperatures = self._split_frames(response_data=response_data, status_field="temperature_sensors",
|
||||||
|
structure=">b 7b")
|
||||||
|
for id in temperatures:
|
||||||
|
temperatures[id] = temperatures[id] - 40
|
||||||
|
return temperatures
|
||||||
|
|
||||||
|
def get_balancing_status(self, response_data=None):
|
||||||
|
# Cell balancing status
|
||||||
|
if not response_data:
|
||||||
|
response_data = self._read_request("97")
|
||||||
|
if not response_data:
|
||||||
|
return False
|
||||||
|
self.logger.info(response_data.hex())
|
||||||
|
bits = bin(int(response_data.hex(), base=16))[2:].zfill(48)
|
||||||
|
self.logger.info(bits)
|
||||||
|
cells = {}
|
||||||
|
for cell in range(1, self.status["cells"] + 1):
|
||||||
|
cells[cell] = bool(int(bits[cell * -1]))
|
||||||
|
self.logger.info(cells)
|
||||||
|
# todo: get sample data and verify result
|
||||||
|
return {"error": "not implemented"}
|
||||||
|
|
||||||
|
def get_errors(self, response_data=None):
|
||||||
|
# Battery failure status
|
||||||
|
if not response_data:
|
||||||
|
response_data = self._read_request("98")
|
||||||
|
if int.from_bytes(response_data, byteorder='big') == 0:
|
||||||
|
return []
|
||||||
|
|
||||||
|
byte_index = 0
|
||||||
|
errors = []
|
||||||
|
for b in response_data:
|
||||||
|
if b == 0:
|
||||||
|
byte_index += 1
|
||||||
|
continue
|
||||||
|
bits = bin(b)[2:]
|
||||||
|
bit_index = 0
|
||||||
|
for bit in reversed(bits):
|
||||||
|
if bit == "1":
|
||||||
|
errors.append(ERROR_CODES[byte_index][bit_index])
|
||||||
|
|
||||||
|
bit_index += 1
|
||||||
|
|
||||||
|
self.logger.debug("%s %s %s" % (byte_index, b, bits))
|
||||||
|
byte_index += 1
|
||||||
|
return errors
|
||||||
|
|
||||||
|
def get_all(self):
|
||||||
|
return {
|
||||||
|
"soc": self.get_soc(),
|
||||||
|
"cell_voltage_range": self.get_cell_voltage_range(),
|
||||||
|
"temperature_range": self.get_temperature_range(),
|
||||||
|
"mosfet_status": self.get_mosfet_status(),
|
||||||
|
"status": self.get_status(),
|
||||||
|
"cell_voltages": self.get_cell_voltages(),
|
||||||
|
"temperatures": self.get_temperatures(),
|
||||||
|
"balancing_status": self.get_balancing_status(),
|
||||||
|
"errors": self.get_errors()
|
||||||
|
}
|
||||||
|
|
||||||
|
def set_charge_mosfet(self, on=True, response_data=None):
|
||||||
|
if on:
|
||||||
|
extra = "01"
|
||||||
|
else:
|
||||||
|
extra = "00"
|
||||||
|
if not response_data:
|
||||||
|
response_data = self._read_request("da", extra=extra)
|
||||||
|
if not response_data:
|
||||||
|
return False
|
||||||
|
self.logger.info(response_data.hex())
|
||||||
|
# on response
|
||||||
|
# 0101000002006cbe
|
||||||
|
# off response
|
||||||
|
# 0001000002006c44
|
||||||
|
|
||||||
|
def set_discharge_mosfet(self, on=True, response_data=None):
|
||||||
|
if on:
|
||||||
|
extra = "01"
|
||||||
|
else:
|
||||||
|
extra = "00"
|
||||||
|
if not response_data:
|
||||||
|
response_data = self._read_request("d9", extra=extra)
|
||||||
|
if not response_data:
|
||||||
|
return False
|
||||||
|
self.logger.info(response_data.hex())
|
||||||
|
# on response
|
||||||
|
# 0101000002006cbe
|
||||||
|
# off response
|
||||||
|
# 0001000002006c44
|
||||||
|
|
||||||
|
|
||||||
|
# Set SoC. Value is float from 0.0 to 100.0
|
||||||
|
def set_soc(self, value):
|
||||||
|
v = round(value*10.0)
|
||||||
|
if v > 1000 : v = 1000
|
||||||
|
if v < 0 : v = 0
|
||||||
|
extra='000000000000%0.4X' % v
|
||||||
|
response_data = self._read_request("21", extra=extra)
|
||||||
|
self.logger.info(response_data.hex())
|
||||||
|
|
||||||
|
def restart(self, response_data=None):
|
||||||
|
response_data = self._read("00","",1,False)
|
||||||
180
python-reference/daly_bms_bluetooth.py
Normal file
180
python-reference/daly_bms_bluetooth.py
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
import asyncio
|
||||||
|
import subprocess
|
||||||
|
import logging
|
||||||
|
from bleak import BleakClient
|
||||||
|
|
||||||
|
from .daly_bms import DalyBMS
|
||||||
|
|
||||||
|
|
||||||
|
class DalyBMSBluetooth(DalyBMS):
|
||||||
|
def __init__(self, request_retries=3, logger=None):
|
||||||
|
"""
|
||||||
|
|
||||||
|
:param request_retries: How often read requests should get repeated in case that they fail (Default: 3).
|
||||||
|
:param logger: Python Logger object for output (Default: None)
|
||||||
|
"""
|
||||||
|
if logger:
|
||||||
|
self.logger = logger
|
||||||
|
else:
|
||||||
|
self.logger = logging.getLogger(__name__)
|
||||||
|
DalyBMS.__init__(self, request_retries=request_retries, address=8, logger=logger)
|
||||||
|
self.client = None
|
||||||
|
self.response_cache = {}
|
||||||
|
|
||||||
|
async def connect(self, mac_address):
|
||||||
|
"""
|
||||||
|
Open the connection to the Bluetooth device.
|
||||||
|
|
||||||
|
:param mac_address: MAC address of the Bluetooth device
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
"""
|
||||||
|
When an earlier execution of the script crashed, the connection to the devices stays open and future
|
||||||
|
connection attempts would fail with this error:
|
||||||
|
bleak.exc.BleakError: Device with address AA:BB:CC:DD:EE:FF was not found.
|
||||||
|
see https://github.com/hbldh/bleak/issues/367
|
||||||
|
"""
|
||||||
|
open_blue = subprocess.Popen(["bluetoothctl"], shell=True, stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.STDOUT, stdin=subprocess.PIPE)
|
||||||
|
open_blue.communicate(b"disconnect %s\n" % mac_address.encode('utf-8'))
|
||||||
|
open_blue.kill()
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
self.client = BleakClient(mac_address)
|
||||||
|
await self.client.connect()
|
||||||
|
await self.client.start_notify(17, self._notification_callback)
|
||||||
|
await self.client.write_gatt_char(48, bytearray(b""))
|
||||||
|
|
||||||
|
async def disconnect(self):
|
||||||
|
"""
|
||||||
|
Disconnect from the Bluetooth device
|
||||||
|
"""
|
||||||
|
self.logger.info("Bluetooth Disconnecting")
|
||||||
|
await self.client.disconnect()
|
||||||
|
self.logger.info("Bluetooth Disconnected")
|
||||||
|
|
||||||
|
async def _read_request(self, command, max_responses=1):
|
||||||
|
response_data = None
|
||||||
|
x = None
|
||||||
|
for x in range(0, self.request_retries):
|
||||||
|
response_data = await self._read(
|
||||||
|
command=command,
|
||||||
|
max_responses=max_responses)
|
||||||
|
if not response_data:
|
||||||
|
self.logger.debug("%x. try failed, retrying..." % (x + 1))
|
||||||
|
await asyncio.sleep(0.2)
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
if not response_data:
|
||||||
|
self.logger.error('%s failed after %s tries' % (command, x + 1))
|
||||||
|
return False
|
||||||
|
return response_data
|
||||||
|
|
||||||
|
async def _read(self, command, max_responses=1):
|
||||||
|
self.logger.debug("-- %s ------------------------" % command)
|
||||||
|
self.response_cache[command] = {"queue": [],
|
||||||
|
"future": asyncio.Future(),
|
||||||
|
"max_responses": max_responses,
|
||||||
|
"done": False}
|
||||||
|
|
||||||
|
message_bytes = self._format_message(command)
|
||||||
|
result = await self._async_char_write(command, message_bytes)
|
||||||
|
self.logger.debug("got %s" % result)
|
||||||
|
if not result:
|
||||||
|
return False
|
||||||
|
if max_responses == 1:
|
||||||
|
return result[0]
|
||||||
|
else:
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _notification_callback(self, handle, data):
|
||||||
|
self.logger.debug("%s %s %s" % (handle, repr(data), len(data)))
|
||||||
|
responses = []
|
||||||
|
if len(data) == 13:
|
||||||
|
responses.append(data)
|
||||||
|
elif len(data) == 26:
|
||||||
|
responses.append(data[0:13])
|
||||||
|
responses.append(data[13:])
|
||||||
|
else:
|
||||||
|
self.logger.error(len(data), "bytes received, not 13 or 26, not implemented")
|
||||||
|
|
||||||
|
for response_bytes in responses:
|
||||||
|
command = response_bytes[2:3].hex()
|
||||||
|
if self.response_cache[command]["done"] is True:
|
||||||
|
self.logger.debug("skipping response for %s, done" % command)
|
||||||
|
return
|
||||||
|
self.response_cache[command]["queue"].append(response_bytes[4:-1])
|
||||||
|
if len(self.response_cache[command]["queue"]) == self.response_cache[command]["max_responses"]:
|
||||||
|
self.response_cache[command]["done"] = True
|
||||||
|
self.response_cache[command]["future"].set_result(self.response_cache[command]["queue"])
|
||||||
|
|
||||||
|
async def _async_char_write(self, command, value):
|
||||||
|
if not self.client.is_connected:
|
||||||
|
self.logger.info("Connecting...")
|
||||||
|
await self.client.connect()
|
||||||
|
|
||||||
|
await self.client.write_gatt_char(15, value)
|
||||||
|
self.logger.debug("Waiting...")
|
||||||
|
try:
|
||||||
|
result = await asyncio.wait_for(self.response_cache[command]["future"], 5)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
self.logger.warning("Timeout while waiting for %s response" % command)
|
||||||
|
return False
|
||||||
|
self.logger.debug("got %s" % result)
|
||||||
|
return result
|
||||||
|
|
||||||
|
# wrap all sync functions so that they can be awaited
|
||||||
|
async def get_soc(self, response_data=None):
|
||||||
|
response_data = await self._read_request("90")
|
||||||
|
return super().get_soc(response_data=response_data)
|
||||||
|
|
||||||
|
async def get_cell_voltage_range(self, response_data=None):
|
||||||
|
response_data = await self._read_request("91")
|
||||||
|
return super().get_cell_voltage_range(response_data=response_data)
|
||||||
|
|
||||||
|
async def get_max_min_temperature(self, response_data=None):
|
||||||
|
response_data = await self._read_request("92")
|
||||||
|
return super().get_max_min_temperature(response_data=response_data)
|
||||||
|
|
||||||
|
async def get_mosfet_status(self, response_data=None):
|
||||||
|
response_data = await self._read_request("93")
|
||||||
|
return super().get_mosfet_status(response_data=response_data)
|
||||||
|
|
||||||
|
async def get_status(self, response_data=None):
|
||||||
|
response_data = await self._read_request("94")
|
||||||
|
return super().get_status(response_data=response_data)
|
||||||
|
|
||||||
|
async def get_cell_voltages(self, response_data=None):
|
||||||
|
if not self.status:
|
||||||
|
await self.get_status()
|
||||||
|
max_responses = self._calc_cell_voltage_responses()
|
||||||
|
if not max_responses:
|
||||||
|
return
|
||||||
|
response_data = await self._read_request("95", max_responses=max_responses)
|
||||||
|
|
||||||
|
return super().get_cell_voltages(response_data=response_data)
|
||||||
|
|
||||||
|
async def get_temperatures(self, response_data=None):
|
||||||
|
response_data = await self._read_request("95")
|
||||||
|
return super().get_temperatures(response_data=response_data)
|
||||||
|
|
||||||
|
async def get_balancing_status(self, response_data=None):
|
||||||
|
response_data = await self._read_request("96")
|
||||||
|
return super().get_balancing_status(response_data=response_data)
|
||||||
|
|
||||||
|
async def get_errors(self, response_data=None):
|
||||||
|
response_data = await self._read_request("97")
|
||||||
|
return super().get_errors(response_data=response_data)
|
||||||
|
|
||||||
|
async def get_all(self):
|
||||||
|
return {
|
||||||
|
"soc": await self.get_soc(),
|
||||||
|
"cell_voltage_range": await self.get_cell_voltage_range(),
|
||||||
|
"temperature_range": await self.get_temperature_range(),
|
||||||
|
"mosfet_status": await self.get_mosfet_status(),
|
||||||
|
"status": await self.get_status(),
|
||||||
|
"cell_voltages": await self.get_cell_voltages(),
|
||||||
|
"temperatures": await self.get_temperatures(),
|
||||||
|
"balancing_status": await self.get_balancing_status(),
|
||||||
|
"errors": await self.get_errors()
|
||||||
|
}
|
||||||
241
python-reference/daly_sinowealth.py
Normal file
241
python-reference/daly_sinowealth.py
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
import serial
|
||||||
|
import struct
|
||||||
|
import logging
|
||||||
|
|
||||||
|
"""
|
||||||
|
List from BMStool PC / Sinowealth
|
||||||
|
1 = Cell 1 Voltage
|
||||||
|
...
|
||||||
|
09 = Cell 9 Voltage
|
||||||
|
0A = Cell 10 Voltage
|
||||||
|
0B = Total Voltage
|
||||||
|
0C = External Temperature 1
|
||||||
|
0D = External Temperature 2
|
||||||
|
0E = IC Temperature 1
|
||||||
|
0F = IC Temperature 2
|
||||||
|
10 = CADC Current (4 byte)
|
||||||
|
11 = Full Charge Capacity (4 byte)
|
||||||
|
12 = Remaining Capacity (4 byte)
|
||||||
|
13 = RSOC
|
||||||
|
14 = Cycle Count
|
||||||
|
15 = Pack Status
|
||||||
|
16 = Battery Status
|
||||||
|
17 = Pack Config
|
||||||
|
18 = Manufacture Access
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class DalyBMSSinowealth:
|
||||||
|
PACK_STATUS = {
|
||||||
|
0: 'CAL: ',
|
||||||
|
5: 'VDQ: Valid Discharge Qualified',
|
||||||
|
6: 'FD: Fully Discharged',
|
||||||
|
7: 'FC: Fully Charged',
|
||||||
|
9: 'FAST_DSG: Fast Discharging',
|
||||||
|
10: 'MID_DSG: Medium Discharging',
|
||||||
|
11: 'SLOW_DSG: Slow Discharging',
|
||||||
|
12: 'DSGING: Discharging',
|
||||||
|
13: 'CHGING: Charging',
|
||||||
|
14: 'DSGMOS: Discharging enabled',
|
||||||
|
15: 'CHGMOS: Charging enabled',
|
||||||
|
}
|
||||||
|
|
||||||
|
BATTERY_STATUS = {
|
||||||
|
1: 'CTO: Disconnection protection occurs',
|
||||||
|
2: 'AFE_SC: Hardware short circuit protection occurs',
|
||||||
|
3: 'AFE_OV: Hardware overvoltage protection occurs',
|
||||||
|
4: 'UTD: Discharge low temperature protection',
|
||||||
|
5: 'UTC: Charge low temperature protection occurs',
|
||||||
|
6: 'OTD: Discharge high temperature protection',
|
||||||
|
7: 'OTC: Charge high temperature protection occurs',
|
||||||
|
12: 'OCD: Discharge overcurrent protection occurs',
|
||||||
|
13: 'OCC: Charge overcurrent protection occurs',
|
||||||
|
14: 'UV: Undervoltage protection occurs',
|
||||||
|
15: 'OV: Overvoltage protection occurs',
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, request_retries=3, logger=None):
|
||||||
|
"""
|
||||||
|
|
||||||
|
:param request_retries: How often read requests should get repeated in case that they fail (Default: 3).
|
||||||
|
:param logger: Python Logger object for output (Default: None)
|
||||||
|
"""
|
||||||
|
if logger:
|
||||||
|
self.logger = logger
|
||||||
|
else:
|
||||||
|
self.logger = logging.getLogger(__name__)
|
||||||
|
self.request_retries = request_retries
|
||||||
|
|
||||||
|
def connect(self, device):
|
||||||
|
"""
|
||||||
|
Connect to a serial device
|
||||||
|
|
||||||
|
:param device: Serial device, e.g. /dev/ttyUSB0
|
||||||
|
"""
|
||||||
|
self.serial = serial.Serial(
|
||||||
|
port=device,
|
||||||
|
baudrate=9600,
|
||||||
|
bytesize=serial.EIGHTBITS,
|
||||||
|
parity=serial.PARITY_NONE,
|
||||||
|
stopbits=serial.STOPBITS_ONE,
|
||||||
|
timeout=0.5,
|
||||||
|
xonxoff=False,
|
||||||
|
writeTimeout=0.5
|
||||||
|
)
|
||||||
|
|
||||||
|
def disconnect(self):
|
||||||
|
if self.serial and self.serial.is_open:
|
||||||
|
self.serial.close()
|
||||||
|
|
||||||
|
def _format_message(self, command, length):
|
||||||
|
message = "0a%s0%s" % (command.zfill(2), length)
|
||||||
|
message_bytes = bytearray.fromhex(message)
|
||||||
|
self.logger.debug("message: %s, %s" % (message_bytes, message_bytes.hex()))
|
||||||
|
return message_bytes
|
||||||
|
|
||||||
|
def _read(self, command):
|
||||||
|
if not self.serial.is_open:
|
||||||
|
self.serial.open()
|
||||||
|
if command in ("10", "11", "12"):
|
||||||
|
length = 4
|
||||||
|
else:
|
||||||
|
length = 2
|
||||||
|
message_bytes = self._format_message(command, length)
|
||||||
|
|
||||||
|
# clear all buffers, in case something is left from a previous command that failed
|
||||||
|
self.serial.reset_input_buffer()
|
||||||
|
self.serial.reset_output_buffer()
|
||||||
|
|
||||||
|
if not self.serial.write(message_bytes):
|
||||||
|
self.logger.error("serial write failed for command" % command)
|
||||||
|
return False
|
||||||
|
|
||||||
|
response_data = self.serial.read(length + 1)
|
||||||
|
if len(response_data) == 0:
|
||||||
|
self.logger.debug("empty response for command %s" % (command))
|
||||||
|
return False
|
||||||
|
|
||||||
|
self.logger.debug("%s (%i)" % (response_data.hex(), len(response_data)))
|
||||||
|
if command in ("10", "11", "12"):
|
||||||
|
return struct.unpack('>i x', response_data)[0]
|
||||||
|
elif command in ("15", "16", "17", "18"):
|
||||||
|
return bin(int.from_bytes(response_data[:-1], byteorder='big'))[2:].zfill(16)
|
||||||
|
else:
|
||||||
|
return struct.unpack('>h x', response_data)[0]
|
||||||
|
|
||||||
|
def get_cell_voltages(self):
|
||||||
|
max_cells = 10
|
||||||
|
x = 1
|
||||||
|
cell_voltages = {}
|
||||||
|
while x <= max_cells:
|
||||||
|
response_data = self._read("%02x" % x)
|
||||||
|
if not response_data:
|
||||||
|
break
|
||||||
|
if response_data == 0:
|
||||||
|
# last cell
|
||||||
|
break
|
||||||
|
|
||||||
|
cell_voltages[x] = response_data / 1000
|
||||||
|
x += 1
|
||||||
|
|
||||||
|
return cell_voltages
|
||||||
|
|
||||||
|
def _read_bulk(self, requests):
|
||||||
|
data = {}
|
||||||
|
for key, command in requests.items():
|
||||||
|
response_data = self._read(command[0])
|
||||||
|
if response_data is False:
|
||||||
|
continue
|
||||||
|
data[key] = response_data / command[1]
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
def get_soc(self):
|
||||||
|
requests = {
|
||||||
|
"total_voltage": ("b", 1000),
|
||||||
|
"current": ("10", 1000),
|
||||||
|
"soc_percent": ("13", 1)
|
||||||
|
}
|
||||||
|
return self._read_bulk(requests)
|
||||||
|
|
||||||
|
def get_temperatures(self):
|
||||||
|
# The BMS returns temperatures in Kelvin
|
||||||
|
# 2731 / 10 = 273,1 K = 0°C
|
||||||
|
requests = {
|
||||||
|
"external1": ("c", 10),
|
||||||
|
"external2": ("d", 10),
|
||||||
|
# "ic1": ("e", 10),
|
||||||
|
# "ic2": ("f", 100), # always 71
|
||||||
|
}
|
||||||
|
responses = self._read_bulk(requests)
|
||||||
|
|
||||||
|
for key, value in responses.items():
|
||||||
|
# change temperatures from Kelvin to °C
|
||||||
|
responses[key] = round(value - 273, 2)
|
||||||
|
return responses
|
||||||
|
|
||||||
|
def get_status(self):
|
||||||
|
requests = {
|
||||||
|
"cycles": ("14", 1),
|
||||||
|
}
|
||||||
|
responses = self._read_bulk(requests)
|
||||||
|
|
||||||
|
for key, value in responses.items():
|
||||||
|
if type(responses[key]) is float:
|
||||||
|
responses[key] = int(value)
|
||||||
|
return responses
|
||||||
|
|
||||||
|
def get_mosfet_status(self):
|
||||||
|
requests = {
|
||||||
|
"full_capacity_ah": ("11", 1000),
|
||||||
|
"remaining_capacity_ah": ("12", 1000),
|
||||||
|
}
|
||||||
|
responses = self._read_bulk(requests)
|
||||||
|
|
||||||
|
for key, value in responses.items():
|
||||||
|
if type(responses[key]) is float:
|
||||||
|
responses[key] = round(value, 2)
|
||||||
|
|
||||||
|
pack_response = self._read("15")
|
||||||
|
if pack_response is False:
|
||||||
|
return responses
|
||||||
|
|
||||||
|
pack_state = []
|
||||||
|
for key, value in self.PACK_STATUS.items():
|
||||||
|
if pack_response[key] == "1":
|
||||||
|
pack_state.append(value)
|
||||||
|
|
||||||
|
responses['pack_state'] = pack_state
|
||||||
|
return responses
|
||||||
|
|
||||||
|
def get_errors(self):
|
||||||
|
response = self._read("16")
|
||||||
|
pack_state = []
|
||||||
|
for key, value in self.BATTERY_STATUS.items():
|
||||||
|
if response[key] == "1":
|
||||||
|
pack_state.append(value)
|
||||||
|
|
||||||
|
return pack_state
|
||||||
|
|
||||||
|
# dummy functions for everything that is not supported by the Sinowealth BMS
|
||||||
|
def get_cell_voltage_range(self):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def get_temperature_range(self):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def get_balancing_status(self):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def get_all(self):
|
||||||
|
return {
|
||||||
|
"soc": self.get_soc(),
|
||||||
|
# "cell_voltage_range": self.get_cell_voltage_range(),
|
||||||
|
# "temperature_range": self.get_temperature_range(),
|
||||||
|
"mosfet_status": self.get_mosfet_status(),
|
||||||
|
"status": self.get_status(),
|
||||||
|
"cell_voltages": self.get_cell_voltages(),
|
||||||
|
"temperatures": self.get_temperatures(),
|
||||||
|
# "balancing_status": self.get_balancing_status(),
|
||||||
|
"errors": self.get_errors()
|
||||||
|
}
|
||||||
67
python-reference/error_codes.py
Normal file
67
python-reference/error_codes.py
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
"""
|
||||||
|
The error messages are taken from the "Part 4_ Daly RS485+UART Protocol.pdf",
|
||||||
|
so the translation quality isn't that great yet.
|
||||||
|
"""
|
||||||
|
|
||||||
|
ERROR_CODES = {
|
||||||
|
0: [
|
||||||
|
"one stage warning of unit over voltage",
|
||||||
|
"one stage warning of unit over voltage",
|
||||||
|
"one stage warning of unit over voltage",
|
||||||
|
"two stage warning of unit over voltage",
|
||||||
|
"Total voltage is too high One alarm",
|
||||||
|
"Total voltage is too high Level two alarm",
|
||||||
|
"Total voltage is too low One alarm",
|
||||||
|
"Total voltage is too low Level two alarm"
|
||||||
|
],
|
||||||
|
1: ["Charging temperature too high. One alarm",
|
||||||
|
"Charging temperature too high. Level two alarm",
|
||||||
|
"Charging temperature too low. One alarm",
|
||||||
|
"Charging temperature's too low. Level two alarm",
|
||||||
|
"Discharge temperature is too high. One alarm",
|
||||||
|
"Discharge temperature is too high. Level two alarm",
|
||||||
|
"Discharge temperature is too low. One alarm",
|
||||||
|
"Discharge temperature is too low. Level two alarm",
|
||||||
|
],
|
||||||
|
2: ["Charge over current. Level one alarm",
|
||||||
|
"Charge over current, level two alarm",
|
||||||
|
"Discharge over current. Level one alarm",
|
||||||
|
"Discharge overcurrent, level two alarm",
|
||||||
|
"SOC is too high an alarm",
|
||||||
|
"SOC is too high. Alarm Two",
|
||||||
|
"SOC is too low. level one alarm",
|
||||||
|
"SOC is too low. level two alarm",
|
||||||
|
],
|
||||||
|
3: ["Excessive differential pressure level one alarm",
|
||||||
|
"Excessive differential pressure level two alarm",
|
||||||
|
"Excessive temperature difference level one alarm",
|
||||||
|
"Excessive temperature difference level two alarm",
|
||||||
|
],
|
||||||
|
4: ["charging MOS overtemperature warning",
|
||||||
|
"discharge MOS overtemperature warning",
|
||||||
|
"charging MOS temperature detection sensor failure",
|
||||||
|
"discharge MOS temperature detection sensor failure",
|
||||||
|
"charging MOS adhesion failure",
|
||||||
|
"discharge MOS adhesion failure",
|
||||||
|
"charging MOS breaker failure",
|
||||||
|
"discharge MOS breaker failure",
|
||||||
|
],
|
||||||
|
5: ["AFE acquisition chip malfunction",
|
||||||
|
"monomer collect drop off",
|
||||||
|
"Single Temperature Sensor Fault",
|
||||||
|
"EEPROM storage failures",
|
||||||
|
"RTC clock malfunction",
|
||||||
|
"Precharge Failure",
|
||||||
|
"vehicle communications malfunction",
|
||||||
|
"intranet communication module malfunction",
|
||||||
|
],
|
||||||
|
6: ["Current Module Failure",
|
||||||
|
"main pressure detection module",
|
||||||
|
"Short circuit protection failure",
|
||||||
|
"Low Voltage No Charging",
|
||||||
|
"RESERVED",
|
||||||
|
"RESERVED",
|
||||||
|
"RESERVED",
|
||||||
|
"RESERVED",
|
||||||
|
],
|
||||||
|
}
|
||||||
262
src/bmscore.js
Normal file
262
src/bmscore.js
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
const { SerialPort } = require('serialport');
|
||||||
|
const errors = require('./errors');
|
||||||
|
|
||||||
|
const address = 0x04;
|
||||||
|
const frameLength = 13;
|
||||||
|
const timeoutMS = 700;
|
||||||
|
const retryDelayMS = 200;
|
||||||
|
|
||||||
|
//Arrotondamenti
|
||||||
|
const r1 = v => Math.round(v * 10) / 10; // 0.1 — tensione totale, corrente, SOC%
|
||||||
|
const r3 = v => Math.round(v * 1000) / 1000; // 0.001 — tensioni cella, capacità Ah
|
||||||
|
const r2 = v => Math.round(v * 100) / 100; // 0.01 — temperature in kelvin (SignalK)
|
||||||
|
|
||||||
|
class BMS {
|
||||||
|
constructor({ device, retries = 5, log = () => {} } = {}) {
|
||||||
|
this.device = device;
|
||||||
|
this.retries = retries;
|
||||||
|
this.log = log;
|
||||||
|
this.port = null;
|
||||||
|
this.status = null; // ultimo getStatus (n. celle, sensori temp)
|
||||||
|
this._queue = Promise.resolve(); // serializza le richieste sulla seriale
|
||||||
|
}
|
||||||
|
|
||||||
|
open() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
this.port = new SerialPort({
|
||||||
|
path: this.device,
|
||||||
|
baudRate: 9600,
|
||||||
|
dataBits: 8,
|
||||||
|
parity: 'none',
|
||||||
|
stopBits: 1,
|
||||||
|
autoOpen: false
|
||||||
|
});
|
||||||
|
this.port.open(err => err ? reject(err) : this.port.flush(() => resolve()));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async close() {
|
||||||
|
if (this.port && this.port.isOpen) {
|
||||||
|
await new Promise(r => this.port.close(() => r()));
|
||||||
|
}
|
||||||
|
this.port = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static _crc(buf) {
|
||||||
|
let s = 0;
|
||||||
|
for (let i = 0; i < buf.length; i++) s = (s + buf[i]) & 0xFF;
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
_buildFrame(cmd, extraHex = '') {
|
||||||
|
const hex = `a5${address.toString(16).padStart(2, '0')}${cmd}08${extraHex}`.padEnd(24, '0');
|
||||||
|
const buf = Buffer.from(hex, 'hex');
|
||||||
|
return Buffer.concat([buf, Buffer.from([BMS._crc(buf)])]);
|
||||||
|
}
|
||||||
|
|
||||||
|
//Invii multipli
|
||||||
|
_sendBatch(cmd, opts = {}) {
|
||||||
|
const run = async () => {
|
||||||
|
let lastErr;
|
||||||
|
for (let i = 0; i < this.retries; i++) {
|
||||||
|
try {
|
||||||
|
const r = await this._send(cmd, opts);
|
||||||
|
if (opts.returnList ? r && r.length : r) return r;
|
||||||
|
} catch (e) {
|
||||||
|
lastErr = e;
|
||||||
|
this.log(`cmd ${cmd} try ${i + 1} failed: ${e.message}`);
|
||||||
|
}
|
||||||
|
await new Promise(r => setTimeout(r, retryDelayMS));
|
||||||
|
}
|
||||||
|
throw lastErr || new Error(`cmd ${cmd} failed after ${this.retries} retries`);
|
||||||
|
};
|
||||||
|
const next = this._queue.then(run, run);
|
||||||
|
this._queue = next.catch(() => {}); // non rompere la coda al primo errore
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
_send(cmd, { extraHex = '', maxResponses = 1, returnList = false } = {}) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (!this.port || !this.port.isOpen) return reject(new Error('serial not open'));
|
||||||
|
const tx = this._buildFrame(cmd, extraHex);
|
||||||
|
const frames = [];
|
||||||
|
let buf = Buffer.alloc(0);
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
this.port.off('data', onData);
|
||||||
|
};
|
||||||
|
|
||||||
|
const finish = () => {
|
||||||
|
cleanup();
|
||||||
|
if (returnList || frames.length > 1) return resolve(frames);
|
||||||
|
return resolve(frames[0] || null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onData = chunk => {
|
||||||
|
buf = buf.length ? Buffer.concat([buf, chunk]) : chunk;
|
||||||
|
while (buf.length >= frameLength) {
|
||||||
|
const frame = buf.subarray(0, frameLength);
|
||||||
|
buf = buf.subarray(frameLength);
|
||||||
|
if (BMS._crc(frame.subarray(0, 12)) !== frame[12]) {
|
||||||
|
this.log(`crc mismatch: ${frame.toString('hex')}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (frame[2].toString(16).padStart(2, '0') !== cmd) {
|
||||||
|
this.log(`unexpected cmd: ${frame.toString('hex')}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
frames.push(frame.subarray(4, 12));
|
||||||
|
if (frames.length >= maxResponses) return finish();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
if (frames.length === 0) { cleanup(); return reject(new Error('serial timeout')); }
|
||||||
|
finish();
|
||||||
|
}, timeoutMS);
|
||||||
|
|
||||||
|
this.port.flush(() => {
|
||||||
|
this.port.on('data', onData);
|
||||||
|
this.port.write(tx, err => { if (err) { cleanup(); reject(err); } });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 0x90 — voltage / current / SOC
|
||||||
|
async getSoc() {
|
||||||
|
const d = await this._sendBatch('90');
|
||||||
|
if (!d) return null;
|
||||||
|
return {
|
||||||
|
total_voltage: r1(d.readInt16BE(0) / 10),
|
||||||
|
current: r1((d.readInt16BE(4) - 30000) / 10), // < 0 charging, > 0 discharging
|
||||||
|
soc_percent: r1(d.readInt16BE(6) / 10)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 0x91 — range tensioni cella
|
||||||
|
async getCellVoltageRange() {
|
||||||
|
const d = await this._sendBatch('91');
|
||||||
|
if (!d) return null;
|
||||||
|
return {
|
||||||
|
highest_voltage: r3(d.readInt16BE(0) / 1000),
|
||||||
|
highest_cell: d.readInt8(2),
|
||||||
|
lowest_voltage: r3(d.readInt16BE(3) / 1000),
|
||||||
|
lowest_cell: d.readInt8(5)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 0x92 — range temperature
|
||||||
|
async getTemperatureRange() {
|
||||||
|
const d = await this._sendBatch('92');
|
||||||
|
if (!d) return null;
|
||||||
|
return {
|
||||||
|
highest_temperature: d.readInt8(0) - 40,
|
||||||
|
highest_sensor: d.readInt8(1),
|
||||||
|
lowest_temperature: d.readInt8(2) - 40,
|
||||||
|
lowest_sensor: d.readInt8(3)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 0x93 — MOSFET + capacità residua
|
||||||
|
async getMosfetStatus() {
|
||||||
|
const d = await this._sendBatch('93');
|
||||||
|
if (!d) return null;
|
||||||
|
const m = d.readInt8(0);
|
||||||
|
return {
|
||||||
|
mode: m === 0 ? 'stationary' : m === 1 ? 'charging' : 'discharging',
|
||||||
|
charging_mosfet: !!d[1],
|
||||||
|
discharging_mosfet: !!d[2],
|
||||||
|
capacity_ah: r3(d.readInt32BE(4) / 1000)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 0x94 — status (n. celle, sensori, cicli, DI/DO)
|
||||||
|
async getStatus() {
|
||||||
|
const d = await this._sendBatch('94');
|
||||||
|
if (!d) return null;
|
||||||
|
const stateByte = d.readInt8(4);
|
||||||
|
const stateNames = ['DI1','DI2','DI3','DI4','DO1','DO2','DO3','DO4'];
|
||||||
|
const states = {};
|
||||||
|
for (let i = 0; i < 8; i++) states[stateNames[i]] = !!((stateByte >> i) & 1);
|
||||||
|
const data = {
|
||||||
|
cells: d.readInt8(0),
|
||||||
|
temperature_sensors: d.readInt8(1),
|
||||||
|
charger_running: !!d[2],
|
||||||
|
load_running: !!d[3],
|
||||||
|
states,
|
||||||
|
cycles: d.readInt16BE(5)
|
||||||
|
};
|
||||||
|
this.status = data;
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async _ensureStatus() {
|
||||||
|
if (!this.status) await this.getStatus();
|
||||||
|
if (!this.status) throw new Error('cannot read BMS status');
|
||||||
|
return this.status;
|
||||||
|
}
|
||||||
|
|
||||||
|
_splitFrames(frames, total, perFrame, parser) {
|
||||||
|
const out = {};
|
||||||
|
let expected = 1;
|
||||||
|
for (const f of frames) {
|
||||||
|
const { idx, vals } = parser(f);
|
||||||
|
if (idx !== expected) { this.log(`frame out of order: exp ${expected} got ${idx}`); continue; }
|
||||||
|
for (let i = 0; i < perFrame; i++) {
|
||||||
|
const k = (expected - 1) * perFrame + i + 1;
|
||||||
|
if (k > total) return out;
|
||||||
|
out[k] = vals[i];
|
||||||
|
}
|
||||||
|
expected++;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 0x95 — tensioni cella (3/frame)
|
||||||
|
async getCellVoltages() {
|
||||||
|
const st = await this._ensureStatus();
|
||||||
|
const max = Math.ceil(st.cells / 3);
|
||||||
|
const frames = await this._sendBatch('95', { maxResponses: max, returnList: true });
|
||||||
|
if (!frames) return null;
|
||||||
|
const cells = this._splitFrames(frames, st.cells, 3, f => ({
|
||||||
|
idx: f.readInt8(0),
|
||||||
|
vals: [f.readInt16BE(1), f.readInt16BE(3), f.readInt16BE(5)]
|
||||||
|
}));
|
||||||
|
for (const k of Object.keys(cells)) cells[k] = cells[k] / 1000;
|
||||||
|
return cells;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 0x96 — temperature (7/frame, raw - 40 = °C)
|
||||||
|
async getTemperatures() {
|
||||||
|
const st = await this._ensureStatus();
|
||||||
|
const max = Math.ceil(st.temperature_sensors / 7);
|
||||||
|
const frames = await this._sendBatch('96', { maxResponses: max, returnList: true });
|
||||||
|
if (!frames) return null;
|
||||||
|
const t = this._splitFrames(frames, st.temperature_sensors, 7, f => ({
|
||||||
|
idx: f.readInt8(0),
|
||||||
|
vals: [f.readInt8(1), f.readInt8(2), f.readInt8(3), f.readInt8(4), f.readInt8(5), f.readInt8(6), f.readInt8(7)]
|
||||||
|
}));
|
||||||
|
for (const k of Object.keys(t)) t[k] = t[k] - 40;
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 0x98 — errori (bitmap)
|
||||||
|
async getErrors() {
|
||||||
|
const d = await this._sendBatch('98');
|
||||||
|
if (!d) return [];
|
||||||
|
const errors = [];
|
||||||
|
for (let i = 0; i < d.length; i++) {
|
||||||
|
const b = d[i];
|
||||||
|
if (!b) continue;
|
||||||
|
for (let bit = 0; bit < 8; bit++) {
|
||||||
|
if ((b >> bit) & 1) {
|
||||||
|
errors.push((errors[i] && errors[i][bit]) || `unknown(${i}.${bit})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = BMS;
|
||||||
76
src/errors.js
Normal file
76
src/errors.js
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
// Porting di dalybms/error_codes.py — indice[byte][bit] → stringa errore.
|
||||||
|
module.exports = [
|
||||||
|
// byte 0
|
||||||
|
[
|
||||||
|
'one stage warning of unit over voltage',
|
||||||
|
'one stage warning of unit over voltage',
|
||||||
|
'one stage warning of unit over voltage',
|
||||||
|
'two stage warning of unit over voltage',
|
||||||
|
'Total voltage is too high - level 1 alarm',
|
||||||
|
'Total voltage is too high - level 2 alarm',
|
||||||
|
'Total voltage is too low - level 1 alarm',
|
||||||
|
'Total voltage is too low - level 2 alarm'
|
||||||
|
],
|
||||||
|
// byte 1
|
||||||
|
[
|
||||||
|
'Charging temperature too high - level 1 alarm',
|
||||||
|
'Charging temperature too high - level 2 alarm',
|
||||||
|
'Charging temperature too low - level 1 alarm',
|
||||||
|
'Charging temperature too low - level 2 alarm',
|
||||||
|
'Discharge temperature too high - level 1 alarm',
|
||||||
|
'Discharge temperature too high - level 2 alarm',
|
||||||
|
'Discharge temperature too low - level 1 alarm',
|
||||||
|
'Discharge temperature too low - level 2 alarm'
|
||||||
|
],
|
||||||
|
// byte 2
|
||||||
|
[
|
||||||
|
'Charge over current - level 1 alarm',
|
||||||
|
'Charge over current - level 2 alarm',
|
||||||
|
'Discharge over current - level 1 alarm',
|
||||||
|
'Discharge over current - level 2 alarm',
|
||||||
|
'SOC too high - level 1 alarm',
|
||||||
|
'SOC too high - level 2 alarm',
|
||||||
|
'SOC too low - level 1 alarm',
|
||||||
|
'SOC too low - level 2 alarm'
|
||||||
|
],
|
||||||
|
// byte 3
|
||||||
|
[
|
||||||
|
'Excessive differential pressure - level 1 alarm',
|
||||||
|
'Excessive differential pressure - level 2 alarm',
|
||||||
|
'Excessive temperature difference - level 1 alarm',
|
||||||
|
'Excessive temperature difference - level 2 alarm',
|
||||||
|
'reserved', 'reserved', 'reserved', 'reserved'
|
||||||
|
],
|
||||||
|
// byte 4
|
||||||
|
[
|
||||||
|
'Charging MOS overtemperature warning',
|
||||||
|
'Discharge MOS overtemperature warning',
|
||||||
|
'Charging MOS temperature sensor failure',
|
||||||
|
'Discharge MOS temperature sensor failure',
|
||||||
|
'Charging MOS adhesion failure',
|
||||||
|
'Discharge MOS adhesion failure',
|
||||||
|
'Charging MOS breaker failure',
|
||||||
|
'Discharge MOS breaker failure'
|
||||||
|
],
|
||||||
|
// byte 5
|
||||||
|
[
|
||||||
|
'AFE acquisition chip malfunction',
|
||||||
|
'Monomer collect drop off',
|
||||||
|
'Single temperature sensor fault',
|
||||||
|
'EEPROM storage failure',
|
||||||
|
'RTC clock malfunction',
|
||||||
|
'Precharge failure',
|
||||||
|
'Vehicle communications error',
|
||||||
|
'Intranet communication module malfunction'
|
||||||
|
],
|
||||||
|
// byte 6
|
||||||
|
[
|
||||||
|
'Current module failure',
|
||||||
|
'Main pressure detection module failure',
|
||||||
|
'Short circuit protection failure',
|
||||||
|
'Low voltage no charging',
|
||||||
|
'reserved', 'reserved', 'reserved', 'reserved'
|
||||||
|
],
|
||||||
|
// byte 7
|
||||||
|
['reserved', 'reserved', 'reserved', 'reserved', 'reserved', 'reserved', 'reserved', 'reserved']
|
||||||
|
];
|
||||||
Reference in New Issue
Block a user