diff --git a/README.md b/README.md index f486594..112c5e5 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ This is a Python module for reading data from Daly BMS devices. It supports seri ## Compatibility -There are two different types of devices sold under the Daly Brand, which use different communication protocols. +There are two different types of devices sold under the Daly Brand, which use different communication protocols. This module was initially written for BMS supported by the Windows program BMS monitor. Later (in #1) the support for BMS supported by the BMStool, also known as Sinowealth, was added. They don't support all commands of the first protocol, but still provide valuable information. @@ -163,10 +163,71 @@ Get everything possible: ## Notes + ### Bluetooth +You can fetch the values via bluetooth with the instructions below, this was tested on a raspi zero - It's also recommended to have a recent BlueZ installed (>=5.53). The Bluetooth connection uses `asyncio` for the connection, so the data is received asynchronous. - It seems like the Bluetooth BMS Module goes to sleep after 1 hour of inactivity (no load or charging), while the serial connection responds all the time. Sending a command via the serial interface wakes up the Bluetooth module. + +- Use the daly-bmsBT-cli to interact with your bluetooth, you will need to find the MAC address of your device +``` +pi@pizero:~ $ bluetoothctl +Agent registered +[CHG] Controller B8:27:EB:AC:93:B5 Pairable: yes +[bluetooth]# power on +Failed to set power on: org.bluez.Error.Blocked +[bluetooth]# scan on +Failed to start discovery: org.bluez.Error.NotReady +[bluetooth]# exit +pi@pizero:~ $ rfkill list +0: phy0: Wireless LAN + Soft blocked: no + Hard blocked: no +1: hci0: Bluetooth + Soft blocked: yes + Hard blocked: no +pi@pizero:~ $ rfkill unblock bluetooth +pi@pizero:~ $ rfkill list + 0: phy0: Wireless LAN + Soft blocked: no + Hard blocked: no + 1: hci0: Bluetooth + Soft blocked: no + Hard blocked: no +pi@pizero:~ $ bluetoothctl +Agent registered +[CHG] Controller B8:27:EB:AC:93:B5 Pairable: yes +[bluetooth]# power on +Changing power on succeeded +[bluetooth]# scan on +Discovery started +[CHG] Controller B8:27:EB:AC:93:B5 Discovering: yes +[NEW] Device 02:11:23:34:7D:88 DL-021123347D88 +[NEW] Device A0:9E:1A:61:8B:37 Polar Vantage M 62C +[NEW] Device 58:7E:61:4D:0A:DB 58-7E-61-4D-0A-DB +[bluetooth]# exit +``` +I spot the MAC of my Daly it is this line [NEW] Device 02:11:23:34:7D:88 DL-021123347D88 +Test the output to screen +``` +./daly-bmsBT-cli --all -d 02:11:23:34:7D:88 +``` +This is the command to populate your homeassistant, you should setup a systemd service for this +``` +./daly-bmsBT-cli --all --mqtt --mqtt-hass --mqtt-user yourrmqttuser --mqtt-password yourmqttpassword --mqtt-broker 192.168.2.21 -d 02:11:23:34:7D:88 +``` +Setting up the bluetooth as a systemd Service +``` +pi@pizero:~/python-daly-bms $ sudo cp service/dalybt.service /etc/systemd/system/ +pi@pizero:~/python-daly-bms $ sudo cp service/dalybt.conf /etc/ +``` +Now edit the options to setup your mqtt server in the /etc/dalybt.conf filename +``` +sudo systemctl start dalybt +sudo systemctl enable dalybt +``` +Your system should now automatically start the service when booting diff --git a/bin/daly-bmsBT-cli b/bin/daly-bmsBT-cli new file mode 100755 index 0000000..4e6c28d --- /dev/null +++ b/bin/daly-bmsBT-cli @@ -0,0 +1,347 @@ +#!/usr/bin/python3 +import argparse +import json +import logging +import sys +import asyncio +from dalybms import DalyBMSBluetooth + +class DalyBMSConnection(): + def __init__(self, mac_address,request_retries=3, logger=None): + self.bt_bms = DalyBMSBluetooth(request_retries,logger) + self.mac_address = mac_address + self.connected = False + + async def connect(self): + self.connected = await self.bt_bms.connect(mac_address=self.mac_address) + + async def update_cell_voltages(self): + if not self.connected: + await self.connect() + return self.bt_bms.get_cell_voltages() + + + async def update_cell_voltage_range(self): + if not self.connected: + await self.connect() + return await self.bt_bms.get_cell_voltage_range() + + async def get_status(self): + if not self.connected: + await self.connect() + return await self.bt_bms.get_status() + + async def get_temps2(self): + if not self.connected: + await self.connect() + return await self.bt_bms.get_max_min_temperature() + + async def get_soc(self): + if not self.connected: + await self.connect() + return await self.bt_bms.get_soc() + + async def get_mosfet(self): + if not self.connected: + await self.connect() + return await self.bt_bms.get_mosfet_status() + + async def get_temps(self): + if not self.connected: + await self.connect() + return await self.bt_bms.get_temperatures() + + async def get_bal(self): + if not self.connected: + await self.connect() + return await self.bt_bms.get_balancing_status() + + async def get_errors(self): + if not self.connected: + await self.connect() + return await self.bt_bms.get_errors() + + async def get_all(self): + if not self.connected: + await self.connect() + return await self.bt_bms.get_all() + + async def disconnect(self): + if not self.connected: + return + return await self.bt_bms.disconnect() + + +#async def main(con): +# await con.connect() +# await con.update_status() +# await con.update_soc() +# await con.update_temps2() +# await con.update_cell_voltage_range() +# await con.update_mosfet() +# await con.update_bal() +# await con.update_errors() +# await con.bt_bms.disconnect() +#asyncio.run(main(con)) + +parser = argparse.ArgumentParser() +parser.add_argument("-d", "--device", + help="MAC address, e.g. 88:99:AA:BB:CC", + type=str) +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-discharge-mosfet", help="'on' or 'off'", type=str) +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) +parser.add_argument("-C","--configfile", + nargs="?", + type=str, + help="Full location of config file (default None, /etc/dalybt.conf if -C supplied)", + const="/etc/dalybt.conf", + default=None,) +parser.add_argument("--daemon", action="store_true", help="Run as daemon",default=False) +parser.add_argument("--daemon-pause", type=int, help="Sleep time for daemon",default=60) +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() +#print (logger) + + # If config file specified, process +if args.configfile: + import configparser + logger.debug(f"args.configfile is true: {args.configfile}") + config = configparser.ConfigParser() + try: + config.read(args.configfile) + except configparser.DuplicateSectionError as e: + log.error(f"Config File '{args.configfile}' has duplicate sections") + log.error(e) + exit(1) + sections = config.sections() + # Check setup section exists + if "SETUP" not in config: + log.error(f"Config File '{args.configfile}' is missing the required 'SETUP' section") + exit(1) + # Process setup section + args.daemon_pause = config["SETUP"].getint("daemon-pause", fallback=60) + # Overide mqtt_broker settings + args.mqtt = config["SETUP"].getboolean("mqtt", fallback=args.mqtt) + args.mqtt_hass = config["SETUP"].getboolean("mqtt-hass", fallback=args.mqtt_hass) + args.mqtt_broker = config["SETUP"].get("mqtt-broker", fallback=args.mqtt_broker) + args.mqtt_port = config["SETUP"].getint("mqtt-port", fallback=args.mqtt_port) + args.mqtt_user = config["SETUP"].get("mqtt-user", fallback=args.mqtt_user) + args.mqtt_password = config["SETUP"].get("mqtt-password", fallback=args.mqtt_password) + sections.remove("SETUP") + + args.device = config["DALYBTBMS"].get("device", fallback=None) + +#print(args) + +bms = DalyBMSConnection(mac_address=args.device,request_retries=3, logger=logger ) +#asyncio.run(bms.connect()) + +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) + mqtt_client.loop_start() + +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: + 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' + 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)) + + +# Initialize Daemon +if args.daemon: + import time + import systemd.daemon + + # Tell systemd that our service is ready + systemd.daemon.notify(systemd.daemon.Notification.READY) + + +while True: + if args.status: + result = asyncio.run(bms.get_status()) + print_result(result) + if args.soc: + result = asyncio.run(bms.get_soc()) + print_result(result) + if args.mosfet: + result = asyncio.run(bms.get_mosfet_status()) + print_result(result) + if args.cell_voltages: + if not args.status: + asyncio.run(bms.get_status()) + result = asyncio.run(bms.get_cell_voltages()) + print_result(result) + if args.temperatures: + result = asyncio.run(bms.get_temperatures()) + print_result(result) + if args.balancing: + result = asyncio.run(bms.get_balancing_status()) + print_result(result) + if args.errors: + result = asyncio.run(bms.get_errors()) + print_result(result) + if args.all: + result = asyncio.run(bms.get_all()) + print_result(result) + + if args.check: + status = asyncio.run(bms.get_status()) + status_code = 0 # OK + status_codes = ('OK', 'WARNING', 'CRITICAL', 'UNKNOWN') + status_line = '' + + data = asyncio.run(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_discharge_mosfet: + break #setting mosfets not part of the daemon loop + if args.daemon: + systemd.daemon.notify(systemd.daemon.Notification.WATCHDOG) + print(f"Sleeping for {args.daemon_pause} sec") + time.sleep(args.daemon_pause) + else: + logger.debug("Once off command, not looping") + break + + +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 = asyncio.run(bms.set_discharge_mosfet(on=on)) + +if mqtt_client: + mqtt_client.disconnect() + mqtt_client.loop_stop() + +asyncio.run(bms.disconnect()) + +if not result: + sys.exit(1) + +if args.daemon: + systemd.daemon.notify(systemd.daemon.Notification.STOPPING) diff --git a/dalybms/daly_bms_bluetooth.py b/dalybms/daly_bms_bluetooth.py index 104eddc..769f9cf 100644 --- a/dalybms/daly_bms_bluetooth.py +++ b/dalybms/daly_bms_bluetooth.py @@ -1,3 +1,4 @@ +#!/usr/bin/python3 import asyncio import subprocess import logging @@ -29,15 +30,18 @@ async def connect(self, mac_address): """ try: """ - When an earlier execution of the script crashed, the connection to the devices stays open and future + 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 """ + out = subprocess.check_output("rfkill unblock bluetooth", shell = True) 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) @@ -55,7 +59,7 @@ async def disconnect(self): async def _read_request(self, command, max_responses=1): response_data = None - x = None + x = 0 for x in range(0, self.request_retries): response_data = await self._read( command=command, @@ -179,7 +183,7 @@ 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(), + "temperature_range": await self.get_max_min_temperature(), "mosfet_status": await self.get_mosfet_status(), "status": await self.get_status(), "cell_voltages": await self.get_cell_voltages(), diff --git a/service/dalybt.conf b/service/dalybt.conf new file mode 100644 index 0000000..513c127 --- /dev/null +++ b/service/dalybt.conf @@ -0,0 +1,14 @@ +[SETUP] +# Number of seconds to pause between loops of processing the sections +# i.e. the pause at the end of an entire run through the config file +# default is 60 +pause=60 +mqtt=True +mqtt-broker=localhost +mqtt-port=1883 +mqtt-user=username +mqtt-password=password +mqtt-hass=True + +[DALYBTBMS] +device=02:11:23:34:7D:88 diff --git a/service/dalybt.service b/service/dalybt.service new file mode 100644 index 0000000..de5439c --- /dev/null +++ b/service/dalybt.service @@ -0,0 +1,32 @@ +# systemd unit file for the DALY Bluetooth Service +# +# needs to go to /etc/systemd/user/* + +[Unit] +# Human readable name of the unit +Description=Daly Bluetooth Service + + +[Service] +# Command to execute when the service is started +ExecStart=/usr/bin/python3 /usr/local/bin/daly-bmsBT-cli -C /etc/dalybt.conf --daemon --all + +# Disable Python's buffering of STDOUT and STDERR, so that output from the +# service shows up immediately in systemd's logs +Environment=PYTHONUNBUFFERED=1 + +# Automatically restart the service if it crashes +Restart=always +WatchdogSec=300 + +# Our service will notify systemd once it is up and running +Type=notify + +# Use a dedicated user to run our service +User=pi + + +[Install] +# Tell systemd to automatically start this service when the system boots +# (assuming the service is enabled) +WantedBy=default.target diff --git a/setup.py b/setup.py index 9f9175d..21f049c 100644 --- a/setup.py +++ b/setup.py @@ -1,6 +1,6 @@ #!/usr/bin/python3 import pathlib -from setuptools import setup +from setuptools import setup, find_packages HERE = pathlib.Path(__file__).parent README = (HERE / "README.md").read_text() @@ -22,6 +22,6 @@ "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", ], - packages=["dalybms"], - scripts=["bin/daly-bms-cli"], + packages=find_packages(), + scripts=["bin/daly-bms-cli" ,"bin/daly-bmsBT-cli"], )