commit 4d5d51c018b83daacecc668adfc2e18b8fa08e2d Author: Giuseppe Raffa <77052701+sesee3@users.noreply.github.com> Date: Mon May 11 19:45:07 2026 +0200 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. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c41e55e --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.DS_Store +node_modules + +.env + +.claude diff --git a/index.js b/index.js new file mode 100644 index 0000000..8d37434 --- /dev/null +++ b/index.js @@ -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; +}; diff --git a/package.json b/package.json new file mode 100644 index 0000000..1728cce --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/python-reference/__init__.py b/python-reference/__init__.py new file mode 100644 index 0000000..8a2240b --- /dev/null +++ b/python-reference/__init__.py @@ -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 \ No newline at end of file diff --git a/python-reference/daly-bms-cli b/python-reference/daly-bms-cli new file mode 100755 index 0000000..f746e05 --- /dev/null +++ b/python-reference/daly-bms-cli @@ -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) diff --git a/python-reference/daly_bms.py b/python-reference/daly_bms.py new file mode 100644 index 0000000..ea94ee2 --- /dev/null +++ b/python-reference/daly_bms.py @@ -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) \ No newline at end of file diff --git a/python-reference/daly_bms_bluetooth.py b/python-reference/daly_bms_bluetooth.py new file mode 100644 index 0000000..eef2abb --- /dev/null +++ b/python-reference/daly_bms_bluetooth.py @@ -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() + } diff --git a/python-reference/daly_sinowealth.py b/python-reference/daly_sinowealth.py new file mode 100644 index 0000000..0a5a8ea --- /dev/null +++ b/python-reference/daly_sinowealth.py @@ -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() + } diff --git a/python-reference/error_codes.py b/python-reference/error_codes.py new file mode 100644 index 0000000..b605f30 --- /dev/null +++ b/python-reference/error_codes.py @@ -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", + ], +} \ No newline at end of file diff --git a/src/bmscore.js b/src/bmscore.js new file mode 100644 index 0000000..61bdcee --- /dev/null +++ b/src/bmscore.js @@ -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; diff --git a/src/errors.js b/src/errors.js new file mode 100644 index 0000000..9301726 --- /dev/null +++ b/src/errors.js @@ -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'] +];