├── .github └── ISSUE_TEMPLATE │ └── bug-report.md ├── .gitignore ├── .idea ├── .gitignore ├── .name ├── batmon.iml ├── codeStyles │ ├── Project.xml │ └── codeStyleConfig.xml ├── csv-plugin.xml ├── deployment.xml ├── dictionaries │ ├── Fabian.xml │ └── fab.xml ├── encodings.xml ├── inspectionProfiles │ └── profiles_settings.xml ├── misc.xml ├── modules.xml ├── runConfigurations │ ├── block_compute.xml │ └── main.xml ├── sshConfigs.xml └── vcs.xml ├── CHANGELOG.md ├── Dockerfile ├── LICENSE ├── LICENSE_BMS_BLE-HA ├── README.md ├── TODO.md ├── addon_main.sh ├── apparmor.txt ├── bmslib ├── __init__.py ├── algorithm.py ├── bms.py ├── bms_ble │ ├── __init__.py │ ├── const.py │ ├── plugins │ │ ├── __init__.py │ │ ├── abc_bms.py │ │ ├── basebms.py │ │ ├── cbtpwr_bms.py │ │ ├── daly_bms.py │ │ ├── dpwrcore_bms.py │ │ ├── dummy_bms.py │ │ ├── ecoworthy_bms.py │ │ ├── ective_bms.py │ │ ├── ej_bms.py │ │ ├── felicity_bms.py │ │ ├── jbd_bms.py │ │ ├── jikong_bms.py │ │ ├── ogt_bms.py │ │ ├── redodo_bms.py │ │ ├── roypow_bms.py │ │ ├── seplos_bms.py │ │ ├── seplos_v2_bms.py │ │ └── tdt_bms.py │ ├── strings.json │ └── translations │ │ ├── de.json │ │ ├── en.json │ │ └── pt.json ├── bt.py ├── cache │ ├── __init__.py │ ├── disk.py │ └── mem.py ├── group.py ├── models │ ├── BLE_BMS_wrap.py │ ├── __init__.py │ ├── ant.py │ ├── daly.py │ ├── daly2.py │ ├── dummy.py │ ├── jbd.py │ ├── jikong.py │ ├── sok.py │ ├── supervolt.py │ └── victron.py ├── pwmath.py ├── sampling.py ├── sinks.py ├── store.py ├── test │ ├── group.py │ └── test_futures_pool.py ├── tracker.py ├── util.py └── wired │ ├── __init__.py │ └── transport.py ├── config.yaml ├── doc ├── Algorithms.md ├── BMSes.md ├── Battery Care.md ├── Calibration.md ├── Downgrade.md ├── Groups.md ├── HA Energy Dashboard.md ├── InfluxDB.md ├── LiFePo4.md ├── NOTES.md ├── Solar.md ├── Standalone.md ├── Telemetry.md └── dev │ ├── BT Sniffing.md │ ├── Cycle Logic.md │ ├── Impedance.md │ └── snooping.py ├── main.py ├── mqtt_util.py ├── requirements.txt └── tools ├── bit_finder.py ├── bt_discovery.py ├── impedance ├── README.md ├── ac_impedance.py ├── block_compute.py ├── data.md ├── data.py ├── datasets.py ├── energy.py ├── imp.py ├── imp2.py ├── mppt_scan_I.csv ├── mppt_scan_V.csv ├── requirements.txt ├── stats.py └── test.py ├── old-data.md └── service_explorer.py /.github/ISSUE_TEMPLATE/bug-report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug Report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | Describe the issue here. 11 | If you experience bluetooth connection problems, follow these steps before opening an issue: 12 | https://github.com/fl4p/batmon-ha#troubleshooting 13 | 14 | * Include exact BMS model name 15 | * Query HW and SW numbers from the BMS app 16 | * Enable verbose_log 17 | 18 | ``` 19 | Paste log output between BEGIN and END: 20 | BEGIN 21 | 22 | END 23 | ``` 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | .DS_Store 3 | 4 | options.json 5 | 6 | bms_meter_states.json 7 | 8 | /bat_*.json 9 | 10 | /venv 11 | tools/impedance/influxdb_*.json 12 | 13 | user_id 14 | 15 | *.pickle 16 | -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | # Datasource local storage ignored files 5 | /dataSources/ 6 | /dataSources.local.xml 7 | # Editor-based HTTP Client requests 8 | /httpRequests/ 9 | -------------------------------------------------------------------------------- /.idea/.name: -------------------------------------------------------------------------------- 1 | batmon -------------------------------------------------------------------------------- /.idea/batmon.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /.idea/codeStyles/Project.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | -------------------------------------------------------------------------------- /.idea/codeStyles/codeStyleConfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | -------------------------------------------------------------------------------- /.idea/csv-plugin.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 22 | 23 | -------------------------------------------------------------------------------- /.idea/deployment.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 37 | -------------------------------------------------------------------------------- /.idea/dictionaries/Fabian.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | batmon 5 | bluetooth 6 | bluetoothctl 7 | daly 8 | esphome 9 | jikong 10 | mqtt 11 | victron 12 | xiaoxiang 13 | 14 | 15 | -------------------------------------------------------------------------------- /.idea/dictionaries/fab.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | ewma 5 | fabi 6 | hassio 7 | 8 | 9 | -------------------------------------------------------------------------------- /.idea/encodings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/profiles_settings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.idea/runConfigurations/block_compute.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 25 | -------------------------------------------------------------------------------- /.idea/runConfigurations/main.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 26 | -------------------------------------------------------------------------------- /.idea/sshConfigs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | ARG BUILD_FROM 2 | FROM $BUILD_FROM 3 | 4 | WORKDIR /app 5 | 6 | # Install requirements for add-on 7 | # (alpine image) 8 | # RUN apk add --no-cache python3 bluez py-pip git 9 | 10 | RUN apk add python3 11 | RUN apk add bluez 12 | #RUN apk add bluez < 5.66-r4" 13 | # https://pkgs.alpinelinux.org/packages?name=bluez&branch=v3.16&repo=&arch=aarch64&maintainer= 14 | RUN apk add py-pip 15 | RUN apk add git 16 | # py3-pip 17 | 18 | # copy files 19 | COPY . . 20 | 21 | # create a separate venv for a specific bleak version that has a pairing agent that can pair devices with a PSK 22 | RUN python3 -m venv venv_bleak_pairing 23 | RUN venv_bleak_pairing/bin/pip3 install -r requirements.txt 24 | RUN venv_bleak_pairing/bin/pip3 install 'git+https://github.com/jpeters-ml/bleak@feature/windowsPairing' || true 25 | 26 | 27 | RUN python3 -m venv venv 28 | RUN venv/bin/pip3 install -r requirements.txt 29 | RUN venv/bin/pip3 install influxdb || true 30 | RUN . venv/bin/activate 31 | 32 | RUN chmod a+x addon_main.sh 33 | 34 | CMD ["./addon_main.sh" ] 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Fabian Schlieper 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | * track emptiest cell 2 | * always install newer bleak. if need to pair, install old bleak 3 | * Batmon show pw in logs 4 | * batmon set soc 5 | * Impedance computation 6 | * Calibrated SoC 7 | * Pin bleak version 8 | * Pin bluez version 9 | * Try latest bleak version with victron smart shunt (on HA OS and macOS) 10 | * https://github.com/hbldh/bleak/pull/1133 11 | * 12 | * Current calibration factor 13 | * For large publish periods, publish mean values 14 | * MicroPython port 15 | ** https://docs.micropython.org/en/latest/reference/packages.html 16 | 17 | * smooth current (10s) 18 | * only mqqt publish differences 19 | * SoC Energy compute? 20 | * temperatures 21 | * parallel fetch 22 | 23 | * MQTT discovery cleanup (use new names) 24 | * dashboard integration preset? https://community.home-assistant.io/t/esphome-daly-bms-using-uart-guide/394429 25 | * add ant bms: https://diysolarforum.com/threads/for-those-of-you-looking-to-monitor-your-ant-bms-with-pi3-via-bluetooth.6726/ 26 | 27 | Victron Readouts https://github.com/fl4p/batmon-ha/issues/63 28 | 29 | Merge Batteries together 30 | 31 | DONE: 32 | * BMSSample POD class 33 | 34 | - Rename MQTT messages 35 | - cell voltages 36 | - battery current, voltage, soc, capacity, charge 37 | - bms mosfet state 38 | - don't send discovery for nan-only data -------------------------------------------------------------------------------- /addon_main.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/with-contenv bashio 2 | 3 | MQTT_HOST=$(bashio::services mqtt "host") 4 | MQTT_USER=$(bashio::services mqtt "username") 5 | MQTT_PASSWORD=$(bashio::services mqtt "password") 6 | 7 | /app/venv/bin/python3 main.py pair-only 8 | 9 | 10 | MQTT_HOST=$MQTT_HOST MQTT_USER=$MQTT_USER MQTT_PASSWORD=$MQTT_PASSWORD \ 11 | /app/venv/bin/python3 main.py 12 | 13 | -------------------------------------------------------------------------------- /apparmor.txt: -------------------------------------------------------------------------------- 1 | #include 2 | 3 | profile batmon flags=(attach_disconnected,mediate_deleted) { 4 | #include 5 | 6 | # Capabilities 7 | network, 8 | capability, 9 | file, 10 | 11 | # ### 12 | # included from https://github.com/edgexfoundry-holding/device-bluetooth-c/blob/main/docker-ble-policy 13 | # ### 14 | dbus (send, receive) bus=system peer=(name=org.bluez, label=unconfined), 15 | dbus (send, receive) bus=system interface=org.freedesktop.DBus peer=(label=unconfined), 16 | 17 | # ### 18 | # included from https://github.com/jdstrand/snapd/blob/4befc00e3318a3231e96b38b575bf6e637ddad6c/interfaces/builtin/bluez.go 19 | # ### 20 | dbus (receive, send) 21 | bus=system 22 | interface=org.bluez.* 23 | peer=(label=unconfined), 24 | dbus (receive, send) 25 | bus=system 26 | path=/org/bluez{,/**} 27 | interface=org.freedesktop.DBus.* 28 | peer=(label=unconfined), 29 | dbus (receive, send) 30 | bus=system 31 | path=/ 32 | interface=org.freedesktop.DBus.* 33 | peer=(label=unconfined), 34 | 35 | 36 | # ### 37 | # included from https://developers.home-assistant.io/docs/add-ons/presentation#apparmor 38 | # ### 39 | signal (send) set=(kill,term,int,hup,cont), 40 | 41 | # Receive signals from S6-Overlay 42 | signal (send,receive) peer=*_batmon, 43 | 44 | # S6-Overlay 45 | /init ix, 46 | /bin/** ix, 47 | /usr/bin/** ix, 48 | /run/{s6,s6-rc*,service}/** ix, 49 | /package/** ix, 50 | /command/** ix, 51 | /etc/services.d/** rwix, 52 | /etc/cont-init.d/** rwix, 53 | /etc/cont-finish.d/** rwix, 54 | /run/{,**} rwk, 55 | /dev/tty rw, 56 | 57 | # Bashio 58 | /usr/lib/bashio/** ix, 59 | /tmp/** rwk, 60 | 61 | # Access to options.json and other files within your addon 62 | /data/** rw, 63 | 64 | # Access to mapped volumes specified in config.json 65 | /share/** rw, 66 | 67 | 68 | # ### 69 | # included from https://gist.github.com/disconnect3d/d578af68b09ab56db657854ec03879aa 70 | # (docker-default profile which would usually be used for this container) 71 | # ### 72 | signal (receive) peer=unconfined, 73 | signal (send,receive) peer=docker-default, 74 | 75 | deny @{PROC}/* w, # deny write for all files directly in /proc (not in a subdir) 76 | # deny write to files not in /proc//** or /proc/sys/** 77 | deny @{PROC}/{[^1-9],[^1-9][^0-9],[^1-9s][^0-9y][^0-9s],[^1-9][^0-9][^0-9][^0-9]*}/** w, 78 | deny @{PROC}/sys/[^k]** w, # deny /proc/sys except /proc/sys/k* (effectively /proc/sys/kernel) 79 | deny @{PROC}/sys/kernel/{?,??,[^s][^h][^m]**} w, # deny everything except shm* in /proc/sys/kernel/ 80 | deny @{PROC}/sysrq-trigger rwklx, 81 | deny @{PROC}/kcore rwklx, 82 | 83 | deny mount, 84 | 85 | deny /sys/[^f]*/** wklx, 86 | deny /sys/f[^s]*/** wklx, 87 | deny /sys/fs/[^c]*/** wklx, 88 | deny /sys/fs/c[^g]*/** wklx, 89 | deny /sys/fs/cg[^r]*/** wklx, 90 | deny /sys/firmware/** rwklx, 91 | deny /sys/kernel/security/** rwklx, 92 | 93 | 94 | # suppress ptrace denials when using 'docker ps' or using 'ps' inside a container 95 | ptrace (trace,read) peer=docker-default, 96 | } -------------------------------------------------------------------------------- /bmslib/__init__.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from typing import Dict, Union, Tuple 3 | 4 | # NameType = Union[str, Tuple[str]] 5 | NameType = Union[str, int, Tuple[Union[str, int]]] 6 | 7 | 8 | class FuturesPool: 9 | """ 10 | Manage a collection of named futures. 11 | """ 12 | 13 | def __init__(self): 14 | self._futures: Dict[str, asyncio.Future] = {} 15 | 16 | def acquire(self, name: NameType): 17 | if isinstance(name, tuple): 18 | tuple(self.acquire(n) for n in name) 19 | return FutureContext(name, pool=self) 20 | 21 | assert isinstance(name, (str, int)) 22 | 23 | existing = self._futures.get(name) 24 | if existing and not existing.done(): 25 | raise Exception("already waiting for future named '%s'" % name) 26 | 27 | fut = asyncio.Future() 28 | self._futures[name] = fut 29 | return FutureContext(name, pool=self) 30 | 31 | async def acquire_timeout(self, name: NameType, timeout): 32 | if isinstance(name, tuple): 33 | await asyncio.gather(*tuple(self.acquire_timeout(n, timeout) for n in name), return_exceptions=False) 34 | return FutureContext(name, pool=self) 35 | 36 | assert isinstance(name, (str, int)) 37 | 38 | existing = self._futures.get(name) 39 | if existing and not existing.done(): 40 | for i in range(int(timeout * 10)): 41 | await asyncio.sleep(.1) 42 | if existing.done(): 43 | existing = None 44 | break 45 | if existing: 46 | raise Exception("still waiting for future named '%s'" % name) 47 | 48 | fut = asyncio.Future() 49 | self._futures[name] = fut 50 | return FutureContext(name, pool=self) 51 | 52 | def set_result(self, name, value): 53 | fut = self._futures.get(name, None) 54 | if fut: 55 | if fut.done(): 56 | # silently remove done future 57 | self.remove(name) 58 | else: 59 | fut.set_result(value) 60 | 61 | def clear(self): 62 | for fut in self._futures.values(): 63 | fut.cancel() 64 | self._futures.clear() 65 | 66 | def remove(self, name): 67 | if isinstance(name, tuple): 68 | return tuple(self.remove(n) for n in name) 69 | assert isinstance(name, (str, int)) 70 | self._futures.pop(name, None) 71 | 72 | async def wait_for(self, name: NameType, timeout): 73 | if isinstance(name, tuple): 74 | tasks = [self.wait_for(n, timeout) for n in name] 75 | return await asyncio.gather(*tasks, return_exceptions=False) 76 | 77 | if name not in self._futures: 78 | raise KeyError('future %s not found' % name) 79 | 80 | try: 81 | return await asyncio.wait_for(self._futures.get(name), timeout) 82 | except (asyncio.TimeoutError, asyncio.CancelledError): 83 | self.remove(name) 84 | raise asyncio.TimeoutError("timeout waiting for %s" % name) 85 | finally: 86 | self.remove(name) 87 | 88 | 89 | class FutureContext: 90 | def __init__(self, name: NameType, pool: FuturesPool): 91 | self.name = name 92 | self.pool = pool 93 | 94 | def __enter__(self): 95 | pass 96 | 97 | def __exit__(self, exc_type, exc_val, exc_tb): 98 | self.pool.remove(self.name) 99 | -------------------------------------------------------------------------------- /bmslib/algorithm.py: -------------------------------------------------------------------------------- 1 | import time 2 | from typing import Optional, Union 3 | 4 | from bmslib.bms import BmsSample 5 | from bmslib.util import get_logger, dict_to_short_string 6 | 7 | logger = get_logger() 8 | 9 | 10 | class BatterySwitches: 11 | def __init__(self, charge: Optional[bool] = None, discharge: Optional[bool] = None): 12 | self.charge = charge 13 | self.discharge = discharge 14 | 15 | def __str__(self): 16 | s = 'BatSw(' 17 | if self.charge is not None: 18 | s += 'chg=%i ' % self.charge 19 | if self.discharge is not None: 20 | s += 'dis=%i ' % self.discharge 21 | return s.strip() + ')' 22 | 23 | def __getitem__(self, item): 24 | return self.__dict__[item] 25 | 26 | 27 | class UpdateResult: 28 | def __init__(self, switches: BatterySwitches): 29 | self.switches = switches 30 | 31 | def __str__(self): 32 | return f'{self.switches}' 33 | 34 | 35 | class BaseAlgorithm: 36 | state = None 37 | 38 | def __init__(self, name: str): 39 | self.name = name 40 | 41 | def update(self, sample: BmsSample) -> UpdateResult: 42 | raise NotImplementedError() 43 | 44 | 45 | class SocArgs: 46 | def __init__(self, charge_stop, charge_start=None, discharge_stop=None, discharge_start=None, 47 | calibration_interval_h=24 * 14): 48 | charge_stop = float(charge_stop.strip('%')) 49 | if not charge_start: 50 | charge_start = charge_stop 51 | else: 52 | charge_start = float(charge_start.strip('%')) 53 | assert charge_stop >= charge_start 54 | 55 | self.charge_stop = charge_stop 56 | self.charge_start = charge_start 57 | self.discharge_stop = discharge_stop 58 | self.discharge_start = discharge_start 59 | self.calibration_interval_s = (calibration_interval_h or 0) * 3600 60 | 61 | def __str__(self): 62 | return dict_to_short_string(self.__dict__) 63 | 64 | 65 | class SocState: 66 | def __init__(self, charging: bool, last_calibration_time: float): 67 | self.charging = charging # this is not currently used (write only) 68 | self.last_calibration_time = last_calibration_time 69 | 70 | def __str__(self): 71 | return f'SocState(chg={self.charging}, t_calib={int(self.last_calibration_time)})' 72 | 73 | 74 | class SocAlgorithm(BaseAlgorithm): 75 | 76 | def __init__(self, name, args: SocArgs, state: SocState): 77 | super().__init__(name=name) 78 | self.args = args 79 | self.state: SocState = state 80 | self._logged_calib = False 81 | # self._debug_state = {} 82 | 83 | # def restore(self, charging, last_calibration_time): 84 | 85 | def update(self, sample: BmsSample) -> Optional[UpdateResult]: 86 | # SOC_SPAN_MARGIN = 1 / 5 87 | 88 | if self.args.calibration_interval_s: 89 | time_since_last_calib = sample.timestamp - self.state.last_calibration_time 90 | need_calibration = time_since_last_calib > self.args.calibration_interval_s 91 | if need_calibration: 92 | if sample.soc == 100: 93 | logger.info('Reached 100% soc, calibration done.') 94 | self.state.last_calibration_time = sample.timestamp 95 | self.state.charging = False 96 | return UpdateResult(switches=BatterySwitches(charge=False)) 97 | # ^^ don't return None here, need to store state! 98 | 99 | if not sample.switches['charge']: 100 | logger.info('Need calibration, charge to 100%% soc (calib.interval=%.0f h, last calib=%.0f h ago', 101 | self.args.calibration_interval_s / 3600, time_since_last_calib / 3600) 102 | self.state.charging = True 103 | return UpdateResult(switches=BatterySwitches(charge=True)) 104 | 105 | if not self._logged_calib: 106 | logger.info("Calibrating SoC ...") 107 | self._logged_calib = True 108 | 109 | return # nop 110 | 111 | if self.state.charging: 112 | if sample.soc >= self.args.charge_stop: 113 | self.state.charging = False 114 | if sample.switches['charge']: 115 | logger.info('Max Soc reached, stop charging') 116 | return UpdateResult(switches=BatterySwitches(charge=False)) 117 | else: 118 | if sample.soc <= min(self.args.charge_start, self.args.charge_stop - 0.2): 119 | self.state.charging = True 120 | if not sample.switches['charge']: 121 | logger.info('Min Soc reached, start charging') 122 | return UpdateResult(switches=BatterySwitches(charge=True)) 123 | 124 | # span = self.args.charge_stop - self.args.charge_start 125 | # if self.args.charge_stop - max(span*SOC_SPAN_MARGIN, 1) < sample.soc < self.args.charge_stop: 126 | # if sample.switches['charge']: 127 | 128 | 129 | # noinspection PyShadowingBuiltins 130 | def create_algorithm(repr: Union[dict, str], bms_name=None) -> BaseAlgorithm: 131 | classes = dict(soc=SocAlgorithm) 132 | args, kwargs = [], {} 133 | if isinstance(repr, dict): 134 | repr = dict(repr) 135 | name = repr.pop('name') 136 | kwargs = repr 137 | else: 138 | repr = repr.strip().split(' ') 139 | name = repr.pop(0) 140 | args = repr 141 | 142 | from bmslib.store import store_algorithm_state 143 | state = store_algorithm_state(bms_name, algorithm_name=name) 144 | algo = classes[name]( 145 | name=name, 146 | args=SocArgs(*args, **kwargs), 147 | state=SocState(**state) if state else SocState(charging=True, last_calibration_time=time.time()) 148 | ) 149 | 150 | if state: 151 | logger.info('Restored %s algo [args=%s] state %s', name, algo.args, dict_to_short_string(state)) 152 | else: 153 | logger.info('Initialized %s algo [args=%s]', name, algo.args) 154 | 155 | return algo 156 | -------------------------------------------------------------------------------- /bmslib/bms.py: -------------------------------------------------------------------------------- 1 | import math 2 | import time 3 | from copy import copy 4 | from typing import List, Dict, Optional 5 | 6 | MIN_VALUE_EXPIRY = 20 7 | 8 | 9 | class DeviceInfo: 10 | def __init__(self, mnf: str, model: str, hw_version: Optional[str], sw_version: Optional[str], name: Optional[str], 11 | sn: Optional[str] = None): 12 | self.mnf = mnf 13 | self.model = model 14 | self.hw_version = hw_version 15 | self.sw_version = sw_version 16 | self.name = name 17 | self.sn = sn 18 | 19 | def __str__(self): 20 | s = f'DeviceInfo({self.model},hw-{self.hw_version},sw-{self.sw_version}' 21 | if self.name: 22 | s += ',' + self.name 23 | if self.sn: 24 | s += ',#' + self.sn 25 | return s + ')' 26 | 27 | 28 | class PowerMonitorSample: 29 | # Todo this is a draft 30 | def __init__(self, voltage, current, power=math.nan, total_energy=math.nan): 31 | pass 32 | 33 | 34 | class BmsSample: 35 | def __init__(self, voltage, current, power=math.nan, 36 | charge=math.nan, capacity=math.nan, cycle_capacity=math.nan, 37 | num_cycles=math.nan, soc=math.nan, 38 | balance_current=math.nan, 39 | temperatures: List[float] = None, 40 | mos_temperature: float = math.nan, 41 | switches: Optional[Dict[str, bool]] = None, 42 | uptime=math.nan, timestamp: Optional[float] = None): 43 | """ 44 | 45 | :param voltage: 46 | :param current: Current out of the battery (negative=charging, positive=discharging) 47 | :param charge: The charge available in Ah, aka remaining capacity, between 0 and `capacity` 48 | :param capacity: The capacity of the battery in Ah 49 | :param cycle_capacity: Total absolute charge meter (coulomb counter). Increases during charge and discharge. Can tell you the battery cycles (num_cycles = cycle_capacity/2/capacity). A better name would be cycle_charge. This is not well defined. 50 | :param num_cycles: 51 | :param soc: in % (0-100) 52 | :param balance_current: 53 | :param temperatures: 54 | :param mos_temperature: 55 | :param uptime: BMS uptime in seconds 56 | :param timestamp: seconds since epoch (unix timestamp from time.time()) 57 | """ 58 | self.voltage: float = voltage 59 | self.current: float = current or 0 # - 60 | self._power = power # 0 -> +0 61 | self.balance_current = balance_current 62 | 63 | # infer soc from capacity if soc is nan or type(soc)==int (for higher precision) 64 | if capacity > 0 and (math.isnan(soc) or (isinstance(soc, int) and charge > 0)): 65 | soc = round(charge / capacity * 100, 2) 66 | elif math.isnan(capacity) and soc > .2: 67 | capacity = round(charge / soc * 100) 68 | 69 | # assert math.isfinite(soc) 70 | 71 | self.charge: float = charge 72 | self.capacity: float = capacity 73 | self.soc: float = soc 74 | self.cycle_capacity: float = cycle_capacity 75 | self.num_cycles: float = num_cycles 76 | self.temperatures = temperatures 77 | self.mos_temperature = mos_temperature 78 | self.switches = switches 79 | self.uptime = uptime 80 | self.timestamp = timestamp or time.time() 81 | 82 | self.num_samples = 0 83 | 84 | if switches: 85 | assert all(map(lambda x: isinstance(x, bool), switches.values())), "non-bool switches values %s" % switches 86 | 87 | @property 88 | def power(self): 89 | """ 90 | :return: Power (P=U*I) in W 91 | """ 92 | return (self.voltage * self.current) if math.isnan(self._power) else self._power 93 | 94 | def values(self): 95 | return {**self.__dict__, "power": self.power} 96 | 97 | def __str__(self): 98 | # noinspection PyStringFormat 99 | s = 'BmsSampl(' 100 | if not math.isnan(self.soc): 101 | s += '%.1f%%,' % self.soc 102 | vals = self.values() 103 | s += 'U=%(voltage).1fV,I=%(current).2fA,P=%(power).0fW,' % vals 104 | if not math.isnan(self.charge): 105 | s += 'Q=%(charge).0f/%(capacity).0fAh,mos=%(mos_temperature).0f°C' % vals 106 | return s.rstrip(',') + ')' 107 | 108 | def invert_current(self): 109 | return self.multiply_current(-1) 110 | 111 | def multiply_current(self, x): 112 | res = copy(self) 113 | if res.current != 0: # prevent -0 values 114 | res.current *= x 115 | if not math.isnan(res._power) and res._power != 0: 116 | res._power *= x 117 | return res 118 | -------------------------------------------------------------------------------- /bmslib/bms_ble/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fl4p/batmon-ha/8e8704d56a0c2a1f426203a78adc7121302eb886/bmslib/bms_ble/__init__.py -------------------------------------------------------------------------------- /bmslib/bms_ble/const.py: -------------------------------------------------------------------------------- 1 | """Constants for the BLE Battery Management System integration.""" 2 | 3 | import logging 4 | from typing import Final 5 | 6 | ATTR_BATTERY_CHARGING: Final = "battery_charging" 7 | ATTR_BATTERY_LEVEL: Final = "battery_level" 8 | ATTR_VOLTAGE: Final = "voltage" 9 | ATTR_TEMPERATURE: Final = "temperature" 10 | 11 | 12 | BMS_TYPES: Final[list[str]] = [ 13 | "abc_bms", 14 | "cbtpwr_bms", 15 | "daly_bms", 16 | "ecoworthy_bms", 17 | "ective_bms", 18 | "ej_bms", 19 | "jbd_bms", 20 | "jikong_bms", 21 | "ogt_bms", 22 | "redodo_bms", 23 | "seplos_bms", 24 | "seplos_v2_bms", 25 | "roypow_bms", 26 | "tdt_bms", 27 | "dpwrcore_bms", # only name filter 28 | "felicity_bms", 29 | ] # available BMS types 30 | DOMAIN: Final[str] = "bms_ble" 31 | LOGGER: Final = logging.getLogger(__package__) 32 | UPDATE_INTERVAL: Final[int] = 30 # [s] 33 | 34 | # attributes (do not change) 35 | ATTR_BALANCE_CUR: Final[str] = "balance_current" # [A] 36 | ATTR_CELL_VOLTAGES: Final[str] = "cell_voltages" # [V] 37 | ATTR_CURRENT: Final[str] = "current" # [A] 38 | ATTR_CYCLE_CAP: Final[str] = "cycle_capacity" # [Wh] 39 | ATTR_CYCLE_CHRG: Final[str] = "cycle_charge" # [Ah] 40 | ATTR_CYCLES: Final[str] = "cycles" # [#] 41 | ATTR_DELTA_VOLTAGE: Final[str] = "delta_voltage" # [V] 42 | ATTR_LQ: Final[str] = "link_quality" # [%] 43 | ATTR_POWER: Final[str] = "power" # [W] 44 | ATTR_PROBLEM: Final[str] = "problem" # [bool] 45 | ATTR_RSSI: Final[str] = "rssi" # [dBm] 46 | ATTR_RUNTIME: Final[str] = "runtime" # [s] 47 | ATTR_TEMP_SENSORS: Final[str] = "temperature_sensors" # [°C] 48 | 49 | # temporary dictionary keys (do not change) 50 | KEY_CELL_COUNT: Final[str] = "cell_count" # [#] 51 | KEY_CELL_VOLTAGE: Final[str] = "cell#" # [V] 52 | KEY_DESIGN_CAP: Final[str] = "design_capacity" # [Ah] 53 | KEY_PACK: Final[str] = "pack" # prefix for pack sensors 54 | KEY_PACK_COUNT: Final[str] = "pack_count" # [#] 55 | KEY_PROBLEM: Final[str] = "problem_code" # [#] 56 | KEY_TEMP_SENS: Final[str] = "temp_sensors" # [#] 57 | KEY_TEMP_VALUE: Final[str] = "temp#" # [°C] 58 | -------------------------------------------------------------------------------- /bmslib/bms_ble/plugins/__init__.py: -------------------------------------------------------------------------------- 1 | """Package for battery management systems (BMS) plugins.""" 2 | -------------------------------------------------------------------------------- /bmslib/bms_ble/plugins/daly_bms.py: -------------------------------------------------------------------------------- 1 | """Module to support Daly Smart BMS.""" 2 | 3 | from collections.abc import Callable 4 | from typing import Final 5 | 6 | from bleak.backends.characteristic import BleakGATTCharacteristic 7 | from bleak.backends.device import BLEDevice 8 | from bleak.uuids import normalize_uuid_str 9 | 10 | from bmslib.bms_ble.const import ( 11 | ATTR_BATTERY_CHARGING, 12 | ATTR_BATTERY_LEVEL, 13 | ATTR_CURRENT, 14 | ATTR_CYCLE_CAP, 15 | ATTR_CYCLE_CHRG, 16 | ATTR_CYCLES, 17 | ATTR_DELTA_VOLTAGE, 18 | ATTR_POWER, 19 | ATTR_RUNTIME, 20 | ATTR_TEMPERATURE, 21 | ATTR_VOLTAGE, 22 | KEY_CELL_COUNT, 23 | KEY_CELL_VOLTAGE, 24 | KEY_PROBLEM, 25 | KEY_TEMP_SENS, 26 | KEY_TEMP_VALUE, 27 | ) 28 | 29 | from .basebms import BaseBMS, BMSsample, crc_modbus 30 | 31 | 32 | class BMS(BaseBMS): 33 | """Daly Smart BMS class implementation.""" 34 | 35 | HEAD_READ: Final[bytes] = b"\xD2\x03" 36 | CMD_INFO: Final[bytes] = b"\x00\x00\x00\x3E\xD7\xB9" 37 | MOS_INFO: Final[bytes] = b"\x00\x3E\x00\x09\xF7\xA3" 38 | HEAD_LEN: Final[int] = 3 39 | CRC_LEN: Final[int] = 2 40 | MAX_CELLS: Final[int] = 32 41 | MAX_TEMP: Final[int] = 8 42 | INFO_LEN: Final[int] = 84 + HEAD_LEN + CRC_LEN + MAX_CELLS + MAX_TEMP 43 | MOS_TEMP_POS: Final[int] = HEAD_LEN + 8 44 | _FIELDS: Final[list[tuple[str, int, int, Callable[[int], int | float]]]] = [ 45 | (ATTR_VOLTAGE, 80 + HEAD_LEN, 2, lambda x: float(x / 10)), 46 | (ATTR_CURRENT, 82 + HEAD_LEN, 2, lambda x: float((x - 30000) / 10)), 47 | (ATTR_BATTERY_LEVEL, 84 + HEAD_LEN, 2, lambda x: float(x / 10)), 48 | (ATTR_CYCLE_CHRG, 96 + HEAD_LEN, 2, lambda x: float(x / 10)), 49 | (KEY_CELL_COUNT, 98 + HEAD_LEN, 2, lambda x: min(x, BMS.MAX_CELLS)), 50 | (KEY_TEMP_SENS, 100 + HEAD_LEN, 2, lambda x: min(x, BMS.MAX_TEMP)), 51 | (ATTR_CYCLES, 102 + HEAD_LEN, 2, lambda x: x), 52 | (ATTR_DELTA_VOLTAGE, 112 + HEAD_LEN, 2, lambda x: float(x / 1000)), 53 | (KEY_PROBLEM, 116 + HEAD_LEN, 8, lambda x: x % 2**64), 54 | ] 55 | 56 | def __init__(self, ble_device: BLEDevice, reconnect: bool = False) -> None: 57 | """Intialize private BMS members.""" 58 | super().__init__(__name__, ble_device, reconnect) 59 | 60 | @staticmethod 61 | def matcher_dict_list() -> list[dict]: 62 | """Provide BluetoothMatcher definition.""" 63 | return [ 64 | { 65 | "local_name": "DL-*", 66 | "service_uuid": BMS.uuid_services()[0], 67 | "connectable": True, 68 | }, 69 | ] + [ 70 | {"manufacturer_id": m_id, "connectable": True} 71 | for m_id in (0x102, 0x104, 0x0302) 72 | ] 73 | 74 | @staticmethod 75 | def device_info() -> dict[str, str]: 76 | """Return device information for the battery management system.""" 77 | return {"manufacturer": "Daly", "model": "Smart BMS"} 78 | 79 | @staticmethod 80 | def uuid_services() -> list[str]: 81 | """Return list of 128-bit UUIDs of services required by BMS.""" 82 | return [normalize_uuid_str("fff0")] 83 | 84 | @staticmethod 85 | def uuid_rx() -> str: 86 | """Return 16-bit UUID of characteristic that provides notification/read property.""" 87 | return "fff1" 88 | 89 | @staticmethod 90 | def uuid_tx() -> str: 91 | """Return 16-bit UUID of characteristic that provides write property.""" 92 | return "fff2" 93 | 94 | @staticmethod 95 | def _calc_values() -> frozenset[str]: 96 | return frozenset( 97 | { 98 | ATTR_CYCLE_CAP, 99 | ATTR_POWER, 100 | ATTR_BATTERY_CHARGING, 101 | ATTR_RUNTIME, 102 | ATTR_TEMPERATURE, 103 | } 104 | ) 105 | 106 | def _notification_handler( 107 | self, _sender: BleakGATTCharacteristic, data: bytearray 108 | ) -> None: 109 | self._log.debug("RX BLE data: %s", data) 110 | 111 | if ( 112 | len(data) < BMS.HEAD_LEN 113 | or data[0:2] != BMS.HEAD_READ 114 | or int(data[2]) + 1 != len(data) - len(BMS.HEAD_READ) - BMS.CRC_LEN 115 | ): 116 | self._log.debug("response data is invalid") 117 | return 118 | 119 | if (crc := crc_modbus(data[:-2])) != int.from_bytes( 120 | data[-2:], byteorder="little" 121 | ): 122 | self._log.debug( 123 | "invalid checksum 0x%X != 0x%X", 124 | int.from_bytes(data[-2:], byteorder="little"), 125 | crc, 126 | ) 127 | self._data.clear() 128 | return 129 | 130 | self._data = data 131 | self._data_event.set() 132 | 133 | async def _async_update(self) -> BMSsample: 134 | """Update battery status information.""" 135 | data: BMSsample = {} 136 | try: 137 | # request MOS temperature (possible outcome: response, empty response, no response) 138 | await self._await_reply(BMS.HEAD_READ + BMS.MOS_INFO) 139 | 140 | if sum(self._data[BMS.MOS_TEMP_POS :][:2]): 141 | self._log.debug("MOS info: %s", self._data) 142 | data |= { 143 | f"{KEY_TEMP_VALUE}0": float( 144 | int.from_bytes( 145 | self._data[BMS.MOS_TEMP_POS :][:2], 146 | byteorder="big", 147 | signed=True, 148 | ) 149 | - 40 150 | ) 151 | } 152 | except TimeoutError: 153 | self._log.debug("no MOS temperature available.") 154 | 155 | await self._await_reply(BMS.HEAD_READ + BMS.CMD_INFO) 156 | 157 | if len(self._data) != BMS.INFO_LEN: 158 | self._log.debug("incorrect frame length: %i", len(self._data)) 159 | return {} 160 | 161 | data |= { 162 | key: func( 163 | int.from_bytes( 164 | self._data[idx : idx + size], byteorder="big", signed=True 165 | ) 166 | ) 167 | for key, idx, size, func in BMS._FIELDS 168 | } 169 | 170 | # get temperatures 171 | # shift index if MOS temperature is available 172 | t_off: Final[int] = 1 if f"{KEY_TEMP_VALUE}0" in data else 0 173 | data |= { 174 | f"{KEY_TEMP_VALUE}{((idx-64-BMS.HEAD_LEN)>>1) + t_off}": float( 175 | int.from_bytes(self._data[idx : idx + 2], byteorder="big", signed=True) 176 | - 40 177 | ) 178 | for idx in range( 179 | 64 + self.HEAD_LEN, 64 + self.HEAD_LEN + int(data[KEY_TEMP_SENS]) * 2, 2 180 | ) 181 | } 182 | 183 | # get cell voltages 184 | data |= { 185 | f"{KEY_CELL_VOLTAGE}{idx}": float( 186 | int.from_bytes( 187 | self._data[BMS.HEAD_LEN + 2 * idx : BMS.HEAD_LEN + 2 * idx + 2], 188 | byteorder="big", 189 | signed=True, 190 | ) 191 | / 1000 192 | ) 193 | for idx in range(int(data[KEY_CELL_COUNT])) 194 | } 195 | 196 | return data 197 | -------------------------------------------------------------------------------- /bmslib/bms_ble/plugins/dummy_bms.py: -------------------------------------------------------------------------------- 1 | """Module to support Dummy BMS.""" 2 | 3 | from bleak.backends.characteristic import BleakGATTCharacteristic 4 | from bleak.backends.device import BLEDevice 5 | from bleak.uuids import normalize_uuid_str 6 | 7 | from bmslib.bms_ble.const import ( 8 | ATTR_BATTERY_CHARGING, 9 | # ATTR_BATTERY_LEVEL, 10 | ATTR_CURRENT, 11 | # ATTR_CYCLE_CAP, 12 | # ATTR_CYCLE_CHRG, 13 | # ATTR_CYCLES, 14 | # ATTR_DELTA_VOLTAGE, 15 | ATTR_POWER, 16 | # ATTR_RUNTIME, 17 | ATTR_TEMPERATURE, 18 | ATTR_VOLTAGE, 19 | ) 20 | 21 | from .basebms import BaseBMS, BMSsample 22 | 23 | 24 | class BMS(BaseBMS): 25 | """Dummy battery class implementation.""" 26 | 27 | def __init__(self, ble_device: BLEDevice, reconnect: bool = False) -> None: 28 | """Initialize BMS.""" 29 | super().__init__(__name__, ble_device, reconnect) 30 | 31 | @staticmethod 32 | def matcher_dict_list() -> list[dict]: 33 | """Provide BluetoothMatcher definition.""" 34 | return [{"local_name": "dummy", "connectable": True}] 35 | 36 | @staticmethod 37 | def device_info() -> dict[str, str]: 38 | """Return device information for the battery management system.""" 39 | return {"manufacturer": "Dummy Manufacturer", "model": "dummy model"} 40 | 41 | @staticmethod 42 | def uuid_services() -> list[str]: 43 | """Return list of 128-bit UUIDs of services required by BMS.""" 44 | return [normalize_uuid_str("0000")] # change service UUID here! 45 | 46 | @staticmethod 47 | def uuid_rx() -> str: 48 | """Return 16-bit UUID of characteristic that provides notification/read property.""" 49 | return "#changeme" 50 | 51 | @staticmethod 52 | def uuid_tx() -> str: 53 | """Return 16-bit UUID of characteristic that provides write property.""" 54 | return "#changeme" 55 | 56 | @staticmethod 57 | def _calc_values() -> frozenset[str]: 58 | return frozenset( 59 | {ATTR_POWER, ATTR_BATTERY_CHARGING} 60 | ) # calculate further values from BMS provided set ones 61 | 62 | def _notification_handler( 63 | self, _sender: BleakGATTCharacteristic, data: bytearray 64 | ) -> None: 65 | """Handle the RX characteristics notify event (new data arrives).""" 66 | # self._log.debug("RX BLE data: %s", data) 67 | # 68 | # # do things like checking correctness of frame here and 69 | # # store it into a instance variable, e.g. self._data 70 | # 71 | # self._data_event.set() 72 | 73 | async def _async_update(self) -> BMSsample: 74 | """Update battery status information.""" 75 | self._log.debug("replace with command to UUID %s", BMS.uuid_tx()) 76 | # await self._await_reply(b"") 77 | # # 78 | # # parse data from self._data here 79 | 80 | return { 81 | ATTR_VOLTAGE: 12, 82 | ATTR_CURRENT: 1.5, 83 | ATTR_TEMPERATURE: 27.182, 84 | } # fixed values, replace parsed data 85 | -------------------------------------------------------------------------------- /bmslib/bms_ble/plugins/ecoworthy_bms.py: -------------------------------------------------------------------------------- 1 | """Module to support ECO-WORTHY BMS.""" 2 | 3 | import asyncio 4 | from collections.abc import Callable 5 | from typing import Final 6 | 7 | from bleak.backends.characteristic import BleakGATTCharacteristic 8 | from bleak.backends.device import BLEDevice 9 | from bleak.uuids import normalize_uuid_str 10 | 11 | from bmslib.bms_ble.const import ( 12 | ATTR_BATTERY_CHARGING, 13 | ATTR_BATTERY_LEVEL, 14 | ATTR_CURRENT, 15 | ATTR_CYCLE_CAP, 16 | ATTR_CYCLE_CHRG, 17 | # ATTR_CYCLES, 18 | ATTR_DELTA_VOLTAGE, 19 | ATTR_POWER, 20 | ATTR_RUNTIME, 21 | ATTR_TEMPERATURE, 22 | ATTR_VOLTAGE, 23 | KEY_CELL_COUNT, 24 | KEY_CELL_VOLTAGE, 25 | KEY_DESIGN_CAP, 26 | KEY_PROBLEM, 27 | KEY_TEMP_SENS, 28 | KEY_TEMP_VALUE, 29 | ) 30 | 31 | from .basebms import BaseBMS, BMSsample, crc_modbus 32 | 33 | 34 | class BMS(BaseBMS): 35 | """ECO-WORTHY battery class implementation.""" 36 | 37 | _HEAD: Final[tuple] = (b"\xa1", b"\xa2") 38 | _CELL_POS: Final[int] = 14 39 | _TEMP_POS: Final[int] = 80 40 | _FIELDS: Final[ 41 | list[tuple[str, int, int, int, bool, Callable[[int], int | float]]] 42 | ] = [ 43 | (ATTR_BATTERY_LEVEL, 0xA1, 16, 2, False, lambda x: x), 44 | (ATTR_VOLTAGE, 0xA1, 20, 2, False, lambda x: float(x / 100)), 45 | (ATTR_CURRENT, 0xA1, 22, 2, True, lambda x: float(x / 100)), 46 | (KEY_PROBLEM, 0xA1, 51, 2, False, lambda x: x), 47 | (KEY_DESIGN_CAP, 0xA1, 26, 2, False, lambda x: float(x / 100)), 48 | (KEY_CELL_COUNT, 0xA2, _CELL_POS, 2, False, lambda x: x), 49 | (KEY_TEMP_SENS, 0xA2, _TEMP_POS, 2, False, lambda x: x), 50 | # (ATTR_CYCLES, 0xA1, 8, 2, False, lambda x: x), 51 | ] 52 | _CMDS: Final[set[int]] = set({field[1] for field in _FIELDS}) 53 | 54 | def __init__(self, ble_device: BLEDevice, reconnect: bool = False) -> None: 55 | """Initialize BMS.""" 56 | super().__init__(__name__, ble_device, reconnect) 57 | self._data_final: dict[int, bytearray] = {} 58 | 59 | @staticmethod 60 | def matcher_dict_list() -> list[dict]: 61 | """Provide BluetoothMatcher definition.""" 62 | return [ 63 | { 64 | "local_name": "ECO-WORTHY*", 65 | "manufacturer_id": 0xC2B4, 66 | "connectable": True, 67 | } 68 | ] 69 | 70 | @staticmethod 71 | def device_info() -> dict[str, str]: 72 | """Return device information for the battery management system.""" 73 | return {"manufacturer": "ECO-WORTHY", "model": "BW02"} 74 | 75 | @staticmethod 76 | def uuid_services() -> list[str]: 77 | """Return list of 128-bit UUIDs of services required by BMS.""" 78 | return [normalize_uuid_str("fff0")] 79 | 80 | @staticmethod 81 | def uuid_rx() -> str: 82 | """Return 16-bit UUID of characteristic that provides notification/read property.""" 83 | return "fff1" 84 | 85 | @staticmethod 86 | def uuid_tx() -> str: 87 | """Return 16-bit UUID of characteristic that provides write property.""" 88 | raise NotImplementedError 89 | 90 | @staticmethod 91 | def _calc_values() -> frozenset[str]: 92 | return frozenset( 93 | { 94 | ATTR_BATTERY_CHARGING, 95 | ATTR_CYCLE_CHRG, 96 | ATTR_CYCLE_CAP, 97 | ATTR_DELTA_VOLTAGE, 98 | ATTR_POWER, 99 | ATTR_RUNTIME, 100 | ATTR_TEMPERATURE, 101 | } 102 | ) # calculate further values from BMS provided set ones 103 | 104 | def _notification_handler( 105 | self, _sender: BleakGATTCharacteristic, data: bytearray 106 | ) -> None: 107 | """Handle the RX characteristics notify event (new data arrives).""" 108 | self._log.debug("RX BLE data: %s", data) 109 | 110 | if not data.startswith(BMS._HEAD): 111 | self._log.debug("invalid frame type: '%s'", data[0:1].hex()) 112 | return 113 | 114 | if (crc := crc_modbus(data[:-2])) != int.from_bytes(data[-2:], "little"): 115 | self._log.debug( 116 | "invalid checksum 0x%X != 0x%X", 117 | int.from_bytes(data[-2:], "little"), 118 | crc, 119 | ) 120 | self._data = bytearray() 121 | return 122 | 123 | self._data_final[data[0]] = data.copy() 124 | if BMS._CMDS.issubset(self._data_final.keys()): 125 | self._data_event.set() 126 | 127 | @staticmethod 128 | def _decode_data(data: dict[int, bytearray]) -> dict[str, int | float]: 129 | return { 130 | key: func( 131 | int.from_bytes( 132 | data[cmd][idx : idx + size], byteorder="big", signed=sign 133 | ) 134 | ) 135 | for key, cmd, idx, size, sign, func in BMS._FIELDS 136 | } 137 | 138 | @staticmethod 139 | def _cell_voltages(data: bytearray, cells: int, offs: int) -> dict[str, float]: 140 | return {KEY_CELL_COUNT: cells} | { 141 | f"{KEY_CELL_VOLTAGE}{idx}": float( 142 | int.from_bytes( 143 | data[offs + idx * 2 : offs + idx * 2 + 2], 144 | byteorder="big", 145 | signed=False, 146 | ) 147 | ) 148 | / 1000 149 | for idx in range(cells) 150 | } 151 | 152 | @staticmethod 153 | def _temp_sensors(data: bytearray, sensors: int, offs: int) -> dict[str, float]: 154 | return { 155 | f"{KEY_TEMP_VALUE}{idx}": ( 156 | int.from_bytes( 157 | data[offs + idx * 2 : offs + (idx + 1) * 2], 158 | byteorder="big", 159 | signed=True, 160 | ) 161 | ) 162 | / 10 163 | for idx in range(sensors) 164 | } 165 | 166 | async def _async_update(self) -> BMSsample: 167 | """Update battery status information.""" 168 | 169 | self._data_final.clear() 170 | self._data_event.clear() # clear event to ensure new data is acquired 171 | await asyncio.wait_for(self._wait_event(), timeout=self.TIMEOUT) 172 | 173 | result: BMSsample = BMS._decode_data(self._data_final) 174 | 175 | result |= BMS._cell_voltages( 176 | self._data_final[0xA2], 177 | int(result.get(KEY_CELL_COUNT, 0)), 178 | BMS._CELL_POS + 2, 179 | ) 180 | result |= BMS._temp_sensors( 181 | self._data_final[0xA2], int(result.get(KEY_TEMP_SENS, 0)), BMS._TEMP_POS + 2 182 | ) 183 | 184 | return result 185 | -------------------------------------------------------------------------------- /bmslib/bms_ble/plugins/ective_bms.py: -------------------------------------------------------------------------------- 1 | """Module to support Ective BMS.""" 2 | 3 | import asyncio 4 | from collections.abc import Callable 5 | from string import hexdigits 6 | from typing import Final 7 | 8 | from bleak.backends.characteristic import BleakGATTCharacteristic 9 | from bleak.backends.device import BLEDevice 10 | from bleak.uuids import normalize_uuid_str 11 | 12 | from bmslib.bms_ble.const import ( 13 | ATTR_BATTERY_CHARGING, 14 | ATTR_BATTERY_LEVEL, 15 | ATTR_CURRENT, 16 | ATTR_CYCLE_CAP, 17 | ATTR_CYCLE_CHRG, 18 | ATTR_CYCLES, 19 | ATTR_DELTA_VOLTAGE, 20 | ATTR_POWER, 21 | ATTR_RUNTIME, 22 | ATTR_TEMPERATURE, 23 | ATTR_VOLTAGE, 24 | KEY_CELL_VOLTAGE, 25 | KEY_PROBLEM, 26 | ) 27 | 28 | from .basebms import BaseBMS, BMSsample 29 | 30 | 31 | class BMS(BaseBMS): 32 | """Ective battery class implementation.""" 33 | 34 | _HEAD_RSP: Final[bytes] = bytes([0x5E]) # header for responses 35 | _MAX_CELLS: Final[int] = 16 36 | _INFO_LEN: Final[int] = 113 37 | _CRC_LEN: Final[int] = 4 38 | _FIELDS: Final[list[tuple[str, int, int, bool, Callable[[int], int | float]]]] = [ 39 | (ATTR_VOLTAGE, 1, 8, False, lambda x: float(x / 1000)), 40 | (ATTR_CURRENT, 9, 8, True, lambda x: float(x / 1000)), 41 | (ATTR_BATTERY_LEVEL, 29, 4, False, lambda x: x), 42 | (ATTR_CYCLE_CHRG, 17, 8, False, lambda x: float(x / 1000)), 43 | (ATTR_CYCLES, 25, 4, False, lambda x: x), 44 | (ATTR_TEMPERATURE, 33, 4, False, lambda x: round(x * 0.1 - 273.15, 1)), 45 | (KEY_PROBLEM, 37, 2, False, lambda x: x), 46 | ] 47 | 48 | def __init__(self, ble_device: BLEDevice, reconnect: bool = False) -> None: 49 | """Initialize BMS.""" 50 | super().__init__(__name__, ble_device, reconnect) 51 | self._data_final: bytearray = bytearray() 52 | 53 | @staticmethod 54 | def matcher_dict_list() -> list[dict]: 55 | """Provide BluetoothMatcher definition.""" 56 | return [ 57 | { 58 | "local_name": pattern, 59 | "service_uuid": BMS.uuid_services()[0], 60 | "connectable": True, 61 | } 62 | for pattern in ("$PFLAC*", "NWJ20*", "ZM20*") 63 | ] 64 | 65 | @staticmethod 66 | def device_info() -> dict[str, str]: 67 | """Return device information for the battery management system.""" 68 | return {"manufacturer": "Ective", "model": "Smart BMS"} 69 | 70 | @staticmethod 71 | def uuid_services() -> list[str]: 72 | """Return list of 128-bit UUIDs of services required by BMS.""" 73 | return [normalize_uuid_str("ffe0")] # change service UUID here! 74 | 75 | @staticmethod 76 | def uuid_rx() -> str: 77 | """Return 16-bit UUID of characteristic that provides notification/read property.""" 78 | return "ffe4" 79 | 80 | @staticmethod 81 | def uuid_tx() -> str: 82 | """Return 16-bit UUID of characteristic that provides write property.""" 83 | raise NotImplementedError 84 | 85 | @staticmethod 86 | def _calc_values() -> frozenset[str]: 87 | return frozenset( 88 | { 89 | ATTR_BATTERY_CHARGING, 90 | ATTR_CYCLE_CAP, 91 | ATTR_CYCLE_CHRG, 92 | ATTR_DELTA_VOLTAGE, 93 | ATTR_POWER, 94 | ATTR_RUNTIME, 95 | } 96 | ) # calculate further values from BMS provided set ones 97 | 98 | def _notification_handler( 99 | self, _sender: BleakGATTCharacteristic, data: bytearray 100 | ) -> None: 101 | """Handle the RX characteristics notify event (new data arrives).""" 102 | 103 | if (start := data.find(BMS._HEAD_RSP)) != -1: # check for beginning of frame 104 | data = data[start:] 105 | self._data.clear() 106 | 107 | self._data += data 108 | self._log.debug( 109 | "RX BLE data (%s): %s", "start" if data == self._data else "cnt.", data 110 | ) 111 | 112 | if len(self._data) < BMS._INFO_LEN: 113 | return 114 | 115 | self._data = self._data[: BMS._INFO_LEN] # cut off exceeding data 116 | 117 | if not ( 118 | self._data.startswith(BMS._HEAD_RSP) 119 | and set(self._data.decode(errors="replace")[1:]).issubset(hexdigits) 120 | ): 121 | self._log.debug("incorrect frame coding: %s", self._data) 122 | self._data.clear() 123 | return 124 | 125 | if (crc := BMS._crc(self._data[1 : -BMS._CRC_LEN])) != int( 126 | self._data[-BMS._CRC_LEN :], 16 127 | ): 128 | self._log.debug( 129 | "invalid checksum 0x%X != 0x%X", 130 | int(self._data[-BMS._CRC_LEN :], 16), 131 | crc, 132 | ) 133 | self._data.clear() 134 | return 135 | 136 | self._data_final = self._data.copy() 137 | self._data_event.set() 138 | 139 | @staticmethod 140 | def _crc(data: bytearray) -> int: 141 | return sum(int(data[idx : idx + 2], 16) for idx in range(0, len(data), 2)) 142 | 143 | @staticmethod 144 | def _cell_voltages(data: bytearray) -> dict[str, float]: 145 | """Return cell voltages from status message.""" 146 | return { 147 | f"{KEY_CELL_VOLTAGE}{idx}": BMS._conv_int( 148 | data[45 + idx * 4 : 49 + idx * 4], False 149 | ) 150 | / 1000 151 | for idx in range(BMS._MAX_CELLS) 152 | if BMS._conv_int(data[45 + idx * 4 : 49 + idx * 4], False) 153 | } 154 | 155 | @staticmethod 156 | def _conv_int(data: bytearray, sign: bool) -> int: 157 | return int.from_bytes( 158 | int(data, 16).to_bytes(len(data) >> 1, byteorder="little", signed=False), 159 | byteorder="big", 160 | signed=sign, 161 | ) 162 | 163 | async def _async_update(self) -> BMSsample: 164 | """Update battery status information.""" 165 | 166 | await asyncio.wait_for(self._wait_event(), timeout=self.TIMEOUT) 167 | return { 168 | key: func(BMS._conv_int(self._data_final[idx : idx + size], sign)) 169 | for key, idx, size, sign, func in BMS._FIELDS 170 | } | BMS._cell_voltages(self._data_final) 171 | -------------------------------------------------------------------------------- /bmslib/bms_ble/plugins/felicity_bms.py: -------------------------------------------------------------------------------- 1 | """Module to support Felicity BMS.""" 2 | 3 | from collections.abc import Callable 4 | from json import JSONDecodeError, loads 5 | from typing import Final 6 | 7 | from bleak.backends.characteristic import BleakGATTCharacteristic 8 | from bleak.backends.device import BLEDevice 9 | from bleak.uuids import normalize_uuid_str 10 | 11 | from bmslib.bms_ble.const import ( 12 | ATTR_BATTERY_CHARGING, 13 | ATTR_BATTERY_LEVEL, 14 | ATTR_CURRENT, 15 | ATTR_CYCLE_CAP, 16 | ATTR_CYCLE_CHRG, 17 | # ATTR_CYCLES, 18 | ATTR_DELTA_VOLTAGE, 19 | ATTR_POWER, 20 | ATTR_RUNTIME, 21 | ATTR_TEMPERATURE, 22 | ATTR_VOLTAGE, 23 | KEY_CELL_VOLTAGE, 24 | KEY_PROBLEM, 25 | KEY_TEMP_VALUE, 26 | ) 27 | 28 | from .basebms import BaseBMS, BMSsample 29 | 30 | 31 | class BMS(BaseBMS): 32 | """Felicity battery class implementation.""" 33 | 34 | _HEAD: Final[bytes] = b"{" 35 | _TAIL: Final[bytes] = b"}" 36 | _CMD_PRE: Final[bytes] = b"wifilocalMonitor:" # CMD prefix 37 | _CMD_BI: Final[bytes] = b"get dev basice infor" 38 | _CMD_DT: Final[bytes] = b"get Date" 39 | _CMD_RT: Final[bytes] = b"get dev real infor" 40 | _FIELDS: Final[list[tuple[str, str, Callable[[list], int | float]]]] = [ 41 | (ATTR_VOLTAGE, "Batt", lambda x: float(x[0][0] / 1000)), 42 | (ATTR_CURRENT, "Batt", lambda x: float(x[1][0] / 10)), 43 | ( 44 | ATTR_CYCLE_CHRG, 45 | "BatsocList", 46 | lambda x: (int(x[0][0]) * int(x[0][2])) / 1e7, 47 | ), 48 | (ATTR_BATTERY_LEVEL, "BatsocList", lambda x: float(x[0][0] / 100)), 49 | ] 50 | 51 | def __init__(self, ble_device: BLEDevice, reconnect: bool = False) -> None: 52 | """Initialize BMS.""" 53 | super().__init__(__name__, ble_device, reconnect) 54 | self._data_final: dict = {} 55 | 56 | @staticmethod 57 | def matcher_dict_list() -> list[dict]: 58 | """Provide BluetoothMatcher definition.""" 59 | return [{"local_name": "F10*", "connectable": True}] 60 | 61 | @staticmethod 62 | def device_info() -> dict[str, str]: 63 | """Return device information for the battery management system.""" 64 | return {"manufacturer": "Felicity Solar", "model": "LiFePo4 battery"} 65 | 66 | @staticmethod 67 | def uuid_services() -> list[str]: 68 | """Return list of 128-bit UUIDs of services required by BMS.""" 69 | return [normalize_uuid_str("6e6f736a-4643-4d44-8fa9-0fafd005e455")] 70 | 71 | @staticmethod 72 | def uuid_rx() -> str: 73 | """Return 128-bit UUID of characteristic that provides notification/read property.""" 74 | return "49535458-8341-43f4-a9d4-ec0e34729bb3" 75 | 76 | @staticmethod 77 | def uuid_tx() -> str: 78 | """Return 128-bit UUID of characteristic that provides write property.""" 79 | return "49535258-184d-4bd9-bc61-20c647249616" 80 | 81 | @staticmethod 82 | def _calc_values() -> frozenset[str]: 83 | return frozenset( 84 | { 85 | ATTR_BATTERY_CHARGING, 86 | ATTR_CYCLE_CAP, 87 | ATTR_DELTA_VOLTAGE, 88 | ATTR_POWER, 89 | ATTR_RUNTIME, 90 | ATTR_TEMPERATURE, 91 | } 92 | ) # calculate further values from BMS provided set ones 93 | 94 | def _notification_handler( 95 | self, _sender: BleakGATTCharacteristic, data: bytearray 96 | ) -> None: 97 | """Handle the RX characteristics notify event (new data arrives).""" 98 | 99 | if data.startswith(BMS._HEAD): 100 | self._data = bytearray() 101 | 102 | self._data += data 103 | self._log.debug( 104 | "RX BLE data (%s): %s", "start" if data == self._data else "cnt.", data 105 | ) 106 | 107 | if not data.endswith(BMS._TAIL): 108 | return 109 | 110 | try: 111 | self._data_final = loads(self._data) 112 | except (JSONDecodeError, UnicodeDecodeError): 113 | self._log.debug("JSON decode error: %s", self._data) 114 | return 115 | 116 | if (ver := self._data_final.get("CommVer", 0)) != 1: 117 | self._log.debug("Unknown protocol version (%i)", ver) 118 | return 119 | 120 | self._data_event.set() 121 | 122 | @staticmethod 123 | def _decode_data(data: dict) -> dict[str, int | float]: 124 | return {key: func(data.get(itm, [])) for key, itm, func in BMS._FIELDS} 125 | 126 | @staticmethod 127 | def _cell_voltages(data: dict) -> dict[str, float]: 128 | return { 129 | f"{KEY_CELL_VOLTAGE}{idx}": value / 1000 130 | for idx, value in enumerate(data.get("BatcelList", [])[0]) 131 | } 132 | 133 | @staticmethod 134 | def _temp_sensors(data: dict) -> dict[str, float]: 135 | return { 136 | f"{KEY_TEMP_VALUE}{idx}": value / 10 137 | for idx, value in enumerate(data.get("BtemList", [])[0]) 138 | if value != 0x7FFF 139 | } 140 | 141 | async def _async_update(self) -> BMSsample: 142 | """Update battery status information.""" 143 | 144 | await self._await_reply(BMS._CMD_PRE + BMS._CMD_RT) 145 | 146 | return ( 147 | BMS._decode_data(self._data_final) 148 | | BMS._temp_sensors(self._data_final) 149 | | BMS._cell_voltages(self._data_final) 150 | | { 151 | KEY_PROBLEM: self._data_final.get("Bwarn", 0) 152 | + self._data_final.get("Bfault", 0) 153 | } 154 | ) 155 | -------------------------------------------------------------------------------- /bmslib/bms_ble/plugins/redodo_bms.py: -------------------------------------------------------------------------------- 1 | """Module to support Dummy BMS.""" 2 | 3 | from collections.abc import Callable 4 | from typing import Final 5 | 6 | from bleak.backends.characteristic import BleakGATTCharacteristic 7 | from bleak.backends.device import BLEDevice 8 | from bleak.uuids import normalize_uuid_str 9 | 10 | from bmslib.bms_ble.const import ( 11 | ATTR_BATTERY_CHARGING, 12 | ATTR_BATTERY_LEVEL, 13 | ATTR_CURRENT, 14 | ATTR_CYCLE_CAP, 15 | ATTR_CYCLE_CHRG, 16 | ATTR_CYCLES, 17 | ATTR_DELTA_VOLTAGE, 18 | ATTR_POWER, 19 | ATTR_RUNTIME, 20 | ATTR_TEMPERATURE, 21 | ATTR_VOLTAGE, 22 | KEY_CELL_VOLTAGE, 23 | KEY_PROBLEM, 24 | KEY_TEMP_VALUE, 25 | ) 26 | 27 | from .basebms import BaseBMS, BMSsample, crc_sum 28 | 29 | 30 | class BMS(BaseBMS): 31 | """Dummy battery class implementation.""" 32 | 33 | CRC_POS: Final[int] = -1 # last byte 34 | HEAD_LEN: Final[int] = 3 35 | MAX_CELLS: Final[int] = 16 36 | MAX_TEMP: Final[int] = 5 37 | _FIELDS: Final[list[tuple[str, int, int, bool, Callable[[int], int | float]]]] = [ 38 | (ATTR_VOLTAGE, 12, 2, False, lambda x: float(x / 1000)), 39 | (ATTR_CURRENT, 48, 4, True, lambda x: float(x / 1000)), 40 | (ATTR_BATTERY_LEVEL, 90, 2, False, lambda x: x), 41 | (ATTR_CYCLE_CHRG, 62, 2, False, lambda x: float(x / 100)), 42 | (ATTR_CYCLES, 96, 4, False, lambda x: x), 43 | (KEY_PROBLEM, 76, 4, False, lambda x: x), 44 | ] 45 | 46 | def __init__(self, ble_device: BLEDevice, reconnect: bool = False) -> None: 47 | """Initialize BMS.""" 48 | super().__init__(__name__, ble_device, reconnect) 49 | 50 | @staticmethod 51 | def matcher_dict_list() -> list[dict]: 52 | """Provide BluetoothMatcher definition.""" 53 | return [ 54 | { 55 | "service_uuid": BMS.uuid_services()[0], 56 | "manufacturer_id": 0x585A, 57 | "connectable": True, 58 | } 59 | ] 60 | 61 | @staticmethod 62 | def device_info() -> dict[str, str]: 63 | """Return device information for the battery management system.""" 64 | return {"manufacturer": "Redodo", "model": "Bluetooth battery"} 65 | 66 | @staticmethod 67 | def uuid_services() -> list[str]: 68 | """Return list of 128-bit UUIDs of services required by BMS.""" 69 | return [normalize_uuid_str("ffe0")] # change service UUID here! 70 | 71 | @staticmethod 72 | def uuid_rx() -> str: 73 | """Return 16-bit UUID of characteristic that provides notification/read property.""" 74 | return "ffe1" 75 | 76 | @staticmethod 77 | def uuid_tx() -> str: 78 | """Return 16-bit UUID of characteristic that provides write property.""" 79 | return "ffe2" 80 | 81 | @staticmethod 82 | def _calc_values() -> frozenset[str]: 83 | return frozenset( 84 | { 85 | ATTR_BATTERY_CHARGING, 86 | ATTR_DELTA_VOLTAGE, 87 | ATTR_CYCLE_CAP, 88 | ATTR_POWER, 89 | ATTR_RUNTIME, 90 | ATTR_TEMPERATURE, 91 | } 92 | ) # calculate further values from BMS provided set ones 93 | 94 | def _notification_handler( 95 | self, _sender: BleakGATTCharacteristic, data: bytearray 96 | ) -> None: 97 | """Handle the RX characteristics notify event (new data arrives).""" 98 | self._log.debug("RX BLE data: %s", data) 99 | 100 | if len(data) < 3 or not data.startswith(b"\x00\x00"): 101 | self._log.debug("incorrect SOF.") 102 | return 103 | 104 | if len(data) != data[2] + BMS.HEAD_LEN + 1: # add header length and CRC 105 | self._log.debug("incorrect frame length (%i)", len(data)) 106 | return 107 | 108 | if (crc := crc_sum(data[: BMS.CRC_POS])) != data[BMS.CRC_POS]: 109 | self._log.debug( 110 | "invalid checksum 0x%X != 0x%X", data[len(data) + BMS.CRC_POS], crc 111 | ) 112 | return 113 | 114 | self._data = data 115 | self._data_event.set() 116 | 117 | @staticmethod 118 | def _cell_voltages(data: bytearray, cells: int) -> dict[str, float]: 119 | """Return cell voltages from status message.""" 120 | return { 121 | f"{KEY_CELL_VOLTAGE}{idx}": value / 1000 122 | for idx in range(cells) 123 | if ( 124 | value := int.from_bytes( 125 | data[16 + 2 * idx : 16 + 2 * idx + 2], 126 | byteorder="little", 127 | ) 128 | ) 129 | } 130 | 131 | @staticmethod 132 | def _temp_sensors(data: bytearray, sensors: int) -> dict[str, int]: 133 | return { 134 | f"{KEY_TEMP_VALUE}{idx}": value 135 | for idx in range(sensors) 136 | if ( 137 | value := int.from_bytes( 138 | data[52 + idx * 2 : 54 + idx * 2], 139 | byteorder="little", 140 | signed=True, 141 | ) 142 | ) 143 | } 144 | 145 | async def _async_update(self) -> BMSsample: 146 | """Update battery status information.""" 147 | await self._await_reply(b"\x00\x00\x04\x01\x13\x55\xaa\x17") 148 | 149 | return ( 150 | { 151 | key: func( 152 | int.from_bytes( 153 | self._data[idx : idx + size], byteorder="little", signed=sign 154 | ) 155 | ) 156 | for key, idx, size, sign, func in BMS._FIELDS 157 | } 158 | | BMS._cell_voltages(self._data, BMS.MAX_CELLS) 159 | | BMS._temp_sensors(self._data, BMS.MAX_TEMP) 160 | ) 161 | -------------------------------------------------------------------------------- /bmslib/bms_ble/strings.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "abort": { 4 | "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", 5 | "no_devices_found": "No supported devices found via Bluetooth", 6 | "not_supported": "Device not supported" 7 | }, 8 | "flow_title": "Setup {name}", 9 | "step": { 10 | "user": { 11 | "description": "[%key:component::bluetooth::config::step::user::description%]", 12 | "data": { 13 | "address": "[%key:common::config_flow::data::device%]" 14 | } 15 | }, 16 | "bluetooth_confirm": { 17 | "description": "[%key:component::bluetooth::config::step::bluetooth_confirm::description%]" 18 | } 19 | } 20 | }, 21 | "exceptions": { 22 | "device_not_found": { 23 | "message": "Could not find BMS ({MAC}) via Bluetooth" 24 | }, 25 | "missing_unique_id": { 26 | "message": "Missing unique ID for device." 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /bmslib/bms_ble/translations/de.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "abort": { 4 | "already_configured": "Gerät ist bereits konfiguriert.", 5 | "no_devices_found": "Keine unterstützen Geräte via Bluetooth gefunden.", 6 | "not_supported": "Gerät wird nicht unterstützt." 7 | }, 8 | "flow_title": "Setup {name}", 9 | "step": { 10 | "user": { 11 | "description": "Wähle ein Gerät zum Einrichten aus:", 12 | "data": { 13 | "address": "Bluetooth Name (MAC Adresse)" 14 | } 15 | }, 16 | "bluetooth_confirm": { 17 | "description": "Möchtest Du {name} einrichten?" 18 | } 19 | } 20 | }, 21 | "exceptions": { 22 | "device_not_found": { 23 | "message": "BMS ({MAC}) wurde nicht via Bluetooth gefunden." 24 | }, 25 | "missing_unique_id": { 26 | "message": "Eindeutige ID für Gerät fehlt." 27 | } 28 | }, 29 | "entity": { 30 | "sensor": { 31 | "cycles": { 32 | "name": "Zyklen" 33 | }, 34 | "delta_voltage": { 35 | "name": "Differenzspannung" 36 | }, 37 | "link_quality": { 38 | "name": "Verbindungsqualität" 39 | }, 40 | "runtime": { 41 | "name": "Laufzeit" 42 | } 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /bmslib/bms_ble/translations/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "abort": { 4 | "already_configured": "Device is already configured.", 5 | "no_devices_found": "No supported devices found via Bluetooth.", 6 | "not_supported": "Device is not supported." 7 | }, 8 | "flow_title": "Setup {name}", 9 | "step": { 10 | "user": { 11 | "description": "Choose a device to set up:", 12 | "data": { 13 | "address": "Bluetooth name (MAC address)" 14 | } 15 | }, 16 | "bluetooth_confirm": { 17 | "description": "Do you want to set up {name}?" 18 | } 19 | } 20 | }, 21 | "exceptions": { 22 | "device_not_found": { 23 | "message": "Could not find BMS ({MAC}) via Bluetooth." 24 | }, 25 | "missing_unique_id": { 26 | "message": "Missing unique ID for device." 27 | } 28 | }, 29 | "entity": { 30 | "sensor": { 31 | "cycles": { 32 | "name": "Cycles" 33 | }, 34 | "delta_voltage": { 35 | "name": "Delta voltage" 36 | }, 37 | "link_quality": { 38 | "name": "Link quality" 39 | }, 40 | "runtime": { 41 | "name": "Runtime" 42 | } 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /bmslib/bms_ble/translations/pt.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "abort": { 4 | "already_configured": "O dispositivo já está configurado.", 5 | "no_devices_found": "Nenhum dispositivo suportado encontrado via Bluetooth.", 6 | "not_supported": "O dispositivo não é suportado." 7 | }, 8 | "flow_title": "Configuração de {name}", 9 | "step": { 10 | "user": { 11 | "description": "Escolha um dispositivo para configurar:", 12 | "data": { 13 | "address": "Nome Bluetooth (endereço MAC)" 14 | } 15 | }, 16 | "bluetooth_confirm": { 17 | "description": "Pretende configurar {name}?" 18 | } 19 | } 20 | }, 21 | "exceptions": { 22 | "device_not_found": { 23 | "message": "Não foi possível encontrar o BMS ({MAC}) via Bluetooth." 24 | }, 25 | "missing_unique_id": { 26 | "message": "ID único em falta para o dispositivo." 27 | } 28 | }, 29 | "entity": { 30 | "sensor": { 31 | "cycles": { 32 | "name": "Ciclos" 33 | }, 34 | "delta_voltage": { 35 | "name": "Voltagem delta" 36 | }, 37 | "link_quality": { 38 | "name": "Qualidade de ligação" 39 | }, 40 | "runtime": { 41 | "name": "Run" 42 | } 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /bmslib/cache/__init__.py: -------------------------------------------------------------------------------- 1 | import random 2 | import string 3 | 4 | 5 | def random_str(n=12): 6 | return ''.join(random.choice(string.ascii_letters + string.digits) for _ in range(n)) 7 | 8 | 9 | def is_hashable(obj): 10 | # noinspection PyBroadException 11 | try: 12 | hash(obj) 13 | return True 14 | except Exception: 15 | return False 16 | 17 | 18 | 19 | def to_hashable(obj, id_types=()): 20 | if is_hashable(obj): 21 | return obj # , type(obj) 22 | 23 | if isinstance(obj, set): 24 | obj = sorted(obj) 25 | elif isinstance(obj, dict): 26 | obj = sorted(obj.items()) 27 | 28 | if isinstance(obj, (list, tuple)): 29 | return tuple(map(to_hashable, obj)) 30 | 31 | if id_types and isinstance(obj, id_types): 32 | return type(obj), id(obj) 33 | 34 | raise ValueError( 35 | "%r can not be hashed. Try providing a custom key function." 36 | % obj) -------------------------------------------------------------------------------- /bmslib/cache/disk.py: -------------------------------------------------------------------------------- 1 | from typing import Callable 2 | 3 | import pandas as pd 4 | 5 | import os 6 | import pickle 7 | import random 8 | import string 9 | from functools import wraps 10 | 11 | from bmslib.cache import to_hashable, random_str 12 | from bmslib.util import get_logger 13 | 14 | cache_dir = os.path.expanduser('~') + "/.cache/batmon" 15 | 16 | logger = get_logger() 17 | 18 | 19 | 20 | 21 | def touch(fname, times=None): 22 | with open(fname, 'a'): 23 | os.utime(fname, times) 24 | 25 | 26 | def mkdir_p(path): 27 | try: 28 | os.makedirs(path) 29 | except OSError as exc: # Python >2.5 30 | import errno 31 | if exc.errno == errno.EEXIST and os.path.isdir(path): 32 | pass 33 | else: 34 | raise 35 | 36 | 37 | def _get_fn(key, ext): 38 | path = cache_dir + "/" + key + "." + ext 39 | path = os.path.realpath(path) 40 | dn = os.path.dirname(path) 41 | # noinspection PyBroadException 42 | try: 43 | if not os.path.isdir(dn): 44 | mkdir_p(dn) 45 | except: 46 | pass 47 | return path 48 | 49 | 50 | class PickleFileStore: 51 | def __init__(self): 52 | pass 53 | 54 | # noinspection PyMethodMayBeStatic 55 | def read(self, key): 56 | # noinspection PyBroadException 57 | try: 58 | fn = _get_fn(key, ext='pickle') 59 | with open(fn, 'rb') as fh: 60 | ret = pickle.load(fh) 61 | touch(fn) 62 | return ret 63 | except: 64 | return None 65 | 66 | # noinspection PyMethodMayBeStatic 67 | def write(self, key, df): 68 | fn = _get_fn(key, ext='pickle') 69 | s = f'.{random_str(6)}.tmp' 70 | with open(fn + s, 'wb') as fh: 71 | pickle.dump(df, fh, pickle.HIGHEST_PROTOCOL) 72 | os.replace(fn + s, fn) 73 | # _set_df_file_store_mtime(fn, df) 74 | 75 | 76 | def func_args_hash_func(target): 77 | import hashlib 78 | import inspect 79 | mod = inspect.getmodule(target) 80 | path_hash = hashlib.sha224(bytes(mod.__file__, 'utf-8')).hexdigest()[:4] 81 | 82 | def _cache_key(args, kwargs): 83 | cache_key_obj = (to_hashable(args), to_hashable(kwargs)) 84 | cache_key_hash = hashlib.sha224(bytes(str(cache_key_obj), 'utf-8')).hexdigest() 85 | return path_hash, cache_key_hash 86 | 87 | return _cache_key 88 | 89 | 90 | def disk_cache_deco(ignore_kwargs=None): 91 | if ignore_kwargs is None: 92 | ignore_kwargs = set() 93 | 94 | disk_cache = PickleFileStore() 95 | 96 | def decorate(target, hash_func_gen=func_args_hash_func): 97 | import inspect 98 | mod = inspect.getmodule(target) 99 | ckh = hash_func_gen(target) 100 | 101 | # noinspection PyBroadException 102 | @wraps(target) 103 | def _fallback_cache_wrapper(*args, **kwargs): 104 | kwargs_cache = {k: v for k, v in kwargs.items() if k not in ignore_kwargs and v is not None} 105 | k0, k1 = ckh(args, kwargs_cache) 106 | cache_key_str = '/'.join([mod.__name__, target.__name__ + "__" + k0, k1]) 107 | 108 | ret = disk_cache.read(cache_key_str) 109 | if ret is not None: 110 | return ret 111 | 112 | try: 113 | ret = target(*args, **kwargs) 114 | try: 115 | disk_cache.write(cache_key_str, ret) 116 | logger.info("wrote %s", cache_key_str) 117 | except Exception as _e: 118 | logger.warning('Fall-back cache: error storing: %s', _e, exc_info=1) 119 | pass 120 | except: 121 | logger.warning('calling %s (%s) raised an error', target, cache_key_str, exc_info=1) 122 | raise 123 | 124 | return ret 125 | 126 | return _fallback_cache_wrapper 127 | 128 | return decorate 129 | 130 | 131 | -------------------------------------------------------------------------------- /bmslib/cache/mem.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | import time 3 | from asyncio import Lock 4 | from functools import wraps 5 | from typing import Callable 6 | 7 | from bmslib.cache import to_hashable 8 | from bmslib.util import get_logger 9 | 10 | logger = get_logger() 11 | 12 | 13 | class MemoryCacheStorage: 14 | def get(self, key): 15 | raise NotImplementedError() 16 | 17 | def get_default(self, key, returns_default_value: Callable, ttl): 18 | raise NotImplementedError() 19 | 20 | def set(self, key, value, ttl, ignore_overwrite): 21 | raise NotImplementedError() 22 | 23 | def __delitem__(self, key): 24 | raise NotImplementedError() 25 | 26 | def __contains__(self, key): 27 | raise NotImplementedError(); 28 | 29 | 30 | class DictCacheStorage(MemoryCacheStorage): 31 | def __init__(self): 32 | self.d = dict() 33 | self.time = time.time 34 | 35 | def get(self, key): 36 | if key not in self: 37 | return None 38 | return self.d[key][0] 39 | 40 | def get_default(self, key, returns_default_value: Callable, ttl): 41 | if key not in self: 42 | return returns_default_value() 43 | return self.d[key][0] 44 | 45 | def set(self, key, value, ttl, ignore_overwrite): 46 | if not ignore_overwrite and key in self: 47 | logger.warning("overwrite key %s", key) 48 | self.d[key] = value, (self.time() + ttl) 49 | 50 | def __delitem__(self, key): 51 | del self.d[key] 52 | 53 | def __contains__(self, key): 54 | if key not in self.d: 55 | return False 56 | return self.d[key][1] >= self.time() 57 | 58 | 59 | _managed_mem_cache = None 60 | 61 | 62 | def shared_managed_mem_cache() -> DictCacheStorage: 63 | global _managed_mem_cache 64 | if _managed_mem_cache is None: 65 | _managed_mem_cache = DictCacheStorage() 66 | return _managed_mem_cache 67 | 68 | 69 | def mem_cache_deco(ttl, touch=False, ignore_kwargs=None, synchronized=False, expired=None, ignore_rc=False, 70 | cache_storage: MemoryCacheStorage = shared_managed_mem_cache(), 71 | key_func: Callable = None): 72 | """ 73 | Decorator 74 | :param touch: touch key time on hit 75 | :param ttl: 76 | :param ignore_kwargs: a set of keyword arguments to ignore when building the cache key 77 | :param expired Callable to evaluate whether the cached value has expired/invalidated 78 | :return: 79 | """ 80 | 81 | if ignore_kwargs is None: 82 | ignore_kwargs = set() 83 | 84 | # ttl = pd.to_timedelta(ttl) 85 | _mem_cache = cache_storage 86 | _lock_cache = shared_managed_mem_cache() 87 | 88 | def decorate(target): 89 | 90 | if key_func: 91 | def _cache_key_obj(args, kwargs): 92 | return key_func(*args, **kwargs) 93 | else: 94 | def _cache_key_obj(args, kwargs): 95 | kwargs_cache = {k: v for k, v in kwargs.items() if k not in ignore_kwargs} 96 | return (target, to_hashable(args), to_hashable(kwargs_cache)) 97 | 98 | def invalidate(*args, **kwargs): 99 | del _mem_cache[_cache_key_obj(args, kwargs)] 100 | 101 | setattr(target, "invalidate", invalidate) 102 | 103 | is_coro = inspect.iscoroutinefunction(target) 104 | 105 | 106 | @wraps(target) 107 | def _inner_wrapper(cache_key_obj, args, kwargs): 108 | ret = _mem_cache.get(cache_key_obj) 109 | 110 | if expired and ret is not None and expired(ret): 111 | del _mem_cache[cache_key_obj] 112 | ret = None 113 | 114 | if ret is None: 115 | ret = target(*args, **kwargs) 116 | _mem_cache.set(cache_key_obj, ret, ttl=ttl, ignore_overwrite=ignore_rc) 117 | elif touch: 118 | _mem_cache.set(cache_key_obj, ret, ttl=ttl, ignore_overwrite=True) 119 | 120 | return ret 121 | 122 | @wraps(target) 123 | async def _inner_wrapper_async(cache_key_obj, args, kwargs): 124 | ret = _mem_cache.get(cache_key_obj) 125 | 126 | if expired and ret is not None and expired(ret): 127 | del _mem_cache[cache_key_obj] 128 | ret = None 129 | 130 | if ret is None: 131 | ret = await target(*args, **kwargs) 132 | _mem_cache.set(cache_key_obj, ret, ttl=ttl, ignore_overwrite=ignore_rc) 133 | elif touch: 134 | _mem_cache.set(cache_key_obj, ret, ttl=ttl, ignore_overwrite=True) 135 | 136 | return ret 137 | 138 | if synchronized: 139 | target_lock = Lock() 140 | 141 | assert not is_coro, "asyncio io not yet supported" 142 | 143 | @wraps(target) 144 | def _mem_cache_synchronized_wrapper(*args, **kwargs): 145 | cache_key_obj = _cache_key_obj(args, kwargs) 146 | 147 | with target_lock: 148 | lock = _lock_cache.get_default((cache_key_obj, target_lock), Lock, ttl=ttl) 149 | 150 | with lock: 151 | return _inner_wrapper(cache_key_obj, args, kwargs) 152 | 153 | return _mem_cache_synchronized_wrapper 154 | 155 | else: 156 | if is_coro: 157 | @wraps(target) 158 | async def _mem_cache_wrapper_async(*args, **kwargs): 159 | cache_key_obj = _cache_key_obj(args, kwargs) 160 | return await _inner_wrapper_async(cache_key_obj, args, kwargs) 161 | 162 | return _mem_cache_wrapper_async 163 | else: 164 | @wraps(target) 165 | def _mem_cache_wrapper(*args, **kwargs): 166 | cache_key_obj = _cache_key_obj(args, kwargs) 167 | return _inner_wrapper(cache_key_obj, args, kwargs) 168 | 169 | return _mem_cache_wrapper 170 | 171 | return decorate 172 | -------------------------------------------------------------------------------- /bmslib/group.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import math 3 | import statistics 4 | from copy import copy 5 | from typing import Dict, Iterable, List 6 | 7 | from bmslib.bms import BmsSample 8 | from bmslib.bt import BtBms 9 | from bmslib.util import get_logger 10 | 11 | 12 | class BmsGroup: 13 | 14 | def __init__(self, name): 15 | self.name = name 16 | self.bms_names = list() 17 | self.samples: Dict[str, BmsSample] = {} 18 | self.voltages: Dict[str, List[int]] = {} 19 | # self.max_sample_age = 0 20 | 21 | def update(self, bms: BtBms, sample: BmsSample): 22 | assert bms.name in self.bms_names, "bms %s not in group %s" % (bms.name, self.bms_names) 23 | self.samples[bms.name] = copy(sample) 24 | 25 | def update_voltages(self, bms: BtBms, voltages: List[int]): 26 | assert bms.name in self.bms_names, "bms %s not in group %s" % (bms.name, self.bms_names) 27 | self.voltages[bms.name] = copy(voltages) 28 | 29 | def fetch(self) -> BmsSample: 30 | # ts_expire = time.time() - self.max_sample_age 31 | # expired = set(k for k,s in self.samples.items() if s.timestamp < ts_expire) 32 | return sum_parallel(self.samples.values()) 33 | 34 | def fetch_voltages(self): 35 | try: 36 | return sum((self.voltages[name] for name in self.bms_names), []) 37 | except KeyError as e: 38 | raise GroupNotReady(e) 39 | 40 | 41 | class GroupNotReady(Exception): 42 | pass 43 | # TODO rename GroupMissingData ? 44 | 45 | 46 | 47 | class VirtualGroupBms: 48 | # TODO inherit from bms base class 49 | def __init__(self, address: str, name=None, verbose_log=False, **kwargs): 50 | self.address = address 51 | self.name = name 52 | self.group = BmsGroup(name) 53 | self.verbose_log = verbose_log 54 | self.members: List[BtBms] = [] 55 | self.logger = get_logger(verbose_log) 56 | 57 | def __str__(self): 58 | return 'VirtualGroupBms(%s,[%s])' % (self.name, self.address) 59 | 60 | @property 61 | def is_connected(self): 62 | return set(self.group.samples.keys()) == set(self.group.bms_names) 63 | 64 | @property 65 | def is_virtual(self): 66 | return True 67 | 68 | @property 69 | def connect_time(self): 70 | return max(bms.connect_time for bms in self.members) 71 | 72 | def debug_data(self): 73 | return "missing %s" % (set(self.group.bms_names) - set(self.group.samples.keys())) 74 | 75 | async def fetch(self) -> BmsSample: 76 | # TODO wait for update with timeout 77 | return self.group.fetch() 78 | 79 | async def fetch_voltages(self): 80 | return self.group.fetch_voltages() 81 | 82 | def set_keep_alive(self, keep): 83 | pass 84 | 85 | def add_member(self, bms: BtBms): 86 | self.group.bms_names.append(bms.name) 87 | self.members.append(bms) 88 | 89 | def get_member_refs(self): 90 | return set(filter(bool, self.address.split(','))) 91 | 92 | def get_member_names(self): 93 | return self.group.bms_names 94 | 95 | async def connect(self): 96 | for i in range(10): 97 | if self.is_connected: 98 | return 99 | await asyncio.sleep(0.2) 100 | 101 | raise GroupNotReady("group %s waiting for member data %s" % (self.name, self.debug_data())) 102 | 103 | async def disconnect(self): 104 | pass 105 | 106 | async def __aenter__(self): 107 | await self.connect() 108 | 109 | async def __aexit__(self, *args): 110 | pass 111 | 112 | def __await__(self): 113 | pass 114 | 115 | async def set_switch(self, switch: str, state: bool): 116 | for bms in self.members: 117 | try: 118 | await bms.set_switch(switch, state) 119 | except Exception as ex: 120 | self.logger.error("Group %s failed to set %s switch for %s: %s", self.name, switch, bms.name, ex) 121 | 122 | async def fetch_device_info(self): 123 | raise NotImplementedError() 124 | 125 | 126 | def is_finite(x): 127 | return x is not None and math.isfinite(x) 128 | 129 | 130 | def finite_or_fallback(x, fallback): 131 | return x if is_finite(x) else fallback 132 | 133 | 134 | def sum_parallel(samples: Iterable[BmsSample]) -> BmsSample: 135 | return BmsSample( 136 | voltage=statistics.mean(s.voltage for s in samples), 137 | current=sum(s.current for s in samples), 138 | power=sum(s.power for s in samples), 139 | charge=sum(s.charge for s in samples), 140 | capacity=sum(s.capacity for s in samples), 141 | cycle_capacity=sum(s.cycle_capacity for s in samples), 142 | num_cycles=statistics.mean(s.num_cycles for s in samples), 143 | soc=sum(s.soc * s.capacity for s in samples) / sum(s.capacity for s in samples), 144 | temperatures=sum(((s.temperatures or []) for s in samples), []), 145 | mos_temperature=max((s.mos_temperature for s in samples if is_finite(s.mos_temperature)), default=math.nan), 146 | switches={k: v for s in samples for k, v in (s.switches or {}).items()}, 147 | timestamp=min(s.timestamp for s in samples), 148 | ) 149 | -------------------------------------------------------------------------------- /bmslib/models/BLE_BMS_wrap.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import math 3 | import time 4 | from typing import Dict, Tuple 5 | 6 | from bleak import BLEDevice 7 | 8 | from bmslib.bms import BmsSample, DeviceInfo 9 | from bmslib.bms_ble.const import ATTR_BATTERY_LEVEL, KEY_CELL_COUNT 10 | from bmslib.bt import BtBms 11 | 12 | 13 | class BLEDeviceResolver: 14 | devices: Dict[Tuple[str, str], BLEDevice] = {} 15 | 16 | @staticmethod 17 | async def resolve(addr: str, adapter=None) -> BLEDevice: 18 | key = (adapter, addr) 19 | if key in BLEDeviceResolver.devices: 20 | return BLEDeviceResolver.devices[key] 21 | 22 | if BtBms.shutdown: 23 | raise RuntimeError("in shutdown") 24 | 25 | import bleak 26 | scanner_kw = {} 27 | if adapter: 28 | scanner_kw['adapter'] = adapter 29 | scanner = bleak.BleakScanner(**scanner_kw) 30 | 31 | await scanner.start() 32 | 33 | t0 = time.time() 34 | while time.time() - t0 < 5: 35 | if BtBms.shutdown: 36 | raise RuntimeError("in shutdown") 37 | 38 | try: 39 | for d in scanner.discovered_devices: 40 | BLEDeviceResolver.devices[(adapter, d.address)] = d 41 | BLEDeviceResolver.devices[(adapter, d.name)] = d 42 | if key in BLEDeviceResolver.devices: 43 | break 44 | except Exception as e: 45 | pass 46 | 47 | await asyncio.sleep(.1) 48 | 49 | await scanner.stop() 50 | return BLEDeviceResolver.devices.get(key, None) 51 | 52 | 53 | class BMS(): 54 | 55 | def __init__(self, address, type, module=None, keep_alive=False, adapter=None, name=None, **kwargs): 56 | self.address = address 57 | self.adapter = adapter 58 | self.name = name 59 | self._type = type 60 | self._blebms_module = module 61 | self._keep_alive = keep_alive 62 | 63 | self._last_sample = None 64 | 65 | self.is_virtual = False 66 | self.verbose_log = False 67 | 68 | self.connect_time = time.time() 69 | 70 | import bmslib.bms_ble.plugins.basebms 71 | 72 | self.ble_bms: bmslib.bms_ble.plugins.basebms.BaseBMS = None 73 | 74 | def _notification_handler(self, sender, data: bytes): 75 | pass 76 | 77 | def set_keep_alive(self, keep): 78 | self._keep_alive = keep 79 | # self.ble_bms._reconnect = not keep 80 | 81 | @property 82 | def is_connected(self): 83 | return self.ble_bms and self.ble_bms._client.is_connected 84 | 85 | async def __aenter__(self): 86 | if not self._keep_alive or not self.is_connected: 87 | await self.connect() 88 | 89 | async def __aexit__(self, *args): 90 | if not self._keep_alive and self.is_connected: 91 | await self.disconnect() 92 | 93 | def __await__(self): 94 | return self.__aexit__().__await__() 95 | 96 | async def connect(self, timeout=20, **kwargs): 97 | import bmslib.bms_ble.plugins.basebms 98 | 99 | ble_device = await BLEDeviceResolver.resolve(self.address, adapter=self.adapter) 100 | 101 | if ble_device is None: 102 | raise RuntimeError("device %s not found" % self.address) 103 | 104 | 105 | self.ble_bms: bmslib.bms_ble.plugins.basebms.BaseBMS = self._blebms_module.BMS( 106 | ble_device=ble_device, 107 | reconnect=not self._keep_alive 108 | ) 109 | 110 | await self.ble_bms._connect() 111 | 112 | # await super().connect(**kwargs) 113 | # try: 114 | # await super().connect(timeout=6) 115 | # except Exception as e: 116 | # self.logger.info("%s normal connect failed (%s), connecting with scanner", self.name, str(e) or type(e)) 117 | # await self._connect_with_scanner(timeout=timeout) 118 | # await self.start_notify(self.CHAR_UUID, self._notification_handler) 119 | 120 | async def disconnect(self): 121 | await self.ble_bms.disconnect() 122 | # await self.client.stop_notify(self.CHAR_UUID) 123 | # await super().disconnect() 124 | 125 | async def fetch_device_info(self) -> DeviceInfo: 126 | di = self.ble_bms.device_info() 127 | return DeviceInfo( 128 | mnf=di.get("manufacturer"), 129 | model=di.get("model"), 130 | hw_version=None, 131 | sw_version=None, 132 | name=None, 133 | sn=None, 134 | ) 135 | 136 | async def fetch(self) -> BmsSample: 137 | from bmslib.bms_ble.const import ( 138 | ATTR_CURRENT, 139 | ATTR_CYCLE_CAP, 140 | ATTR_CYCLE_CHRG, 141 | ATTR_POWER, 142 | ATTR_TEMPERATURE, 143 | ATTR_VOLTAGE, 144 | ATTR_CYCLES, 145 | ATTR_BALANCE_CUR, 146 | ) 147 | 148 | sample = await self.ble_bms.async_update() 149 | self._last_sample = sample 150 | return BmsSample( 151 | soc=sample[ATTR_BATTERY_LEVEL], 152 | voltage=sample[ATTR_VOLTAGE], 153 | current=sample[ATTR_CURRENT], power=sample.get(ATTR_POWER, math.nan), 154 | capacity=sample.get(ATTR_CYCLE_CHRG, math.nan), # todo ? 155 | cycle_capacity=sample.get(ATTR_CYCLE_CAP, math.nan), # todo ? 156 | num_cycles=sample.get(ATTR_CYCLES, math.nan), 157 | balance_current=sample.get(ATTR_BALANCE_CUR, math.nan), 158 | temperatures=[sample.get(ATTR_TEMPERATURE)], # todo? 159 | # mos_temperature= 160 | 161 | ) 162 | 163 | async def fetch_voltages(self): 164 | s = self._last_sample 165 | if s is None: 166 | return [] 167 | v = [self._last_sample[f'cell#{i}'] for i in range(s[KEY_CELL_COUNT])] 168 | return v 169 | 170 | def debug_data(self): 171 | return self._last_sample 172 | -------------------------------------------------------------------------------- /bmslib/models/__init__.py: -------------------------------------------------------------------------------- 1 | from functools import partial 2 | 3 | from bmslib.util import get_logger 4 | 5 | logger = get_logger() 6 | 7 | 8 | def get_bms_model_class(name): 9 | import bmslib.models.ant 10 | import bmslib.models.daly 11 | import bmslib.models.daly2 12 | import bmslib.models.dummy 13 | import bmslib.models.jbd 14 | import bmslib.models.jikong 15 | import bmslib.models.sok 16 | import bmslib.models.supervolt 17 | import bmslib.models.victron 18 | import bmslib.models.BLE_BMS_wrap 19 | 20 | import bmslib.group 21 | 22 | from bmslib import models 23 | 24 | from bmslib.bms_ble import plugins 25 | import bmslib.bms_ble.plugins.seplos_bms 26 | import bmslib.bms_ble.plugins.seplos_v2_bms 27 | import bmslib.bms_ble.plugins.daly_bms 28 | import bmslib.bms_ble.plugins.tdt_bms 29 | import bmslib.bms_ble.plugins.ej_bms 30 | import bmslib.bms_ble.plugins.abc_bms 31 | import bmslib.bms_ble.plugins.cbtpwr_bms 32 | import bmslib.bms_ble.plugins.dpwrcore_bms 33 | import bmslib.bms_ble.plugins.ecoworthy_bms 34 | import bmslib.bms_ble.plugins.ective_bms 35 | import bmslib.bms_ble.plugins.felicity_bms 36 | import bmslib.bms_ble.plugins.ogt_bms 37 | import bmslib.bms_ble.plugins.redodo_bms 38 | import bmslib.bms_ble.plugins.roypow_bms 39 | 40 | #for k in dir(plugins): 41 | # print(k) 42 | 43 | bms_registry = dict( 44 | daly=models.daly.DalyBt, 45 | daly2=models.daly2.Daly2Bt, 46 | jbd=models.jbd.JbdBt, 47 | jk=models.jikong.JKBt, # auto detect 48 | jk_24s=models.jikong.JKBt_24s, # https://github.com/syssi/esphome-jk-bms/blob/main/esp32-ble-example.yaml#L6 49 | jk_32s=models.jikong.JKBt_32s, 50 | ant=models.ant.AntBt, 51 | victron=models.victron.SmartShuntBt, 52 | group_parallel=bmslib.group.VirtualGroupBms, 53 | # group_serial=bmslib.group.VirtualGroupBms, # TODO 54 | supervolt=models.supervolt.SuperVoltBt, 55 | sok=models.sok.SokBt, 56 | daly_ble=partial(models.BLE_BMS_wrap.BMS, module=plugins.daly_bms, type='daly_ble'), 57 | dummy=models.dummy.DummyBt, 58 | ) 59 | 60 | for k in dir(plugins): 61 | if k.startswith('_') or not k.endswith('_bms'): 62 | continue 63 | if k[:-4] in bms_registry: 64 | continue 65 | # print(k) 66 | bms_registry[k[:-4]] = partial(models.BLE_BMS_wrap.BMS, type=k, module=getattr(plugins, k)) 67 | 68 | return bms_registry.get(name) 69 | 70 | 71 | def construct_bms(dev, verbose_log, bt_discovered_devices): 72 | addr: str = dev['address'] 73 | 74 | if not addr or addr.startswith('#'): 75 | return None 76 | 77 | bms_class = get_bms_model_class(dev['type']) 78 | 79 | if bms_class is None: 80 | logger.warning('Unknown device type %s', dev) 81 | return None 82 | 83 | if dev.get('debug'): 84 | logger.info('Verbose log for %s enabled', addr) 85 | 86 | def name2addr(name: str): 87 | return next((d.address for d in bt_discovered_devices if (d.name or "").strip() == name.strip()), name) 88 | 89 | def dev_by_addr(address: str): 90 | dev = next((d for d in bt_discovered_devices if d.address == address), None) 91 | if not dev: 92 | raise Exception("Can't resolve device name %s, not discovered" % address) 93 | return dev 94 | 95 | addr = name2addr(addr) 96 | 97 | name: str = dev.get('alias') or dev_by_addr(addr).name 98 | 99 | return bms_class(address=addr, 100 | name=name, 101 | verbose_log=verbose_log or dev.get('debug'), 102 | psk=dev.get('pin'), 103 | adapter=dev.get('adapter'), 104 | ) 105 | -------------------------------------------------------------------------------- /bmslib/models/daly2.py: -------------------------------------------------------------------------------- 1 | """ 2 | Other Daly protocol 3 | 4 | References 5 | - https://github.com/roccotsi2/esp32-smart-bms-simulation 6 | 7 | - https://github.com/tomatensaus/python-daly-bms 8 | 9 | """ 10 | 11 | from bmslib import FuturesPool 12 | from bmslib.bms import BmsSample 13 | from bmslib.bt import BtBms 14 | 15 | 16 | def _daly_command(command: int): 17 | return bytes([0xDD, 0xA5, command, 0x00, 0xFF, 0xFF - (command - 1), 0x77]) 18 | 19 | 20 | class Daly2Bt(BtBms): 21 | UUID_RX = '0000fff1-0000-1000-8000-00805f9b34fb' 22 | UUID_TX = '0000fff2-0000-1000-8000-00805f9b34fb' 23 | TIMEOUT = 8 24 | 25 | def __init__(self, address, **kwargs): 26 | super().__init__(address, **kwargs) 27 | self._buffer = bytearray() 28 | self._fetch_futures = FuturesPool() 29 | self._switches = None 30 | 31 | def _notification_handler(self, _sender, data): 32 | self.logger.debug("ble data frame %s", data) 33 | self._buffer += data 34 | 35 | if self._buffer.endswith(b'w'): 36 | command = self._buffer[1] 37 | buf = self._buffer[:] 38 | self._buffer.clear() 39 | 40 | # print(command, 'buffer endswith w', self._buffer) 41 | self._fetch_futures.set_result(command, buf) 42 | 43 | async def connect(self, **kwargs): 44 | await super().connect(**kwargs) 45 | await self.client.start_notify(self.UUID_RX, self._notification_handler) 46 | 47 | async def disconnect(self): 48 | await self.client.stop_notify(self.UUID_RX) 49 | self._fetch_futures.clear() 50 | await super().disconnect() 51 | 52 | async def _q(self, cmd): 53 | with self._fetch_futures.acquire(cmd): 54 | # await self.client.write_gatt_char(self.UUID_TX, data=_jbd_command(cmd)) 55 | return await self._fetch_futures.wait_for(cmd, self.TIMEOUT) 56 | 57 | async def fetch(self) -> BmsSample: 58 | # binary reading 59 | # https://github.com/roccotsi2/esp32-smart-bms-simulation 60 | 61 | buf = await self._q(cmd=bytes.fromhex("D2 03 00 00 00 3E D7 B9")) 62 | buf = buf[3:] 63 | 64 | #num_cell = int.from_bytes(buf[21:22], 'big') 65 | num_temp = int.from_bytes(buf[100:102], 'big') 66 | 67 | mos_byte = int.from_bytes(buf[20:21], 'big') 68 | 69 | sample = BmsSample( 70 | voltage=int.from_bytes(buf[80:82], byteorder='big') / 10, 71 | current=(int.from_bytes(buf[82:84], byteorder='big') - 30000) / 10, 72 | soc=int.from_bytes(buf[84:86], byteorder='big') / 10, 73 | 74 | charge=int.from_bytes(buf[96:98], byteorder='big') / 10, 75 | #capacity=int.from_bytes(buf[6:8], byteorder='big', signed=True) / 100, 76 | 77 | num_cycles=int.from_bytes(buf[102:104], byteorder='big'), 78 | 79 | temperatures=[(int.from_bytes(buf[64 + i * 2:i * 2 + 66], 'big') - 40) for i in range(num_temp)], 80 | 81 | switches=dict( 82 | discharge=mos_byte == 2 or mos_byte == 3, 83 | charge=mos_byte == 1 or mos_byte == 3, 84 | ), 85 | 86 | # charge_enabled 87 | # discharge_enabled 88 | ) 89 | 90 | self._switches = dict(sample.switches) 91 | 92 | # print(dict(num_cell=num_cell, num_temp=num_temp)) 93 | 94 | # self.rawdat['P']=round(self.rawdat['Vbat']*self.rawdat['Ibat'], 1) 95 | # self.rawdat['Bal'] = int.from_bytes(self.response[12:14], byteorder='big', signed=False) 96 | 97 | product_date = int.from_bytes(buf[10:12], byteorder='big', signed=True) 98 | # productDate = convertByteToUInt16(data1: data[14], data2: data[15]) 99 | 100 | return sample 101 | 102 | async def fetch_voltages(self): 103 | raise NotImplementedError() 104 | 105 | async def set_switch(self, switch: str, state: bool): 106 | pass 107 | 108 | 109 | -------------------------------------------------------------------------------- /bmslib/models/jbd.py: -------------------------------------------------------------------------------- 1 | """ 2 | JBD protocol references 3 | https://github.com/syssi/esphome-jbd-bms 4 | https://github.com/syssi/esphome-jbd-bms/blob/main/docs/Jiabaida.communication.protocol.pdf 5 | https://gitlab.com/bms-tools/bms-tools/-/tree/master/bmstools?ref_type=heads 6 | https://github.com/sshoecraft/jbdtool/blob/1168edac728d1e0bdea6cd4fa142548c445f80ec/main.c 7 | https://github.com/Bangybug/esp32xiaoxiangble/blob/master/src/main.cpp 8 | 9 | 10 | https://blog.ja-ke.tech/2020/02/07/ltt-power-bms-chinese-protocol.html # checksum 11 | Unseen: 12 | https://github.com/tgalarneau/bms 13 | 14 | """ 15 | import asyncio 16 | 17 | from bmslib.bms import BmsSample 18 | from bmslib.bt import BtBms 19 | 20 | 21 | def _jbd_command(command: int): 22 | return bytes([0xDD, 0xA5, command, 0x00, 0xFF, 0xFF - (command - 1), 0x77]) 23 | 24 | 25 | class JbdBt(BtBms): 26 | UUID_RX = '0000ff01-0000-1000-8000-00805f9b34fb' 27 | UUID_TX = '0000ff02-0000-1000-8000-00805f9b34fb' 28 | TIMEOUT = 16 29 | 30 | def __init__(self, address, **kwargs): 31 | super().__init__(address, **kwargs) 32 | self._buffer = bytearray() 33 | self._switches = None 34 | self._last_response = None 35 | 36 | def _notification_handler(self, sender, data): 37 | 38 | # print("bms msg {0}: {1}".format(sender, data)) 39 | self._buffer += data 40 | 41 | if self._buffer.endswith(b'w'): 42 | command = self._buffer[1] 43 | buf = self._buffer[:] 44 | self._buffer.clear() 45 | 46 | # print(command, 'buffer endswith w', self._buffer) 47 | self._last_response = buf 48 | self._fetch_futures.set_result(command, buf) 49 | 50 | async def connect(self, **kwargs): 51 | await super().connect(**kwargs) 52 | #try: 53 | # await super().connect(**kwargs) 54 | #except Exception as e: 55 | # self.logger.info("normal connect failed (%s), connecting with scanner", e) 56 | # await self._connect_with_scanner(**kwargs) 57 | 58 | await self.client.start_notify(self.UUID_RX, self._notification_handler) 59 | 60 | async def disconnect(self): 61 | await self.client.stop_notify(self.UUID_RX) 62 | await super().disconnect() 63 | 64 | async def _q(self, cmd): 65 | with self._fetch_futures.acquire(cmd): 66 | await self.client.write_gatt_char(self.UUID_TX, data=_jbd_command(cmd)) 67 | return await self._fetch_futures.wait_for(cmd, self.TIMEOUT) 68 | 69 | async def fetch(self) -> BmsSample: 70 | # binary reading 71 | # https://github.com/NeariX67/SmartBMSUtility/blob/main/Smart%20BMS%20Utility/Smart%20BMS%20Utility/BMSData.swift 72 | 73 | buf = await self._q(cmd=0x03) 74 | buf = buf[4:] 75 | 76 | num_cell = int.from_bytes(buf[21:22], 'big') 77 | num_temp = int.from_bytes(buf[22:23], 'big') 78 | 79 | mos_byte = int.from_bytes(buf[20:21], 'big') 80 | 81 | sample = BmsSample( 82 | voltage=int.from_bytes(buf[0:2], byteorder='big', signed=False) / 100, 83 | current=-int.from_bytes(buf[2:4], byteorder='big', signed=True) / 100, 84 | 85 | charge=int.from_bytes(buf[4:6], byteorder='big', signed=False) / 100, 86 | capacity=int.from_bytes(buf[6:8], byteorder='big', signed=False) / 100, 87 | soc=buf[19], 88 | 89 | num_cycles=int.from_bytes(buf[8:10], byteorder='big', signed=False), 90 | 91 | temperatures=[(int.from_bytes(buf[23 + i * 2:i * 2 + 25], 'big') - 2731) / 10 for i in range(num_temp)], 92 | 93 | switches=dict( 94 | discharge=mos_byte == 2 or mos_byte == 3, 95 | charge=mos_byte == 1 or mos_byte == 3, 96 | ), 97 | 98 | # charge_enabled 99 | # discharge_enabled 100 | ) 101 | 102 | self._switches = dict(sample.switches) 103 | 104 | # print(dict(num_cell=num_cell, num_temp=num_temp)) 105 | 106 | # self.rawdat['P']=round(self.rawdat['Vbat']*self.rawdat['Ibat'], 1) 107 | # self.rawdat['Bal'] = int.from_bytes(self.response[12:14], byteorder='big', signed=False) 108 | 109 | product_date = int.from_bytes(buf[10:12], byteorder='big', signed=True) 110 | # productDate = convertByteToUInt16(data1: data[14], data2: data[15]) 111 | 112 | return sample 113 | 114 | async def fetch_voltages(self): 115 | buf = await self._q(cmd=0x04) 116 | num_cell = int(buf[3] / 2) 117 | voltages = [(int.from_bytes(buf[4 + i * 2:i * 2 + 6], 'big')) for i in range(num_cell)] 118 | return voltages 119 | 120 | async def set_switch(self, switch: str, state: bool): 121 | 122 | assert switch in {"charge", "discharge"} 123 | 124 | # see https://wiki.jmehan.com/download/attachments/59114595/JBD%20Protocol%20English%20version.pdf?version=1&modificationDate=1650716897000&api=v2 125 | # 126 | def jbd_checksum(cmd, data): 127 | crc = 0x10000 128 | for i in (data + bytes([len(data), cmd])): 129 | crc = crc - int(i) 130 | return crc.to_bytes(2, byteorder='big') 131 | 132 | def jbd_message(status_bit, cmd, data): 133 | return bytes([0xDD, status_bit, cmd, len(data)]) + data + jbd_checksum(cmd, data) + bytes([0x77]) 134 | 135 | if not self._switches: 136 | await self.fetch() 137 | 138 | new_switches = {**self._switches, switch: state} 139 | switches_sum = sum(new_switches.values()) 140 | if switches_sum == 2: 141 | tc = 0x00 # all on 142 | elif switches_sum == 0: 143 | tc = 0x03 # all off 144 | elif (switch == "charge" and not state) or (switch == "discharge" and state): 145 | tc = 0x01 # charge off 146 | else: 147 | tc = 0x02 # charge on, discharge off 148 | 149 | data = jbd_message(status_bit=0x5A, cmd=0xE1, data=bytes([0x00, tc])) # all off 150 | self.logger.info("send switch msg: %s", data) 151 | await self.client.write_gatt_char(self.UUID_TX, data=data) 152 | 153 | def debug_data(self): 154 | return self._last_response 155 | 156 | 157 | async def main(): 158 | # mac_address = 'A3161184-6D54-4B9E-8849-E755F10CEE12' 159 | mac_address = 'A4:C1:38:44:48:E7' 160 | # serial_service = '0000ff00-0000-1000-8000-00805f9b34fb' 161 | 162 | bms = JbdBt(mac_address, name='jbd') 163 | await bms.connect() 164 | voltages = await bms.fetch_voltages() 165 | print(voltages) 166 | # sample = await bms.fetch() 167 | # print(sample) 168 | await bms.disconnect() 169 | 170 | 171 | if __name__ == '__main__': 172 | asyncio.run(main()) 173 | -------------------------------------------------------------------------------- /bmslib/models/sok.py: -------------------------------------------------------------------------------- 1 | """ 2 | SOK BMS protocol reverse engineered by @zuccaro Tony Zuccaro 3 | from ABC BMS Android app (com.sjty.sbs_bms). 4 | 5 | References 6 | - https://github.com/Louisvdw/dbus-serialbattery/issues/350#issuecomment-1500658941 7 | 8 | svc_id = '0000ffe0-0000-1000-8000-00805f9b34fb' 9 | notify_id = '0000ffe1-0000-1000-8000-00805f9b34fb' 10 | tx_id = '0000ffe2-0000-1000-8000-00805f9b34fb' 11 | 12 | cmd_name = [ 0xee, 0xc0, 0x00, 0x00, 0x00 ] 13 | cmd_info = [ 0xee, 0xc1, 0x00, 0x00, 0x00 ] 14 | cmd_detail = [ 0xee, 0xc2, 0x00, 0x00, 0x00 ] 15 | cmd_setting = [ 0xee, 0xc3, 0x00, 0x00, 0x00 ] 16 | cmd_protection = [ 0xee, 0xc4, 0x00, 0x00, 0x00 ] 17 | cmd_break = [ 0xdd, 0xc0, 0x00, 0x00, 0x00 ] 18 | """ 19 | import asyncio 20 | import logging 21 | import platform 22 | import struct 23 | import statistics 24 | 25 | from bmslib import FuturesPool 26 | from bmslib.bms import BmsSample 27 | from bmslib.bt import BtBms 28 | 29 | 30 | def get_str(ubit, uuid): 31 | """ reads utf8 string from specified bluetooth uuid """ 32 | return ''.join(bytes(ubit.char_read(uuid)).decode('UTF-8')) 33 | 34 | 35 | def unpack(pattern, data): 36 | """ slightly simpler unpack call """ 37 | return struct.unpack(pattern, data)[0] 38 | 39 | 40 | def getBeUint4(data, offset): 41 | """ gets big-endian unsigned integer """ 42 | return unpack('>I', bytes(data[offset:offset+4])) 43 | 44 | 45 | def getBeUint3(data, offset): 46 | """ reads 3b big-endian unsigned int """ 47 | return unpack('>I', bytes([0]+data[offset:offset+3])) 48 | 49 | 50 | def getLeInt3(data, offset): 51 | """ reads 3b little-endian signed int """ 52 | return unpack('> 1) ^ 140 if (i & 1) != 0 else i >> 1 72 | return i 73 | 74 | 75 | def _sok_command(command: int): 76 | data = [0xee, command, 0x00, 0x00, 0x00] 77 | data2 = data + [minicrc(data)] 78 | logging.debug(f'SOK: Formatting command [{data2}]') 79 | return bytes([0xEE, command, 0x00, 0x00, 0x00]) 80 | 81 | 82 | class SokBt(BtBms): 83 | UUID_RX = '0000ffe1-0000-1000-8000-00805f9b34fb' 84 | UUID_TX = '0000ffe2-0000-1000-8000-00805f9b34fb' 85 | TIMEOUT = 10 86 | 87 | def __init__(self, address, **kwargs): 88 | super().__init__(address, **kwargs) 89 | self._buffer = bytearray() 90 | self._fetch_futures = FuturesPool() 91 | self._switches = None 92 | 93 | def _notification_handler(self, sender, data): 94 | self.logger.debug("ble data frame %s", data) 95 | self._buffer += data 96 | 97 | if self._buffer.endswith(b'w'): 98 | command = self._buffer[1] 99 | buf = self._buffer[:] 100 | self._buffer.clear() 101 | 102 | # print(command, 'buffer endswith w', self._buffer) 103 | self._fetch_futures.set_result(command, buf) 104 | 105 | async def connect(self, **kwargs): 106 | await super().connect(**kwargs) 107 | await self.client.start_notify(self.UUID_RX, self._notification_handler) 108 | 109 | async def disconnect(self): 110 | await self.client.stop_notify(self.UUID_RX) 111 | self._fetch_futures.clear() 112 | await super().disconnect() 113 | 114 | async def _q(self, cmd): 115 | with self._fetch_futures.acquire(cmd): 116 | await self.client.write_gatt_char(self.UUID_TX, data=_sok_command(cmd)) 117 | return await self._fetch_futures.wait_for(cmd, self.TIMEOUT) 118 | 119 | async def fetch(self) -> BmsSample: 120 | 121 | buf = await self._q(cmd=0xC1) # info 122 | logging.debug(f'SOK: Received [{bytes(buf).hex().upper()}]') 123 | # this is not accurate, find out why 124 | # self.volts = (getLeInt3(value, 2) * 4) / 1000**2 125 | # ma = getLeInt3(buf, 5) / 1000**2 126 | # num_cycles = (struct.unpack(' BmsSample: 106 | 107 | t_expire = time.time() - 10 108 | for k, t in self._values_t.items(): 109 | if t < t_expire and not math.isnan(self._values.get(k, 0)): 110 | # check if value actually changed before re-subscription 111 | val = await self._fetch_value(k, reload_services=True) 112 | if val != self._values.get(k): 113 | self.logger.warning('value for %s expired %s, re-sub', k, t) 114 | await self._subscribe(k, val) 115 | values = self._values 116 | sample = BmsSample(**values, timestamp=max(v for k, v in self._values_t.items() if not math.isnan(values[k]))) 117 | return sample 118 | 119 | async def fetch_voltages(self): 120 | return [] 121 | 122 | async def fetch_temperatures(self): 123 | return [] 124 | 125 | async def fetch_device_info(self) -> DeviceInfo: 126 | dev = DeviceInfo( 127 | mnf="Victron", 128 | model='SmartShunt', 129 | hw_version=None, 130 | sw_version=None, 131 | name=None, 132 | sn=None, 133 | ) 134 | return dev 135 | 136 | 137 | async def main(): 138 | # raise NotImplementedError() 139 | v = SmartShuntBt(address='8B133977-182C-62EE-8E81-41FF77969EE9', name='test') 140 | await v.connect() 141 | 142 | _prev_val = None 143 | 144 | while True: 145 | r = await v.fetch() 146 | v.logger.info(r) 147 | await asyncio.sleep(2) 148 | 149 | # testing read vs notify latency and frequency: 150 | # char = VICTRON_CHARACTERISTICS['current'] 151 | # val = parse_value(await v.client.read_gatt_char(char['uuid']), char) 152 | # if val != _prev_val: 153 | # print('val changed', val) 154 | # _prev_val = val 155 | # await asyncio.sleep(.1) 156 | 157 | 158 | if __name__ == '__main__': 159 | asyncio.run(main()) 160 | -------------------------------------------------------------------------------- /bmslib/pwmath.py: -------------------------------------------------------------------------------- 1 | import math 2 | 3 | 4 | class EWMA: 5 | # Implement Exponential Weighted Moving Average 6 | def __init__(self, span: int): 7 | self.alpha = math.nan 8 | self.y = math.nan 9 | self.update_span(span) 10 | 11 | def update_span(self, span): 12 | self.alpha = (2 / (span + 1)) 13 | 14 | def add(self, x): 15 | if not math.isfinite(x): 16 | return 17 | if not math.isfinite(self.y): 18 | self.y = x 19 | self.y = (1 - self.alpha) * self.y + self.alpha * x 20 | 21 | @property 22 | def value(self): 23 | return self.y 24 | 25 | 26 | class LHQ: 27 | """ 28 | Low-Pass hysteresis quantizer 29 | 30 | 1. smooth the already quantized signal with a exponential weighted moving average 31 | 2. Hysteresis 32 | 3. Quantize to 2x input precision (add 1 bit) 33 | """ 34 | 35 | def __init__(self, span=20, inp_q=0.1): 36 | self.ewma = EWMA(span=span) 37 | self.last = math.nan 38 | self.inp_q = inp_q 39 | 40 | def add(self, x): 41 | self.ewma.add(x) 42 | # quantize(mean((last,x,x)) 43 | m = (self.last + 2 * self.ewma.value) / 3 44 | if math.isnan(m): 45 | if math.isnan(self.last): 46 | self.last = x 47 | return math.nan 48 | self.last = round(m * 2 / self.inp_q) * .5 * self.inp_q 49 | return self.last 50 | 51 | 52 | class EWM: 53 | # Implement EWMA statistics mean and stddev 54 | def __init__(self, span: int, std_regularisation: float): 55 | self.avg = EWMA(span) 56 | self.std = EWMA(span) 57 | self._last_x = math.nan 58 | self.std_regularisation = std_regularisation 59 | 60 | def add(self, x): 61 | self.avg.add(x) 62 | if self.std_regularisation != 0: 63 | x = (-1 if x < 0 else 1) * (abs(x) + self.std_regularisation) 64 | ex = self._last_x 65 | # ex = self.avg.value 66 | if math.isfinite(ex): 67 | pct = (x - ex) / ex 68 | pct = min(abs(pct), 1.4) 69 | self.std.add(pct * pct) 70 | self._last_x = x 71 | 72 | @property 73 | def stddev(self): 74 | return abs(self.avg.value * self.std.value) 75 | 76 | def z_score(self, x): 77 | return (x - self.avg.value) / (self.stddev + self.std_regularisation) 78 | 79 | 80 | class Integrator: 81 | """ 82 | Implement a trapezoidal integration, discarding samples with dx > dx_max. 83 | """ 84 | 85 | def __init__(self, name, dx_max, value=0.): 86 | self.name = name 87 | self._last_x = math.nan 88 | self._last_y = math.nan 89 | self._integrator = value 90 | self.dx_max = dx_max 91 | 92 | def __iadd__(self, other): 93 | """ 94 | integrator_object += x, y 95 | 96 | :param other: 97 | :return: 98 | """ 99 | assert isinstance(other, tuple) 100 | self.add_linear(*other) 101 | return self 102 | 103 | def add_linear(self, x, y): 104 | # assert timestamp, "" 105 | # trapezoidal sum 106 | 107 | if not math.isnan(self._last_x): 108 | dx = (x - self._last_x) 109 | if dx < 0: 110 | raise ValueError("x must be monotonic increasing (given %s, last %s)" % (x, self._last_x)) 111 | if dx <= self.dx_max: 112 | y_hat = (self._last_y + y) / 2 113 | self._integrator += dx * y_hat 114 | 115 | self._last_x = x 116 | self._last_y = y 117 | 118 | def get(self): 119 | return self._integrator 120 | 121 | def restore(self, value): 122 | self._integrator = value 123 | 124 | 125 | class DiffAbsSum(Integrator): 126 | """ 127 | Implement a differential absolute sum, discarding samples with dx > dx_max. 128 | """ 129 | 130 | def __init__(self, name, dx_max, dy_max, value=0.): 131 | super().__init__(name, dx_max=dx_max, value=value) 132 | self.dy_max = dy_max 133 | 134 | def add_linear(self, x, y): 135 | raise NotImplementedError() 136 | 137 | def add_diff(self, x, y): 138 | if not math.isnan(self._last_x): 139 | dx = (x - self._last_x) 140 | if dx < 0: 141 | raise ValueError("x must be monotonic increasing (given %s, last %s)" % (x, self._last_x)) 142 | if dx <= self.dx_max: 143 | dy_abs = abs(y - self._last_y) 144 | if dy_abs <= self.dy_max: 145 | self._integrator += dy_abs 146 | 147 | self._last_x = x 148 | self._last_y = y 149 | 150 | def __iadd__(self, other): 151 | assert isinstance(other, tuple) 152 | self.add_diff(*other) 153 | return self 154 | 155 | 156 | def test_integrator(): 157 | i = Integrator("test", dx_max=1) 158 | i += (0, 1) 159 | assert i.get() == 0 160 | i += (1, 1) 161 | assert i.get() == 1 162 | i += (1, 2) 163 | assert i.get() == 1 164 | i += (2, 2) 165 | assert i.get() == 3 166 | i += (3, 3) # test trapezoid 167 | assert i.get() == (3 + 2.5) 168 | i += (5, 3) # skip (>dt_max) 169 | assert i.get() == (3 + 2.5) 170 | 171 | 172 | def test_diff_abs_sum(): 173 | i = DiffAbsSum("test", dx_max=1, dy_max=0.1) 174 | i += (0, 1) 175 | assert i.get() == 0 176 | i += (1, 1) 177 | assert i.get() == 0 178 | i += (1, 1.1) 179 | assert i.get() == 0 180 | i += (2, 1.2) 181 | assert round(i.get(), 5) == 0.1 182 | i += (3, 1.25) 183 | assert round(i.get(), 5) == 0.15 184 | i += (4, 1) 185 | assert round(i.get(), 5) == 0.15 186 | i += (5, 0.95) 187 | assert round(i.get(), 5) == 0.2 188 | 189 | 190 | def test_lhq(): 191 | l = LHQ(span=2, inp_q=.1) 192 | l.add(0) 193 | assert l.add(0.1) == 0.05 194 | assert l.add(0.1) == 0.1 195 | 196 | 197 | if __name__ == "__main__": 198 | test_integrator() 199 | test_diff_abs_sum() 200 | test_lhq() 201 | -------------------------------------------------------------------------------- /bmslib/store.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import re 4 | from os import access, R_OK 5 | from os.path import isfile 6 | from threading import Lock 7 | 8 | from bmslib.cache import random_str 9 | from bmslib.util import dotdict, get_logger 10 | 11 | logger = get_logger() 12 | 13 | 14 | def is_readable(file): 15 | return isfile(file) and access(file, R_OK) 16 | 17 | 18 | root_dir = '/data/' if is_readable('/data/options.json') else '' 19 | bms_meter_states_fn = root_dir + 'bms_meter_states.json' 20 | 21 | lock = Lock() 22 | 23 | 24 | def store_file(fn): 25 | return root_dir + fn 26 | 27 | 28 | def load_meter_states(): 29 | with lock: 30 | with open(bms_meter_states_fn) as f: 31 | meter_states = json.load(f) 32 | return meter_states 33 | 34 | 35 | def store_meter_states(meter_states): 36 | with lock: 37 | s = f'.{random_str(6)}.tmp' 38 | with open(bms_meter_states_fn + s, 'w') as f: 39 | json.dump(meter_states, f, indent=2) 40 | os.replace(bms_meter_states_fn + s, bms_meter_states_fn) 41 | 42 | 43 | def store_algorithm_state(bms_name, algorithm_name, state=None): 44 | fn = root_dir + 'bat_state_' + re.sub(r'[^\w_. -]', '_', bms_name) + '.json' 45 | with lock: 46 | with open(fn, 'a+') as f: 47 | try: 48 | f.seek(0) 49 | bms_state = json.load(f) 50 | except: 51 | logger.info('init %s bms state storage', bms_name) 52 | bms_state = dict(algorithm_state=dict()) 53 | 54 | if state is not None: 55 | bms_state['algorithm_state'][algorithm_name] = state 56 | f.seek(0), f.truncate() 57 | json.dump(bms_state, f, indent=2) 58 | 59 | return bms_state['algorithm_state'].get(algorithm_name, None) 60 | 61 | 62 | def load_user_config(): 63 | try: 64 | with open('/data/options.json') as f: 65 | conf = dotdict(json.load(f)) 66 | _user_config_migrate_addresses(conf) 67 | except Exception as e: 68 | logger.warning('error reading /data/options.json, trying options.json %s', e) 69 | with open('options.json') as f: 70 | conf = dotdict(json.load(f)) 71 | return conf 72 | 73 | 74 | def _user_config_migrate_addresses(conf): 75 | changed = False 76 | slugs = ["daly", "jbd", "jk", "sok", "victron"] 77 | conf["devices"] = conf.get('devices') or [] 78 | devices_by_address = {d['address']: d for d in conf["devices"]} 79 | for slug in slugs: 80 | addr = conf.get(f'{slug}_address') 81 | if addr and not devices_by_address.get(addr): 82 | device = dict( 83 | address=addr.strip('?'), 84 | type=slug, 85 | alias=slug + '_bms', 86 | ) 87 | if addr.endswith('?'): 88 | device["debug"] = True 89 | if conf.get(f'{slug}_pin'): 90 | device['pin'] = conf.get(f'{slug}_pin') 91 | conf["devices"].append(device) 92 | del conf[f'{slug}_address'] 93 | logger.info('Migrated %s_address to device %s', slug, device) 94 | changed = True 95 | if changed: 96 | logger.info('Please update add-on configuration manually.') 97 | -------------------------------------------------------------------------------- /bmslib/test/group.py: -------------------------------------------------------------------------------- 1 | from bmslib.bms import BmsSample 2 | from bmslib.group import sum_parallel 3 | 4 | 5 | def test_add_parallel(): 6 | 7 | samples = [ 8 | BmsSample(12.2, 2, charge=33, capacity=100), 9 | BmsSample(12.4, 3, charge=77, capacity=100) 10 | ] 11 | assert samples[0].power == (12.2*2) 12 | assert samples[0].soc == 33 13 | 14 | ss = sum_parallel(samples) 15 | assert ss.voltage == 12.3 16 | assert ss.current == 5 17 | assert ss.charge == 110 18 | assert ss.capacity == 200 19 | assert ss.soc == (33+77) / 2 20 | 21 | 22 | test_add_parallel() -------------------------------------------------------------------------------- /bmslib/test/test_futures_pool.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | from bmslib import FuturesPool 4 | 5 | pool = FuturesPool() 6 | 7 | 8 | async def test1(): 9 | try: 10 | with pool.acquire(1): 11 | await pool.wait_for(1, 0.01) 12 | except asyncio.exceptions.TimeoutError: 13 | pass 14 | 15 | with pool.acquire(1): 16 | try: 17 | await pool.wait_for(1, 0.01) 18 | except asyncio.exceptions.TimeoutError: 19 | pass 20 | 21 | try: 22 | with pool.acquire(1): 23 | await pool.wait_for(1, 0.01) 24 | except asyncio.exceptions.TimeoutError: 25 | pass 26 | 27 | 28 | asyncio.run(test1()) -------------------------------------------------------------------------------- /bmslib/tracker.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | Battery tracker: 4 | - detect the weakest cell: 5 | if same cell is empty first and full first, pack capacity is limited by this cell. 6 | balance of other cells then doesn't affect back capacity 7 | if we found the weakest cell, we can consider the battery is balanced, as balancing would not increase available cap 8 | - track capacity through current integration TODO 9 | - track SoC error when empty or full (TODO 10 | - track battery eff TODO 11 | - estimate cell charge offset by curve fitting of voltages 12 | 13 | """ 14 | 15 | from typing import Optional, Tuple 16 | 17 | import numpy as np 18 | 19 | from bmslib.util import dotdict, get_logger 20 | 21 | logger = get_logger() 22 | 23 | 24 | class Lifepo4: 25 | cell_voltage_min_valid = 2000, 26 | cell_voltage_max_valid = 4500, 27 | cell_voltage_empty = 2500, 28 | cell_voltage_almost_empty = 2700, 29 | cell_voltage_full = 3650, 30 | cell_voltage_almost_full = 3500, 31 | 32 | 33 | chemistry = Lifepo4() 34 | 35 | 36 | class BatteryTrackerState: 37 | def __init__(self): 38 | self.emptiest_cell: Optional[Tuple[int, int]] = None 39 | self.fullest_cell: Optional[Tuple[int, int]] = None 40 | self.weakest_cell: Optional[int] = None 41 | 42 | 43 | class BatteryTracker: 44 | 45 | def __init__(self): 46 | self.state = BatteryTrackerState() 47 | 48 | def _detect_weakest_cell(self, cell_low, cell_high): 49 | max_idx, max_v = cell_high 50 | min_idx, min_v = cell_low 51 | s = self.state 52 | 53 | if max_v > chemistry.cell_voltage_almost_full: 54 | if s.emptiest_cell: 55 | if max_idx == s.emptiest_cell[0]: 56 | logger.info("found weakest cell %d (it was empty at %s, now almost full at %s)", max_idx, 57 | s.emptiest_cell[1], max_v) 58 | s.weakest_cell = max_idx 59 | else: 60 | logger.info("cell %d almost full at %s, emptiest cell was %d at %s", max_idx, max_v, 61 | *s.emptiest_cell) 62 | s.weakest_cell = None 63 | else: 64 | s.weakest_cell = None 65 | 66 | if min_v < chemistry.cell_voltage_almost_empty: 67 | if s.fullest_cell: 68 | if min_idx == s.fullest_cell[0]: 69 | logger.info("found weakest cell %d (it was full at %s, now almost empty at %s)", min_idx, 70 | s.fullest_cell[1], min_v) 71 | s.weakest_cell = min_idx 72 | else: 73 | logger.info("cell %d almost empty at %s, fullest cell was %d at %s", min_idx, min_v, 74 | *s.fullest_cell) 75 | s.weakest_cell = None 76 | else: 77 | s.weakest_cell = None 78 | 79 | def update_cell_voltages(self, voltages): 80 | min_idx = np.argmin(voltages) 81 | max_idx = np.argmax(voltages) 82 | 83 | min_v = voltages[min_idx] 84 | max_v = voltages[max_idx] 85 | 86 | if min_v < chemistry.cell_voltage_min_valid: 87 | logger.warn("cell %d voltage %d lower than expected", min_idx, min_v) 88 | return False 89 | 90 | if max_v > chemistry.cell_voltage_max_valid: 91 | logger.warn("cell %d voltage %d higher than expected", max_idx, max_v) 92 | return False 93 | 94 | s = self.state 95 | 96 | if not s.emptiest_cell or min_v < s.emptiest_cell[1]: 97 | s.emptiest_cell = (min_idx, min_v) 98 | 99 | if not s.fullest_cell or max_v > s.fullest_cell[1]: 100 | s.fullest_cell = (max_idx, max_v) 101 | 102 | self._detect_weakest_cell((min_idx, min_v), (max_idx, max_v)) 103 | -------------------------------------------------------------------------------- /bmslib/util.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import random 4 | import string 5 | import time 6 | 7 | 8 | class dotdict(dict): 9 | def __getattr__(self, attr): 10 | try: 11 | return self[attr] 12 | except KeyError as e: 13 | raise AttributeError(e) 14 | 15 | __setattr__ = dict.__setitem__ 16 | __delattr__ = dict.__delitem__ 17 | # __hasattr__ = dict.__contains__ 18 | 19 | 20 | def get_logger(verbose=False): 21 | # log_format = '%(asctime)s %(levelname)-6s [%(filename)s:%(lineno)d] %(message)s' 22 | log_format = '%(asctime)s %(levelname)s [%(module)s] %(message)s' 23 | if verbose: 24 | level = logging.DEBUG 25 | else: 26 | level = logging.INFO 27 | 28 | logging.basicConfig(level=level, format=log_format, datefmt='%H:%M:%S') 29 | logger = logging.getLogger() 30 | logger.setLevel(logging.DEBUG if verbose else logging.INFO) 31 | 32 | return logger 33 | 34 | 35 | def dict_to_short_string(d: dict): 36 | return '(' + ','.join(f'{k}={v}' for k, v in d.items() if v is not None) + ')' 37 | 38 | 39 | def to_hex_str(data): 40 | return " ".join(map(lambda b: hex(b)[2:], data)) 41 | 42 | 43 | def exit_process(is_error=True, delayed=False): 44 | from threading import Thread 45 | import _thread 46 | status = 1 if is_error else 0 47 | Thread(target=lambda: (time.sleep(3), _thread.interrupt_main()), daemon=True).start() 48 | Thread(target=lambda: (time.sleep(6), os._exit(status)), daemon=True).start() 49 | if not delayed: 50 | import sys 51 | sys.exit(status) 52 | 53 | 54 | def _id_generator(size=6, chars=string.ascii_uppercase + string.ascii_lowercase + string.digits): 55 | return ''.join(random.choice(chars) for _ in range(size)) 56 | 57 | 58 | def sid_generator(n=2): 59 | assert n >= 2 60 | return _id_generator(n-1, string.ascii_lowercase + string.ascii_uppercase) + _id_generator(1, string.digits) 61 | 62 | -------------------------------------------------------------------------------- /bmslib/wired/__init__.py: -------------------------------------------------------------------------------- 1 | import threading 2 | import time 3 | 4 | from bmslib.wired.transport import SerialTransport, StdioTransport 5 | 6 | 7 | class SerialBleakClientWrapper(object): 8 | 9 | def __init__(self, address, **kwargs): 10 | self.address = address 11 | self.t = SerialTransport(address.split(':')[-1]) 12 | # self.t = StdioTransport() 13 | self.callback = {} 14 | self.services = [] 15 | self._rx_thread = threading.Thread(target=self._on_receive) 16 | self._rx_thread.start() 17 | 18 | async def get_services(self): 19 | return self.services 20 | 21 | async def connect(self, timeout=None): 22 | self.t.open() 23 | 24 | async def disconnect(self): 25 | self.t.close() 26 | 27 | @property 28 | def is_connected(self): 29 | return self.t.is_open 30 | 31 | def _on_receive(self): 32 | while True: 33 | data = self.t.is_open and self.t.read() 34 | if data: 35 | for callback in self.callback.values(): 36 | callback(self, data) 37 | time.sleep(0.1) 38 | 39 | async def start_notify(self, char, callback): 40 | self.callback[char] = callback 41 | pass 42 | 43 | async def stop_notify(self, char): 44 | self.callback.pop(char, None) 45 | 46 | async def write_gatt_char(self, _char, data): 47 | self.t.write(data) 48 | -------------------------------------------------------------------------------- /bmslib/wired/transport.py: -------------------------------------------------------------------------------- 1 | import glob 2 | import socket 3 | from typing import Optional 4 | 5 | import serial # pyserial 6 | 7 | from bmslib.util import get_logger 8 | 9 | logger = get_logger() 10 | 11 | 12 | class Transport(object): 13 | 14 | def open(self): 15 | raise NotImplementedError() 16 | 17 | def read(self) -> bytes: 18 | raise NotImplementedError() 19 | 20 | def write(self, data: bytes): 21 | raise NotImplementedError() 22 | 23 | def close(self): 24 | raise NotImplementedError() 25 | 26 | 27 | class SerialTransport(Transport): 28 | 29 | def __init__(self, port): 30 | self.port = port 31 | self.ser: Optional[serial.Serial] = None 32 | 33 | def open(self): 34 | port = self.port 35 | if '*' in port: 36 | port = glob.glob(port)[0] 37 | logger.info(f'opening serial port {port}') 38 | self.ser = serial.Serial(port, baudrate=115200) 39 | 40 | def close(self): 41 | if self.ser is not None: 42 | self.ser.close() 43 | 44 | def write(self, data: bytes): 45 | self.ser.write(data) 46 | 47 | @property 48 | def is_open(self): 49 | return self.ser and self.ser.is_open 50 | 51 | def read(self) -> Optional[bytes]: 52 | if self.ser.is_open and self.ser.readable(): 53 | return self.ser.readline() 54 | return None 55 | 56 | class StdioTransport(Transport): 57 | 58 | def __init__(self): 59 | self.is_open = False 60 | pass 61 | #self.port = port 62 | #self.ser: Optional[serial.Serial] = None 63 | 64 | def open(self): 65 | self.is_open = True 66 | pass 67 | 68 | def close(self): 69 | pass 70 | 71 | def write(self, data: bytes): 72 | print(data) 73 | 74 | 75 | def read(self) -> Optional[bytes]: 76 | return b'' 77 | 78 | 79 | class SocketTransport(Transport): 80 | def __init__(self, ip, port): 81 | self.addr = (ip, port) 82 | self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 83 | self.sock.settimeout(4) 84 | 85 | def open(self): 86 | logger.info('connecting to %s:%u', *self.addr) 87 | self.sock.connect(self.addr) 88 | 89 | def close(self): 90 | self.sock.close() 91 | 92 | def read(self): 93 | return self.sock.recv(1024) 94 | 95 | def write(self, data): 96 | return self.sock.send(data) 97 | -------------------------------------------------------------------------------- /config.yaml: -------------------------------------------------------------------------------- 1 | name: "Batmon" 2 | description: "Monitor various BMS over bluetooth" 3 | url: https://github.com/fl4p/batmon-ha 4 | version: "1.82" 5 | slug: "batmon" 6 | init: false 7 | host_dbus: true 8 | apparmor: true 9 | 10 | homeassistant_api: true # http://supervisor/core/api 11 | hassio_api: true # http://supervisor 12 | 13 | # watchdog: true # todo https://developers.home-assistant.io/docs/add-ons/configuration/ 14 | 15 | arch: 16 | - aarch64 17 | - amd64 18 | - armhf 19 | - armv7 20 | - i386 21 | 22 | services: 23 | - mqtt:need 24 | 25 | discovery: 26 | - mqtt 27 | 28 | 29 | options: 30 | devices: 31 | - address: D6:6C:0A:61:14:30 32 | type: sok 33 | alias: battery1 34 | concurrent_sampling: false 35 | keep_alive: true 36 | verbose_log: false 37 | sample_period: 1.0 38 | publish_period: 2.0 39 | invert_current: true 40 | watchdog: true 41 | expire_values_after: 20 42 | bt_power_cycle: false 43 | 44 | schema: 45 | devices: 46 | - address: str 47 | type: str 48 | alias: "str?" 49 | debug: "bool?" 50 | pin: "str?" 51 | algorithm: "str?" 52 | current_calibration: "float?" 53 | 54 | mqtt_user: "str?" 55 | mqtt_password: "str?" 56 | mqtt_broker: "str?" 57 | mqtt_port: "int(1,65535)?" 58 | 59 | concurrent_sampling: "bool" 60 | invert_current: "bool" 61 | keep_alive: "bool" 62 | watchdog: "bool" 63 | 64 | sample_period: "float" 65 | publish_period: "float?" 66 | expire_values_after: "float" 67 | 68 | verbose_log: "bool" 69 | 70 | bt_power_cycle: "bool?" 71 | 72 | influxdb_host: "str?" 73 | influxdb_username: "str?" 74 | influxdb_password: "str?" 75 | influxdb_ssl: "bool?" 76 | influxdb_verify_ssl: "bool?" 77 | influxdb_database: "str?" 78 | 79 | # telemetry: "bool?" -------------------------------------------------------------------------------- /doc/Algorithms.md: -------------------------------------------------------------------------------- 1 | *This is feature is experimental.* 2 | 3 | Using a BMS without "talking" to the solar charger (via RS434, CAN bus, etc.) usually causes "unhealthy" charge cycles. 4 | JK, JBD and Daly BMS, they cut off the charger if a certain voltage or cell-voltage level is reached. This is not ideal, 5 | because voltage does hardly represent SoC and it can quickly fall after charging stops (especially with LiFePo4). It 6 | ends up in repeating charge on/off loop, which is believed to be bad for the battery. 7 | 8 | # Algorithms 9 | 10 | Batmon implements charge rules (algorithms) which try to optimize cycling at "healthier" SoC levels to reduce battery 11 | degradation. 12 | You can enable an algorithm for each BMS, and it takes control over the charging switch. 13 | 14 | The algorithm toggles switches at trigger points only, so you can still use the BMS switches manually overriding 15 | the algorithm logic. 16 | Note that a newly added algorithm doesn't do anything until a trigger point is reached, so please wait patiently. 17 | 18 | To ensure proper SoC levels, algorithms might frequently calibrate. The calibration finishes once 100% SoC is reached. 19 | Calibration interval is currently fixed to 14 days. 20 | 21 | To enable an algorithm, add its signature to the BMS device entry in the add-on options: 22 | 23 | ``` 24 | - address: "xx:yy:zz:00:11" 25 | type: "jk" 26 | alias: "jk_bms" 27 | algorithm: "..." 28 | ``` 29 | 30 | There is currently only one simple algorithm, see below. 31 | 32 | # SoC Charge Algorithm 33 | 34 | Controls the `charge` switch to limit max SoC and/or adds a charge start hysteresis. 35 | If you know the alDente macOS App, you can compare this 36 | to [Sailing Mode](https://apphousekitchen.com/feature-explanation-sailing-mode/) 37 | 38 | Here are 3 scenarios you might use the algorithm for: 39 | 40 | 1. Limit the max SoC to e.g. 90% 41 | 42 | 2. "Holiday Mode": Imagine an off-grid system and you are away for a couple of weeks. 43 | During night only 2 % SoC is used from the battery and solar power will charge the battery to 100% each day. 44 | The battery is cycled at 100%-98%-100%. 45 | With the SoC Algorithm you for example implement 80%-70%-80% cycling, which might prolong battery lifetime. 46 | 47 | 3. Another scenario is "dumb" charger cut-off, where the BMS over-voltage protection kicks in. 48 | It might release after a couple of minutes as battery open circuit voltage falls over time, causing trickle charge. 49 | The algorithm will control charging by battery SoC rather than battery voltage, to avoid trickle charge. 50 | 51 | ## Signature 52 | 53 | ``` 54 | algorithm: "soc CHARGE_STOP% [CHARGE_START%]" 55 | ``` 56 | 57 | ## Arguments 58 | 59 | - `charge_stop`: at this SoC% the algorithm turns off the charger to avoid charging beyond 60 | - `charge_start`: at this SoC% the algorithm turns the charger on (optional) 61 | 62 | Even though the SoC% is below `charge_stop`, charging 63 | is paused until `charge_start` is reached. This can avoid trickle charge by adding a hysteresis. 64 | 65 | If `charge_start` is greater than `charge_stop` it is set to `charge_stop` and the hysteresis is disabled. 66 | 67 | ## Pseudo-Code 68 | 69 | ``` 70 | if soc% >= charge_stop: 71 | set_charge_switch(off) 72 | else if soc% <= charge_start: 73 | set_charge_switch(on) 74 | ``` 75 | 76 | ## Examples 77 | 78 | - `algorithm: soc 90%` limits max SoC to 90% without hysteresis. (notice that this is equal 79 | to `algorithm: soc 90% 90%` and `algorithm: soc 90% 100%`) 80 | - `algorithm: soc 100% 95%` avoid trickle charge 81 | - `algorithm: soc 80% 70%` "Holiday Mode" as described above, trying to keep SoC between 80 and 70 % (10% DoD) 82 | - `algorithm: soc 75% 25%` targets a 50% DoD (Depth-of-Discharge). 83 | -------------------------------------------------------------------------------- /doc/BMSes.md: -------------------------------------------------------------------------------- 1 | # Eval Properties 2 | * Idle consumption 3 | * Burden resistance 4 | * Average balancer current 5 | * Current Sensor 6 | * resolution (bit) 7 | * accuracy, linearity (gain error) 8 | * AC+DC currents (100 Hz for inverters) 9 | * current peaks 10 | * Bluetooth security 11 | * Balancer settings (during charge/discharge), dV 12 | * Protection params 13 | * voltage Cell Chemistry 14 | * Hysteresis 15 | * Short circuit protection (current & delay) 16 | * sleep behavior 17 | * energy saving mode? 18 | * auto re-start after UV shutdown? 19 | * SoC 20 | * Track battery capacity over time (calibrate nominal full charge) 21 | * set SoC at (sane) voltage levels (e.g. UV -> SoC:=0%) 22 | * does it track self consumption and battery self discharge? 23 | * Cycle Counter 24 | 25 | 26 | ## Daly BMS 27 | 28 | * Insecure! Password is validated in the app client-side (is publicly readable in device info). Remedy: Disconnect BT 29 | dongle (or leave batmon running with `keep_alive`) 30 | * Buggy Bluetooth dongle 31 | * Balancing during charger *OR* discharge (setting) but not both?! 32 | * No calibrated Nominal Capacity 33 | * Slow response time (2s) 34 | * No custom hysteresis (release threshold) for protection settings 35 | * Sleep Mode and BT not available (https://github.com/fl4p/batmon-ha/issues/42) 36 | * Poor accuracy with low currents 37 | 38 | + Has Cycle counter 39 | + Good current sensor & SoC estimating (ignoring low currents) 40 | 41 | ## JBD BMS 42 | 43 | * Doesn't keep SoC on power loss 44 | * No cycle counter ? 45 | * Buggy SoC? 46 | * Small balancing current 47 | * Balancing during charger OR discharge (setting) but not both?! 48 | * Sometimes detect false short circuits 49 | * Insecure, no proper bluetooth authentication 50 | * Resistance of wires included (red): ~45mOhm 51 | * Make sure to set the "Hardware Overvoltage Protection" and "Hardware undervoltage Protection", otherwise you can 52 | override the protection using the switches in the app 53 | * Over-charge in some rare conditions 54 | * Problems 55 | * Would not recommend 56 | 57 | ## JK BMS 58 | 59 | * [Manual](https://github.com/NEEY-electronic/JK/blob/JK-BMS/JKBMS%20INSTRUCTION.pdf) 60 | * Insecure! built-in Bluetooth, PIN is validated client-side (is publicly readable in device info) 61 | * When UVP is reached the BMS shuts down overnight and needs an activation (i.e. some solar charger (Epever) will not 62 | start) 63 | * Poor current sensor design, "Abnormal current sensor", frequent 64 | interrupts https://diysolarforum.com/threads/jk-abnormal-current-sensor.42795/#post-556556, doesn't capture noisy 65 | current of cheap inverters 66 | * "abnormal current sensor" happens when the superimposed AC current is higher than the DC part, so the total current 67 | wave form crosses zero (e.g. during the day with 30 A DC solar current and an 50 Hz inverter drawing 60A DC+AC on average) 68 | * Weird Bluetooth implementation (Android app doesnt work?, Need to scan & retry on RPI, Apple/iOS app works) 69 | * https://github.com/NEEY-electronic/JK/tree/JK-BMS 70 | * 750 mW stand-by consumption, which is a lot (with 24V battery) 71 | * Current Threshold: charge: 0.4A 72 | * Low BT range, BT antenna covered by metal case (especially with EMI from cheap inverters?) 73 | * Balance Current Positive: SuperCap->Cell_LO (charging the lowest cell from super cap) 74 | * Balance Current Negative: Cell_HI->SuperCap (discharging the highest cell to super cap) 75 | * Value of balance current is inflated 76 | * Not working with batmon: 77 | * JK_B2A24S15P address=C8:47:8C:E8:5C:21 78 | * JK_B2A24S20P 11.XW 11.26 `bytearray(b'U\xaa\xeb\x90\x03sJK_B2A24S20P\x00\x00\x00\x0011.XW\x00\x00\x0011.26\x00\x00\x00\xdc;\x8d\x00\x03\x00\x00\x00JK_B2A24S20P\x00\x00\x00\x001234\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00230219\x00\x002120147127\x000000\x00Input Userdata\x00\x00123456\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00Input Userdata\x00\x00|\xf8\xff\xff\x1f\r\` 79 | * JK_B2A20S20P 11.XW 11.26H `bytearray(b'U\xaa\xeb\x90\x03\xa5JK_B2A20S20P\x00\x00\x00\x0011.XW\x00\x00\x0011.26H\x00\x00<#\x82\x00\x1c\x00\x00\x00JK_B2A20S20P\x00\x00\x00\x001234\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00230425\x00\x003010545162\x000000\x00` 80 | * JK_B2A20S20P 11.XW 11.25 https://github.com/fl4p/batmon-ha/issues/133 81 | * Working 82 | * JK-B2A24S15P works but no the newer (https://github.com/fl4p/batmon-ha/issues/111) 83 | 84 | # ANT BMS 85 | 86 | * Weird SoC computation at certain voltage levels (which doesn't really work) 87 | * Buggy android app 88 | * Good current sensor, proper aliasing filter for inverter current (100 Hz) 89 | 90 | # My Recommandation 91 | 92 | I currently recommend Daly BMS. It has a good current sensor and a cycle counter. 93 | 94 | JK has active balancer, but apart from having a higher balance efficiency, is not very strong. It only balances between 95 | 2 cells at a time, at its duty cycle is about 65%. So a 2A BMS will actually balance with 1.3A, and only between 2 96 | cells. The capacitive balancer works at 60 khZ and produces some EMI. The built-in Bluetooth adapter is insecure ( 97 | everyone can write protection params). 98 | 99 | With JBD I had some serious over-charge issues and it doesn't keep SoC on power-loss. 100 | 101 | ANT BmS SoC is buggy. -------------------------------------------------------------------------------- /doc/Battery Care.md: -------------------------------------------------------------------------------- 1 | * LFP aging has not been fully understood 2 | * Calendar aging is significant 3 | * why are you buying a battery? 4 | * the bigger the better? 5 | * start small! its easy to add another pack in parallel (same chemistry) 6 | * it can be also handy to have a big and a small bat in parallel. you can use the big pack only when needed. 7 | and you can carry the small pack around. 8 | * the worst usage of batteries is not using them 9 | * think of batteries as a precious, limited resource. 10 | * temperature matters a lot. keep the batteries cool 11 | * keep inverters and other heat sources away 12 | * make sure bus bars are properly connected and dont get hot. 13 | * no direct sun light 14 | * stay away from random aliexpress suppliers. this is like playing in a casino. 15 | there are suppliers that sometimes ship good batteries, and when they run out of stock, 16 | they ship a lower grade without further notice. 17 | 18 | 19 | 20 | How to assemble LFP pouch cell battery packs: 21 | https://diysolarforum.com/resources/luyuan-tech-basic-lifepo4-guide.151/ 22 | -------------------------------------------------------------------------------- /doc/Calibration.md: -------------------------------------------------------------------------------- 1 | # 2 | 3 | current_calibration: 1.025 2>1.053 -------------------------------------------------------------------------------- /doc/Downgrade.md: -------------------------------------------------------------------------------- 1 | * Remove the add-on store repository 2 | * Download an old release from https://github.com/fl4p/batmon-ha/tags 3 | * Extract the contents of the ZIP file into Home Assistant's `/addons/batmon-ha` folder (using FTP, Samba Share, etc.). 4 | * The add-on will appear in the add-on store under "Local Addons" (click 'Check for updates') -------------------------------------------------------------------------------- /doc/Groups.md: -------------------------------------------------------------------------------- 1 | *This feature is experimental* 2 | 3 | # Groups 4 | 5 | With battery groups, you can tell batmon that multiple BMSs are connected together. 6 | Batmon will merge readings and switches of the BMSs. 7 | 8 | To create a group, add another device entry in the options. 9 | 10 | ``` 11 | - address: "jk_bms1,jk_bms2" 12 | type: group_parallel 13 | alias: battery_group1 14 | ``` 15 | 16 | Refer to the member BMSs in the `address` property, name/alias separated by `,`. 17 | 18 | Set `type` to `group_parallel`. Serial battery strings are not implemented yet. 19 | 20 | ## Parallel Batteries 21 | 22 | If you have two Batteries in parallel you can use a group to combine SoC & power readings. 23 | Also state changes for charge & discharge switches are jointly applied to all BMS in the group. 24 | 25 | Example: 26 | 27 | ``` 28 | - address: C8:47:8C:E4:55:E5 29 | type: jk 30 | alias: jk_bms1 31 | - address: E8:57:8C:E4:45:34 32 | type: jk 33 | alias: jk_bms2 34 | - address: "jk_bms1,jk_bms2" 35 | type: group_parallel 36 | alias: battery_group1 37 | ``` 38 | 39 | ## Serial Batteries 40 | 41 | Not yet implemented. 42 | 43 | ## BMS Swapping 44 | 45 | You can create a group with a single BMS to ease the process of BMS swapping. 46 | 47 | Imagine you have all your Battery Dashboards and automations set up. 48 | At some point in the future you might want to replace the BMS with a different one, with a different name/alias. 49 | The work of changing all the entity names in Home Assistant can be tedious. 50 | Use a group as a *virtual proxy* BMS to map entities to another *physical* BMS. 51 | -------------------------------------------------------------------------------- /doc/HA Energy Dashboard.md: -------------------------------------------------------------------------------- 1 | # Utility Meter sensor helper 2 | * Go to Settings / Devices / Helpers 3 | * CLick on 'Create Helper' 4 | * choose 'Add Utility Meter' 5 | 6 | * Name : "bat+ energy today" 7 | * Input Sensor: select "total energy charge meter" 8 | * Meter reset cycle: daily 9 | 10 | Submit. -------------------------------------------------------------------------------- /doc/InfluxDB.md: -------------------------------------------------------------------------------- 1 | 2 | batmon can directly write to InfluxDB, without a MQTT broker. 3 | 4 | The influxdb sink writes changed values of each sample. The code is optimized so 5 | it writes minimum data. Write requests to the InfluxDB API are gzipped to further reduce payload. 6 | All values are round to 3 decimal places. 7 | 8 | See [Standalone.md](Standalone.md) for instructions how to run batmon without Home Assistant. 9 | 10 | 11 | add this to the options.json: 12 | ``` 13 | "influxdb_host": "example.com", 14 | "influxdb_username": "", 15 | "influxdb_password": "", 16 | "influxdb_ssl": true, 17 | "influxdb_database": "" 18 | ``` 19 | -------------------------------------------------------------------------------- /doc/LiFePo4.md: -------------------------------------------------------------------------------- 1 | # Aging 2 | [Systematic aging of commercial LiFePO4 |Graphite cylindrical cells including a theory explaining rise of capacity during aging](https://www.sciencedirect.com/science/article/abs/pii/S037877531730143X) 3 | 4 | ## AC Aging (ripple current) 5 | [Aging Effects of Twice Line Frequency Ripple on Lithium Iron Phosphate 6 | (LiFePO4) Batteries](https://sci-hub.se/10.23919/EPE.2019.8915538) 7 | * Ghassemi et al test 13 LFP cells with 100 Hz ripple current 8 | * Bio-Logic SAS VMP-3 potentiostat 9 | * cell group 3 has 0 DC + 300mA AC 10 | * Result: no significant aging 11 | * no need of big DC-link capacitors 12 | 13 | [Impact of Current Ripple on Li-ion Battery Ageing](https://sci-hub.se/10.1109/EVS.2013.6914791) 14 | 15 | 16 | [Impact of high-amplitude alternating current on LiFePO4 battery life performance: Investigation of AC-preheating and microcycling effects](https://www.sciencedirect.com/science/article/abs/pii/S0306261922003592?via%3Dihub) 17 | 18 | -------------------------------------------------------------------------------- /doc/NOTES.md: -------------------------------------------------------------------------------- 1 | ## config.yaml 2 | mqtt add on 3 | * broker is exposed on host `core-mosquitto` 4 | * https://developers.home-assistant.io/docs/add-ons/configuration/#options--schema 5 | * https://github.com/hassio-addons/addon-zwavejs2mqtt/blob/main/zwavejs2mqtt/config.yaml 6 | * https://github.com/hassio-addons/addon-zwavejs2mqtt/blob/d3549ff9d719bee4a770bba038ba3cfbb6bc72aa/zwavejs2mqtt/rootfs/etc/cont-init.d/configuration.sh 7 | 8 | 9 | # RPI Dev 10 | RaspiOS ships with python3 >= 3.9, so you just need to install pip3: 11 | `sudo raspi-config` # set locale 12 | `sudo apt update && sudo apt install -y python3-pip git` 13 | 14 | ``` 15 | cd batmon-ha 16 | pip3 install -r requirements 17 | ``` 18 | 19 | ``` 20 | echo "PATH=$PATH:/home/pi/.local/bin" >> ~/.bashrc 21 | bleak-lescan 22 | ``` 23 | 24 | # Bluetooth Tools 25 | 26 | Scan `bleak-lescan` 27 | ``` 28 | bluetoothctl 29 | connect C8:47:8C:F7:AD:B4 30 | ``` 31 | 32 | Explore BLE Device Services: 33 | ``` 34 | bleak/examples/service_explorer.py 35 | ``` 36 | 37 | 38 | # Mqtt 39 | 40 | * SoC 41 | * Current 42 | * Charge 43 | * Capacity 44 | * Device info 45 | * Switches 46 | * Voltages 47 | 48 | 49 | 50 | # Disk stats 51 | ```cronexp 52 | docker stats 53 | iostat -m 54 | top 55 | sqllite> SELECT * FROM dbstat; 56 | ``` 57 | * disable unused devices 58 | * set recorder settings (purge interval, flush interval) 59 | * call service recorder.disable through auatomatin script (manually triggered) 60 | * disable HA bluetooth integration if not used (batmon doesnt need it as it accesses BLE adapter) 61 | 62 | 63 | # https://developers.home-assistant.io/docs/api/supervisor/endpoints/#service 64 | /core/info 65 | /os/info # data disk 66 | -------------------------------------------------------------------------------- /doc/Solar.md: -------------------------------------------------------------------------------- 1 | * Solar cells resistance has a positive temperature coefficient 2 | * This means that higher temperatures means lower power output 3 | * This is why Wp can barely reached in summer 4 | * Vertically mounted panels have a much better cooling 5 | [src](https://www.pv-magazine.com/2023/11/10/researchers-shed-light-on-mysterious-higher-energy-yields-in-vertical-pv-systems/) 6 | * With vertical mount we can flatten the power curve during the day 7 | * Lower temperatures means longer lifespan 8 | * Vertical Bi-Facial panels 9 | * Vertical panels can collect dew from the air 10 | * 11 | 12 | 13 | -------------------------------------------------------------------------------- /doc/Standalone.md: -------------------------------------------------------------------------------- 1 | # Stand-alone setup 2 | You can run the add-on without Home Assistant and without Docker. 3 | It works on any platform supported by bleak, that currently is: 4 | * Windows 10 or higher 5 | * Linux distributions with BlueZ >= 5.43 6 | * OS X/macOS via Core Bluetooth API, OS X version >= 10.11 7 | 8 | 9 | 10 | Imagine a remote RPI sending MQTT data over WiFi. It is also useful for developing. 11 | You need to have python3 installed. 12 | 13 | ``` 14 | git clone https://github.com/fl4p/batmon-ha 15 | cd batmon-ha 16 | python3 -m venv ./venv 17 | ./venv/bin/pip3 install -r requirements.txt 18 | ``` 19 | 20 | Create `options.json` within the `batmon-ha` directory. Use this as an example and adjust as needed: 21 | ``` 22 | { 23 | "devices": [ 24 | { 25 | "address": "", 26 | "type": "daly", 27 | "alias": "daly1" 28 | }, 29 | { 30 | "address": "", 31 | "type": "jk", 32 | "alias": "jk1" 33 | }, 34 | { 35 | "address": "", 36 | "type": "jbd", 37 | "alias": "jbd1" 38 | }, 39 | { 40 | "address": "", 41 | "type": "victron", 42 | "alias": "victron1", 43 | "pin": "000000" 44 | } 45 | ], 46 | "mqtt_broker": "homeassistant.local", 47 | "mqtt_user": "pv", 48 | "mqtt_password": "Offgrid", 49 | "concurrent_sampling": false, 50 | "keep_alive": true, 51 | "sample_period": 1.0, 52 | "publish_period": 1.0, 53 | "invert_current": false, 54 | "expire_values_after": 20, 55 | "verbose_log": false, 56 | "watchdog": false 57 | } 58 | ``` 59 | 60 | Then start: 61 | ``` 62 | ./venv/bin/python3 main.py 63 | ``` 64 | 65 | If your OS uses systemd, you can use this service file to start batmon on boot (and restart when it crashes): 66 | ``` 67 | [Unit] 68 | Description=Batmon 69 | After=network-online.target 70 | Wants=network-online.target 71 | 72 | [Service] 73 | Type=simple 74 | Restart=always 75 | RestartSec=5s 76 | User=pi 77 | WorkingDirectory=/home/pi/batmon-ha 78 | ExecStart=/home/pi/batmon-ha/venv/bin/python3 main.py 79 | 80 | [Install] 81 | WantedBy=multi-user.target 82 | ``` 83 | 84 | 85 | Place this file to `/etc/systemd/system/batmon.service` and enable to start on boot: 86 | ``` 87 | systemctl enable batmon.service 88 | systemctl start batmon.service 89 | ``` 90 | 91 | Alternatively, you can add batmon to crontab: 92 | 93 | ```shell 94 | crontab -e 95 | ``` 96 | 97 | add this line at the bottom: 98 | ``` 99 | @reboot cd /home/pi/batmon-ha && /home/pi/batmon-ha/venv/bin/python3 main.py 100 | ``` 101 | 102 | 103 | # Docker 104 | Small modifications are needed to run this inside Docker, see https://github.com/fl4p/batmon-ha/issues/25#issuecomment-1400900525 105 | 106 | 107 | 108 | # Minimal options.json 109 | ``` 110 | { 111 | "devices": [ 112 | { 113 | "address": "", 114 | "type": "jk", 115 | "alias": "jk1" 116 | } 117 | ], 118 | "keep_alive": true 119 | } 120 | ``` -------------------------------------------------------------------------------- /doc/Telemetry.md: -------------------------------------------------------------------------------- 1 | # Telemetry 2 | 3 | This is disabled by default. When enabled, batmon will send anonymized battery data to my private influxdb server. 4 | It will help me to develop & test a battery resistance algorithm, you can read more about this in [Impedance.md](dev/Impedance.md). 5 | I highly appreciate your contribution. 6 | 7 | You can optionally share your email with me (only I will be able to see it). Then I can check back with you in case 8 | I need to, which is unlikely. 9 | 10 | The data collection here is for research purposes only. 11 | I am not trying to spy you, or collect for any commercial intent. I'll never sell this data. 12 | I might release a free data set of your anonymized battery data only with your consent. 13 | We live in times of data collection and privacy became scarce. I highly respect your privacy. 14 | 15 | ## Collected Data 16 | * Bat current 17 | * Bat voltage 18 | * cell voltages 19 | * temperatures 20 | * num cycles 21 | * bms model name 22 | * anonymized (through sha1 hash) MAC address 23 | 24 | 25 | When you disable telemetry batmon will stop sending any more data. Samples it has sent will not be deleted automatically. 26 | Please contact me (email address in my github profile) if you want me to delete your data. 27 | 28 | [ 29 | 30 | # HA Analytics 31 | https://analytics.home-assistant.io/custom_integrations.json 32 | https://community.home-assistant.io/t/custom-integration-sonnenbatterie/181781?page=4 33 | https://analytics.home-assistant.io/addons.json 34 | -------------------------------------------------------------------------------- /doc/dev/BT Sniffing.md: -------------------------------------------------------------------------------- 1 | Capturing & Understanding 2 | 3 | # how to add a new bms model or a read-out/function to an existing device 4 | 5 | - google for existing code working with the bms 6 | - contact the manufacturer. they might give you supporting documentations 7 | - Sniff communication while using an existing BMS app. this gives us the query commands sent to the bms and its binary 8 | response 9 | - use the tools/service_explorer.py to find the service characteristic for sending / notification 10 | 11 | # Snooping / Sniffing 12 | 13 | Tools 14 | 15 | * https://github.com/traviswpeters/btsnoop 16 | * Apple PacketLogger 17 | * WireShark 18 | 19 | ## MacOs & iOS (iPad, iPhone) 20 | 21 | Download "[Additional Tools for XCode](https://developer.apple.com/download/all/?q=Additional%20Tools%20for%20Xcode)". 22 | Open `Hardware/PacketLogger.app`. 23 | 24 | To start tracing locally chose File -> "New MacOS trace" 25 | 26 | You can use PacketLogger to log BLE traffic on iOS devices. 27 | Follow this guide https://www.bluetooth.com/blog/a-new-way-to-debug-iosbluetooth-applications/ 28 | You might need an active apple dev subscription ($100/year). 29 | 30 | In PacketLogger, choose File -> "New iOS Trace" 31 | you can export the packets in btsnoop format and load it with this python lib: 32 | https://github.com/traviswpeters/btsnoop 33 | 34 | * https://stackoverflow.com/questions/5863088/bluetooth-sniffer-preferably-mac-osx 35 | 36 | ## Windows & Linux 37 | 38 | Use [WireShark](https://www.wireshark.org/) 39 | 40 | # Understanding the byte stream 41 | 42 | once you can capture communication of the BMS with the app, its time to understand 43 | the data stream. Lets start simple and try to find a boolean state of the BMS, which can either 44 | be represented as a byte (0x00=0b00000000 and 0x01=0b00000001) or a single bit. 45 | 46 | For example, we want to find the state of the discharge switch. So turn the discharge switch 47 | on using the application. Verify that the BMS took the state by restarting the app after changing the switch. 48 | Now capture BT data. Restart the app, go to the menu page where you can control the switch (but leave it as it is!) 49 | Then restart the app and repeat this 3 or more times, while still capturing data. This way we capture similar data 50 | multiple times and we can eliminate those bits whose values do not stay the same. 51 | Then stop the capture, save the recorded data to a file (lets say `dsg_on.btsnoop`). 52 | 53 | Now change the state of the discharge switch off, close the app. 54 | Re-start BT capturing. Doing the same steps as before, starting the app and navigating to the switch without actually 55 | changing it. Store the captured data (`dsg_off.btsnoop`). 56 | 57 | Now we need to parse the messages in both files, see which bits or bytes stay constant within a file, 58 | and those bits that changed in the second file. (usually the byte or bit is 1 when the switch is on) 59 | 60 | To achieve this, we need to group data fragments by an address from the header. 61 | If we need to guess, we can just take the first 4 bytes. 62 | 63 | So we group all data packets starting with the same 4 bytes together, and find the bit 64 | indices of those not changing. Then we do the same with the other file. 65 | 66 | Our bit of interest, representing the state of the switch, is the one that *only* changed between the 67 | two captured files. 68 | 69 | # Decompiling 70 | 71 | To reverse engineer the communication protocol between the BMS and its app you can decompile the app. 72 | https://www.decompiler.com/ -------------------------------------------------------------------------------- /doc/dev/Cycle Logic.md: -------------------------------------------------------------------------------- 1 | *these are my personal notes, maybe you'll find something useful* 2 | 3 | # SoC range 4 | Avoid high SoC cycling, e.g. when only using little energy overnight, the battery would cycle between 100%-95%. 5 | - Keep SoC within lower and upper range. Lower SoC might be normally 0% 6 | 7 | # Threshold switch 8 | Consume excess solar energy e.g. water heater, absorber fridge 9 | Solar power turns on switch. The threshold can depend on SoC 10 | - Higher SoC, lower threshold (SoC 100% -> 0 W threshold) 11 | - Avoid using battery energy 12 | 13 | # Conditional Loads 14 | - If EV is charging, disable other loads 15 | 16 | 17 | # Literature 18 | According to https://www.sciencedirect.com/science/article/abs/pii/S037877531730143X 19 | cycling LiFePo4 between 55 and 45 shows stronger aging. 50% DoD (SoC 75-25) appears to be better. 20 | 21 | https://www.mdpi.com/1996-1073/14/6/1732 22 | -------------------------------------------------------------------------------- /doc/dev/Impedance.md: -------------------------------------------------------------------------------- 1 | *this is a draft and personal notes* 2 | 3 | * see tools/impedance for code 4 | * [tools/impedance/README](../../tools/impedance/README.md) 5 | 6 | 7 | * there is AC and DC cell resistance 8 | * DC resistance is in relaxed state (TI bq chips) 9 | * Lifepo4 can have relexation periods of 6 hours ! 10 | * AC resistance is frequency dependend 11 | * resistance depends on SoC and temperature 12 | * temperature dependence should not be ignored! need to collect temp data 13 | 14 | TI Algorithm Impedance Track 15 | 16 | * [Gauging: Achieving The Successful Learning Cycle](https://www.tij.co.jp/jp/lit/an/slua903/slua903.pdf) 17 | 18 | * [Theory and Implementation of Impedance Track™ Battery 19 | Fuel-Gauging Algorithm in bq20zxx Product Family](https://www.ti.com/lit/an/slua364b/slua364b.pdf?ts=1691485130796) 20 | 21 | 22 | 23 | # Simplified DC Algorithm 24 | * Start Conditions: 25 | * Voltage in range 26 | * Current above threshold 27 | * Conditions met for 500 sec 28 | * wait for current change 29 | * then wait for a couple of seconds for relaxation 30 | 31 | 32 | We try to implement a DC algorithm that considers relaxation. 33 | We can try to model cell relaxation. This will give us even more insights into cell SoH. 34 | Here we try to be keep it as simple as possible and its a first approach. 35 | 36 | For minimu memory footprint (so we possbily can run this on an MCU), we can 37 | use EWMA instead of a look back window buffer of recent values. 38 | 39 | ### OLS (ordinary least squared, regression) 40 | 41 | 42 | ### stddev 43 | Compute Standard deviation of recent U and I readings. 44 | Has poor noise rejection. If U has more noise than I, cell resistance is over-estimated. 45 | Need good filtering to remove individual noise. Common mode (? TODO) noise 46 | can be useful for AC resistance measurement, so might want to use a multi-variate 47 | filter (TODO name? ref?) 48 | 49 | 50 | ### range 51 | -------------------------------------------------------------------------------- /doc/dev/snooping.py: -------------------------------------------------------------------------------- 1 | import btsnoop 2 | 3 | # btsnoop. -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | 2 | bleak==0.20.2 # see Dockerfile 3 | #bleak-retry-connector 4 | 5 | paho-mqtt==2.1.0 6 | backoff 7 | crcmod 8 | 9 | pyserial 10 | 11 | #influxdb # see Dockerfile 12 | # the influxdb package appears to be not available from some platforms, so install it only if needed 13 | # see https://github.com/fl4p/batmon-ha/issues/147 (rpi 3) 14 | -------------------------------------------------------------------------------- /tools/bit_finder.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | samplesA = [ 4 | # off 5 | # bytearray(b'\x01\x01\x01]\x00\x03\xda,'), 6 | # bytearray(b'\x01\x01\x01k\x00\x03\xe2L'), 7 | # bytearray(b'\x01\x01\x01v\x00\x03\xe3P'), 8 | # bytearray(b'\x01\x01\x01\x80\x00\x03\xe3P'), 9 | 10 | # bytearray(b'\x00\x00\x00\x9f\x00\x03\xf7\xa0'), 11 | # bytearray(b'\x01\x01\x01\xc0\x00\x03\xf7\xa0'), 12 | # bytearray(b'\x00\x00\x00\xf7\x00\x03\xf7\xa0'), 13 | # bytearray(b'\x00\x00\x00\x06\x00\x03\xf7\xa0'), 14 | 15 | #bytearray(b'\x01\x13\x00\x00u0\x03\xe8'), 16 | #bytearray(b'\x01\x12\x00\x00u0\x03\xe8'), 17 | #bytearray(b'\x01\x12\x00\x00u0\x03\xe8'), 18 | 19 | #[ D2 03 04 00 00 00 00 18 FE ], 20 | #D203 0400 0000 0018 FE 21 | #D203 0400 0000 0018 FE 22 | #D203 0400 0000 0018 FE 23 | 24 | 25 | """ 26 | on 27 | Nov 12 13:46:21.626 ATT Receive 0x0055 00:00:00:00:00:00 Handle Value Notification - Handle:0x0012 - Value: D203 0414 0306 00EE AE 28 | 29 | Nov 12 13:47:04.511 ATT Receive 0x0055 00:00:00:00:00:00 Handle Value Notification - Handle:0x0012 - Value: D206 00A6 0001 BB8A 30 | 31 | 32 | off 33 | Nov 12 13:46:46.824 ATT Receive 0x0055 00:00:00:00:00:00 Handle Value Notification - Handle:0x0012 - Value: D206 00A6 0000 7A4A 34 | 35 | 36 | 37 | turn dsg on: 38 | Nov 12 13:49:07.061 ATT Send 0x0055 46:64:01:02:04:8E Write Command - Handle:0x0010 - FFF2 - Value: D206 00A6 0001 BB8A 39 | Nov 12 13:49:07.228 ATT Receive 0x0055 46:64:01:02:04:8E Handle Value Notification - Handle:0x0012 - FFF1 - Value: D206 00A6 0001 BB8A 40 | 41 | turn dsg off 42 | Nov 12 13:49:52.712 ATT Send 0x0055 46:64:01:02:04:8E Write Command - Handle:0x0010 - FFF2 - Value: D206 00A6 0000 7A4A 43 | Nov 12 13:49:52.857 ATT Receive 0x0055 46:64:01:02:04:8E Handle Value Notification - Handle:0x0012 - FFF1 - Value: D206 00A6 0000 7A4A 44 | 45 | 46 | 47 | 48 | """ 49 | 50 | 51 | 52 | 53 | ] 54 | 55 | samplesB = [ 56 | # on 57 | # bytearray(b'\x01\x01\x017\x00\x03\xf7\xa0'), 58 | # bytearray(b'\x01\x01\x01E\x00\x03\xf7\xa0'), 59 | # bytearray(b'\x02\x00\x01R\x00\x03\xf7\xa0'), 60 | 61 | # bytearray(b'\x01\x13\x00\x00u0\x03\xe8'), 62 | # bytearray(b'\x01\x11\x00\x00u0\x03\xe8'), 63 | # bytearray(b'\x01\x15\x00\x00ts\x03\xe8'), 64 | 65 | # bytearray(b'\x01\x01\x01\xca\x00\x03\xdd8'), 66 | # bytearray(b'\x01\x01\x01\xf0\x00\x03\xdf@'), 67 | # bytearray(b'\x01\x01\x01\x15\x00\x03\xe0D'), 68 | # bytearray(b'\x01\x01\x01!\x00\x03\xe0D'), 69 | ] 70 | 71 | 72 | def access_bit(data, num): 73 | base = int(num // 8) 74 | shift = int(num % 8) 75 | return (data[base] >> shift) & 0x1 76 | 77 | 78 | bitsA = [[access_bit(s, i) for i in range(len(s) * 8)] for s in samplesA] 79 | bitsB = [[access_bit(s, i) for i in range(len(s) * 8)] for s in samplesB] 80 | 81 | import pandas as pd 82 | 83 | 84 | def find_const_bits(bits: List[List[int]]): 85 | df = pd.DataFrame(bits) 86 | bs = df.sum(axis=0) 87 | return (bs == 0) | (bs == len(bits)) 88 | 89 | 90 | constA = find_const_bits(bitsA) 91 | constB = find_const_bits(bitsB) 92 | 93 | for i, c in constA.items(): 94 | if c and constB[i] and bitsA[0][i] != bitsB[0][i]: 95 | print("bit %d is const in both groups and changes (A=%d, B=%d)" % (i, bitsA[0][i], bitsB[0][i])) 96 | -------------------------------------------------------------------------------- /tools/bt_discovery.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | 4 | from bleak import BleakScanner 5 | 6 | logger = logging.getLogger(__name__) 7 | 8 | async def bt_discovery(): 9 | logger.info('BT Discovery:') 10 | devices = await BleakScanner.discover() 11 | if not devices: 12 | logger.info(' - no devices found - ') 13 | for d in devices: 14 | logger.info("BT Device %s address=%s", d.name, d.address) 15 | return devices 16 | 17 | if __name__ == "__main__": 18 | logging.basicConfig(level=logging.INFO) 19 | asyncio.run(bt_discovery()) 20 | -------------------------------------------------------------------------------- /tools/impedance/README.md: -------------------------------------------------------------------------------- 1 | this is all vip and some notes 2 | 3 | # Battery Impedance Measurement 4 | 5 | With the current and cell voltages from the BMS we can estimate cell resistance. 6 | Impedance is depends on temperature, decreases about 1.5x per 10°C 7 | increase. ([src](https://www.youtube.com/watch?v=_8MzGy_tkEQ&t=69)) 8 | 9 | Theres DC and AC impedance https://batteryuniversity.com/article/bu-902-how-to-measure-internal-resistance 10 | AC is easier to implement than DC, DC requires relaxation considerations. 11 | 12 | `R(f,T,DOD)` 13 | 14 | TI has chips ([`BQ34Z100-R2`](https://www.ti.com/lit/gpn/BQ34Z100-R2)) implemting 15 | their [Impedance Track algorithm](https://www.ti.com/lit/an/slua450a/slua450a.pdf) 16 | [ (learning https://www.tij.co.jp/jp/lit/an/slua903/slua903.pdf) ] 17 | [[2](https://www.ti.com/lit/wp/slpy002/slpy002.pdf)] 18 | [[yt](https://www.youtube.com/watch?v=_8MzGy_tkEQ)] 19 | [[fine tuning](https://www.ti.com/lit/an/slyt402/slyt402.pdf?ts=1695924597934)]. 20 | It computes the DC resistance. 21 | 22 | The algo differentiates three states: charging, discharging and relaxation. 23 | 24 | It constructs a lookup table OCV(DOD,T) which stores relaxed open-circuit voltage depending on DoD and temperature (pg 25 | 5). 26 | 27 | It then computes DC cell resistance `R(DOD) = dV/I` and update the `R(DOD)` table also for higher DoD. 28 | 29 | * Quit Current should not exceed C/20. 30 | 31 | The algorithm is rather complex, updating multiple tables. 32 | 33 | # Our approach 34 | 35 | We simply use moving statistics within a window of recent (U,I) readings. 36 | Only update R when there is a step above threshold 37 | Ideas: 38 | 39 | * using OLS regression in a moving window 40 | * simpler: use std(U)/std(I) in a moving window 41 | * less noise resistant 42 | * OLS with clustering for noise rejection? 43 | * cross correlation (U,I) ? update is O(n) ! 44 | 45 | # std approach 46 | 47 | https://www.ti.com/lit/wp/slpy002/slpy002.pdf 48 | 49 | # Useful Statistical Formulas 50 | 51 | * stddev(x) = pct_change(x)**2 52 | * stddev(x) = sqrt(variance(x)) 53 | * variance(x) = E(xx) - E(x)E(x) 54 | * covariance(x,y) = E(xy) - E(x)*E(y) 55 | * corr(x,y) = cov(x,y) / sqrt(var(x)*var(y)) 56 | * TODO book about bazesian statistics 57 | 58 | Now lets apply this to our cell resistance algorithm. R=dU/dI . 59 | So we use the corr(), as it is normalized, having the same dimensionality as the inputs. 60 | 61 | * cross correlation of dU and dI to find sample time offset 62 | * corr(dU,1/dI) gives us the estimated cell resistance. 63 | * it eliminates any uncorellated noise that is not present in both signals dU,dI 64 | * 65 | 66 | see [Reducing the Noise Floor and Improving the SNR with Cross-Correlation Techniques](https://www.zhinst.com/europe/en/blogs/how-reduce-noise-floor-and-improve-snr-employing-cross-correlation-techniques#Basic%20Principle) 67 | 68 | corr(x,y) = avg(x*y) - avg(x)*avg(y) 69 | corr(x,y) = ( E(xy)-E(x)E(y) ) / sqrt( E(xx)E(yy) - E(xx)E(y)E(y) - E(yy)E(x)E(x) + E(x)E(x)E(y)E(y) ) 70 | 71 | Addionally, we can use cross-correlation, wich basically computes a table of corr(x[t],y[t+n]), for a range of offsets 72 | n. 73 | This is computanaly intense and probably best solved by a FFT convolution. 74 | 75 | # Current Impl State 76 | 77 | * use `imp2.py` to visualize 78 | * block_compute can process large time ranges of years 79 | * cell resistance appears to increase with cell index, 80 | * how to the bms measure single cell voltage? 81 | * do higher cells ge tmore noisy? 82 | * ("2022-01-05", "2022-05-05") 83 | * cell0: using data from daly_bms and jbd_bms both result a median R of 2.8/2.9mOhm. 84 | * dependencies: Temp, SoC 85 | * Relaxation mask 86 | * u0? 87 | 88 | # Input filtering 89 | 90 | We want to remove noise from U and I readings. 91 | The BMS has a current sensors, that samples the current at discrete time points (usuall by measuring a small voltage 92 | drop across a burden/shunt resistors built into the BMS). 93 | If we have a 50 Hz inverter running, it consumes an DC+AC current with the AC part ay 100 Hz. 94 | Some BMS have no or poor aliasing filter (Daly), so we need to average current over time. 95 | A suitable window is ... TODO 96 | This smoothing filter limits our AC bandwith, i.e. the maximum frequency we can sample. 97 | Some induction coockers pulse with a period of 2.5s, which can provide us with useful AC impedance. 98 | However, due to smoothing, this component is eliminated. 99 | For proper AC impedance measurement we need proper alias filtering of the current sensor. 100 | 101 | TODO: a possible fix is to add a capacitor across the differential input of the current sensor on the PCB of the BMS. 102 | The analog filter should have a cut-off frequency of x1 to x10 of the sampling freq. 103 | You need to try until you find a suitable value of C. 104 | 105 | ANT bms appears to have a good alias filtering, even with a strong distorted (non-harmonic, non-sinusoidal) 106 | AC component. JK bms will display an error and set measured current to 0 if it detects a large AC component (althoug 107 | its sensor readouts do not suffer from aliasing). As already mentioned, Daly has aliasing issues (and a quite low 108 | sampling period of ~1.3s). 109 | Still, I experienced the Daly has the best SoC estimate, even with the awful AC current (aliasing noise cancels out 110 | here) 111 | 112 | # TODO, issues 113 | 114 | * examine the way the BMSes measure cell voltage. with increasing cell index, cell resistance appears to increase. 115 | measurement of higher cells might contain more noise (ant24_23_11_12_fry) -------------------------------------------------------------------------------- /tools/impedance/ac_impedance.py: -------------------------------------------------------------------------------- 1 | import math 2 | 3 | import numpy as np 4 | 5 | from tools.impedance import stats 6 | from tools.impedance.stats import cov 7 | 8 | 9 | def estimate(u, i, ignore_nan=False): 10 | assert len(u) == len(i), "lengths do not match" 11 | assert len(u) > 4, "not enough samples" 12 | 13 | i_abs = i.abs() 14 | if i_abs.mean() < 0.1: # or i_abs.max() < 1: 15 | raise ValueError("i too close to 0") 16 | 17 | if u.std() / i.std() > 15: 18 | raise ValueError("variance u/i too high") 19 | 20 | if i.std() / i_abs.mean() < 0.05: 21 | raise ValueError("not enough i variance") 22 | 23 | if u.std() / u.mean() < 0.0005: 24 | raise ValueError("not enough u variance") 25 | 26 | if ignore_nan: 27 | x = i 28 | y = u 29 | 30 | ex = np.nanmean(x) 31 | if math.isnan(ex): 32 | raise ValueError("empty x") 33 | 34 | ey = np.nanmean(y) 35 | if math.isnan(ey): 36 | raise ValueError("empty y") 37 | 38 | sx = np.nanvar(x) 39 | 40 | m = (np.nanmean(np.multiply(x, y)) - ex * ey) / sx 41 | 42 | res = m 43 | u0 = ey - m * ex 44 | 45 | else: 46 | 47 | res, u0 = stats.cov2(i, u) 48 | # res, u0 = cov(i, u) 49 | 50 | return res, u0 51 | -------------------------------------------------------------------------------- /tools/impedance/block_compute.py: -------------------------------------------------------------------------------- 1 | import math 2 | 3 | import matplotlib.pyplot as plt 4 | import numpy as np 5 | import pandas as pd 6 | 7 | import tools.impedance.datasets as datasets 8 | from tools.impedance.data import fetch_batmon_ha_sensors 9 | 10 | cell_results = {} 11 | 12 | num_cells = 8 13 | # df = fetch_batmon_ha_sensors(("2022-01-05", "2022-05-05"), 'daly_bms', num_cells=num_cells, freq="5s") 14 | # df = fetch_batmon_ha_sensors(("2022-10-25", "2022-12-01"), 'daly_bms', num_cells=num_cells, freq="5s") 15 | # df.loc[:, 'i'] *= -1 16 | 17 | freq = '5s' 18 | 19 | #df = datasets.daly22_full(num_cells=num_cells, freq=freq) 20 | 21 | #df = datasets.jbd_full(num_cells=num_cells, freq=freq) 22 | #df = df["2022-11-01":] 23 | 24 | #df = datasets.jk_full(num_cells=num_cells, freq=freq) 25 | 26 | df = datasets.batmon( 27 | # ('2023-11-09T03:30:00Z', '2023-11-09T06:30:00Z'), # fridge 28 | # ('2023-11-08T06:30:00Z', '2023-11-08T09:30:00Z'), # coffee 29 | # ('2023-11-04T06:30:00Z', '2023-11-04T10:30:00Z'), # 3cook 30 | # ('2023-11-09T06:30:00Z', '2023-11-09T08:30:00Z'), # pancakes 31 | # ('2023-11-10T10:30:00Z', '2023-11-10T10:50:00Z'), # ehub test 32 | # ('2023-11-11T12:00:00Z', '2023-11-11T13:00:00Z'), # varing sun 33 | ('2023-11-13T00:00:00Z', '2023-11-13T17:00:00Z'), # recent 34 | freq="1s", device='ant24', cell_index=0, num_cells=num_cells, 35 | ) 36 | #df.loc[:, 'u'] = df.loc[:, str(0)] 37 | df.ffill(limit=1000, inplace=True) 38 | 39 | # df = datasets.batmon( 40 | # ('2023-11-10T16:30:00Z', '2023-11-10T18:10:00Z'), # dalyJKBalNoise 41 | # freq="5s", device='daly_bms', num_cells=num_cells, 42 | # ) 43 | 44 | # filtering (smoothing 45 | # df = df.rolling(5).mean() 46 | df = df.rolling(2).mean() 47 | df = df.rolling(3).mean() 48 | # df = df.rolling(5).mean() 49 | # df = df.rolling(3).mean() 50 | 51 | 52 | # masking 53 | # df = df[df.i.pct_change().abs() > 0.05] 54 | # df = df[df.i.abs() > 1] 55 | df[df.i.abs() < 0.5] = math.nan 56 | 57 | I = df.i 58 | i_mask = ((((I + 0.01).ewm(span=60).mean().pct_change() * 1e2) ** 2).ewm(span=10).mean() < 0.03) \ 59 | # & ((((I + 0.01).pct_change() * 1e2) ** 2).ewm(span=40).mean() < 0.02) 60 | # df = df[i_mask] 61 | # df = df[df.i < -1] 62 | # df.loc[~i_mask, 'i'] = math.nan 63 | 64 | block_size = 1200 65 | # block_size = 300 66 | 67 | df = df.iloc[:len(df) - len(df) % block_size] 68 | 69 | 70 | # cv = df.loc[:, tuple(str(ci) for ci in range(num_cells))] 71 | # df.loc[:, 'cv_max'] = cv.max(axis=1) 72 | # df.loc[:, 'cv_min'] = cv.min(axis=1) 73 | 74 | 75 | def check_constraints(b): 76 | if "u" not in b: 77 | return False 78 | u_max, u_min = b.u.max(), b.u.min() 79 | 80 | if u_max - u_min < 10 or u_max - u_min > 500: 81 | return False 82 | # raise ValueError("u range too small") 83 | 84 | if u_min < 2700: 85 | return False 86 | # raise ValueError("u min too small") 87 | 88 | if u_max > 3500: 89 | return False 90 | # raise ValueError("u max too large") 91 | 92 | return True 93 | 94 | 95 | for ci in range(num_cells): 96 | print('cell', ci) 97 | 98 | # df = datasets.ant24_2023_07(cell_index=ci) 99 | if 0: 100 | df = datasets.batmon( 101 | # ('2023-11-08T10:31:31Z', '2023-11-08T19:50:51Z'), 102 | # ('2023-11-07T11:00:00Z', '2023-11-07T14:50:51Z'), 103 | # ('2023-10-25T06:31:31Z', '2023-11-08T20:50:51Z'), freq="5s", 104 | # ('2023-11-04T06:30:00Z', '2023-11-04T16:30:00Z'),freq='5s', # 3cook 105 | # ('2023-11-09T06:30:00Z', '2023-11-09T08:30:00Z'), freq='5s', # pancakes 106 | ('2023-10-01T06:30:00Z', '2023-11-04T16:30:00Z'), freq='5s', # autumn 107 | device='bat_caravan', cell_index=ci, 108 | ) 109 | 110 | df.loc[:, "u"] = df.loc[:, str(ci)] 111 | 112 | # df = df.iloc[:int(len(df) / 2)] 113 | 114 | # df = df.rolling(5).mean() 115 | # df.dropna(how="any", inplace=True) 116 | 117 | if ci == 0: 118 | fig, ax = plt.subplots(4, 1) 119 | dfr = df.asfreq("15min") 120 | ax[0].step(dfr.index, dfr.u, where='post', label='U', marker='.') 121 | # ax[0].set_xlim((2, 100)) 122 | 123 | ax[1].step(dfr.index, dfr.i, where='post', label='I', marker='.') 124 | 125 | ax[2].step(dfr.index, dfr.soc, where='post', label='soc', marker='.') 126 | ax[2].set_ylim((0, 100)) 127 | 128 | ax[3].step(dfr.index, dfr.temp0, where='post', label='temp0') 129 | ax[3].step(dfr.index, dfr.temp1, where='post', label='temp1') 130 | ax[3].set_ylim((10, 40)) 131 | plt.legend() 132 | 133 | # df.u.plot() 134 | plt.show() 135 | 136 | import tools.impedance.ac_impedance 137 | 138 | # TODO overlapped split 139 | # blocks = np.vsplit(df, int(len(df) / block_size)) 140 | 141 | step = int(block_size / 2) 142 | blocks = [df.iloc[i: i + block_size] for i in range(0, len(df), step)] 143 | 144 | results = [] 145 | ok = 0 146 | 147 | for b in blocks: 148 | t = b.index[-1] 149 | 150 | if not check_constraints(b): 151 | results.append((t, math.nan, math.nan)) 152 | continue 153 | 154 | try: 155 | r, u0 = tools.impedance.ac_impedance.estimate(b.u, b.i, ignore_nan=True) 156 | results.append((t, r, u0)) 157 | ok += 1 158 | except Exception as e: 159 | results.append((t, math.nan, math.nan)) 160 | # print('error %s at block %s' % (e, t)) 161 | pass 162 | 163 | results = pd.DataFrame(results, columns=['time', 'r', 'u0']) 164 | results.set_index("time", inplace=True) 165 | # results.drop(columns="time", inplace=True) 166 | # print(results) 167 | print('cell', ci, "have estimate for %d/%d blocks" % (ok, len(blocks))) 168 | # results.r.plot(label='c%d R(q25)=%.2f' % (ci, results.r.quantile(.25))) 169 | 170 | if ci == 0: 171 | plt.step(results.r.index, results.r.values, where='post', marker='.', alpha=.1, label='R(c%d) raw' % (ci)) 172 | 173 | # fl = int(400) # len(results) / 400) 174 | nday = 3600 * 24 / (pd.to_timedelta(freq).total_seconds() * step) 175 | curve = (results.r 176 | .ffill(limit=4) # int(nday / 4 + 1)) 177 | .rolling(int(nday * 3), min_periods=int(nday * 2)).median() 178 | .rolling(int(nday * 3), min_periods=int(nday * 1)).mean() 179 | ) 180 | curve = curve.ffill() 181 | plt.step(curve.index, 182 | curve.values, where='post', 183 | marker='.', 184 | label='R(c%d) med=%.2f Q25=%.2f' % (ci, results.r.median(), results.r.quantile(.25))) 185 | 186 | cell_results[ci] = results 187 | 188 | # plt.semilogy() 189 | 190 | plt.legend() 191 | plt.ylim((0, 6)) 192 | plt.grid() 193 | plt.show() 194 | 195 | if False: # show hist 196 | for ci, results in cell_results.items(): 197 | plt.hist(results.r, bins=30, range=(results.r.quantile(.2), results.r.quantile(.8)), alpha=0.5, 198 | label='c%i' % ci) 199 | 200 | plt.legend() 201 | plt.grid() 202 | plt.show() 203 | -------------------------------------------------------------------------------- /tools/impedance/data.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | Query1: 4 | ``` 5 | 6 | SELECT value as A FROM "home_assistant"."autogen"."A" 7 | WHERE time >= 1691587662145ms and time <= 1691588646345ms and "entity_id" =~ /.+_soc_current/ group by entity_id 8 | 9 | SELECT value as V FROM "home_assistant"."autogen"."V" 10 | WHERE time >= 1691587662145ms and time <= 1691588646345ms and "entity_id" =~ /bat_caravan_cell_voltages_[0-9]+/) AND t GROUP BY "entity_id" 11 | ``` 12 | 13 | 14 | q2 15 | * with tracking & 50hz inverter noise 16 | * /batmon?orgId=1&from=1694257914567&to=1694258109389 17 | 18 | 19 | 20 | # Local InfluxDB under mac 21 | ``` 22 | brew install influxdb@1 23 | /usr/local/opt/influxdb@1/bin/influxd 24 | 25 | curl -G 'http://localhost:8086/query' --data-urlencode "db=home_assistant" --data-urlencode "q=SELECT * FROM \"V\" " -H "Accept: application/csv" > V.csv 26 | 27 | ``` -------------------------------------------------------------------------------- /tools/impedance/data.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import json 3 | 4 | import influxdb 5 | import pandas as pd 6 | import pytz 7 | from dateutil.tz import tzutc 8 | 9 | from bmslib.cache.disk import disk_cache_deco 10 | 11 | 12 | def to_utc(t, **kwargs) -> pd.Timestamp: 13 | if isinstance(t, pd.Timestamp) and len(kwargs) == 0 and (t.tzinfo == pytz.utc or t.tzinfo == tzutc()): 14 | if t.tzinfo != pytz.utc: 15 | return t.replace(tzinfo=pytz.utc) 16 | else: 17 | return t 18 | t = pd.to_datetime(t, **kwargs) 19 | return t.tz_localize('UTC') if t.tzinfo is None else t.tz_convert('UTC') 20 | 21 | 22 | def ql_time_range(time_range, freq=None): 23 | time_range = list(map(to_utc, time_range)) 24 | if freq is not None: 25 | time_range = list(map(lambda t: t - pd.to_timedelta(freq), time_range)) 26 | assert time_range[0] <= time_range[1] 27 | return " (time >= '%s' and time < '%s') " % (time_range[0].isoformat(), time_range[1].isoformat()) 28 | 29 | # noinspection SqlDialectInspection 30 | 31 | 32 | @disk_cache_deco() 33 | def fetch_influxdb_ha(measurement, time_range, entity_id, freq=None): 34 | with open('influxdb_ha.json') as fp: 35 | influxdb_client = influxdb.InfluxDBClient(**{k[9:]: v for k, v in json.load(fp).items()}) 36 | 37 | # print(influxdb_client.get_list_measurements()) 38 | 39 | q = """ 40 | SELECT %(agg)s(value) as v FROM "home_assistant"."autogen"."%(measurement)s" 41 | WHERE %(tr)s and "entity_id" = '%(entity_id)s' 42 | %(group_by)s 43 | """ % (dict( 44 | agg='mean' if freq else '', 45 | group_by=f"GROUP BY time({freq})" if freq else "", 46 | measurement=measurement, 47 | tr=ql_time_range(time_range), 48 | entity_id=entity_id, 49 | 50 | )) 51 | print(q.replace('\n', ' ').strip(), '...') 52 | r = influxdb_client.query(q) 53 | 54 | points = r.get_points() # tags=dict(entity_id=entity_id)) 55 | points = pd.DataFrame(points) 56 | if points.empty: 57 | return pd.DataFrame(dict(v=[]), index=pd.DatetimeIndex([], tz=datetime.timezone.utc)) 58 | points.set_index(pd.DatetimeIndex(points.time), inplace=True) 59 | points.drop(columns='time', inplace=True) 60 | 61 | return points 62 | 63 | 64 | def fetch_batmon_ha_sensors(tr, alias, num_cells, freq='1s'): 65 | # f = None 66 | f = freq 67 | i = fetch_influxdb_ha("A", tr, alias + "_soc_current", freq=f ).v.rename('i') 68 | soc = fetch_influxdb_ha("%", tr, alias + "_soc_soc_percent", freq=f).v.rename('soc') 69 | temp1 = fetch_influxdb_ha("°C", tr, alias + "_temperatures_1", freq=f).v.rename('temp0') 70 | temp2 = fetch_influxdb_ha("°C", tr, alias + "_temperatures_2", freq=f).v.rename('temp1') 71 | u = [ 72 | fetch_influxdb_ha("V", tr, alias + "_cell_voltages_%i" % (1 + ci), freq=f).v.rename(str(ci)) * 1e3 # rename(dict(v=ci)) 73 | for ci in range(num_cells) 74 | ] 75 | print("joining..") 76 | 77 | 78 | um = pd.concat([i, soc, temp1, temp2] + u, axis=1).resample(freq).mean() 79 | um.loc[:, "temp0"].ffill(limit=1000, inplace=True) 80 | um.loc[:, "temp1"].ffill(limit=1000, inplace=True) 81 | um.ffill(limit=200, inplace=True) 82 | um = um[~um.i.isna()].dropna(how="all") 83 | return um 84 | 85 | 86 | # fetch_influxdb_ha(['2022-02-01','2022-03-01']) 87 | 88 | 89 | if __name__ == "__main__": 90 | fetch_batmon_ha_sensors(("2022-01-05", "2022-02-05"), 'daly_bms', num_cells=8) 91 | -------------------------------------------------------------------------------- /tools/impedance/datasets.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import influxdb 4 | import pandas as pd 5 | 6 | from bmslib.cache.disk import disk_cache_deco 7 | from tools.impedance.data import ql_time_range, fetch_batmon_ha_sensors 8 | 9 | _influxdb_client = None 10 | 11 | 12 | def influxdb_client(): 13 | global _influxdb_client 14 | if not _influxdb_client: 15 | with open('influxdb_server.json') as fp: 16 | _influxdb_client = influxdb.InfluxDBClient(**{k[9:]: v for k, v in json.load(fp).items()}) 17 | return _influxdb_client 18 | 19 | 20 | def ant24_2023_07(cell_index=2): 21 | r = influxdb_client().query(""" 22 | SELECT mean(voltage_cell%03i) as u, mean(current) as i FROM "autogen"."batmon" 23 | WHERE time >= 1694527270s and time <= 1694551907s 24 | GROUP BY time(1s), "device"::tag, "cell_index"::tag fill(null) 25 | """ % cell_index) 26 | 27 | points = r.get_points(tags=dict(device='ant24')) 28 | points = pd.DataFrame(points) 29 | points.u.ffill(limit=20, inplace=True) 30 | points.i.ffill(limit=200, inplace=True) 31 | points.set_index(pd.DatetimeIndex(points.time), inplace=True) 32 | points.drop(columns='time', inplace=True) 33 | return points 34 | 35 | 36 | def batmon(tr, device="bat_caravan", cell_index=0, num_cells=1, freq="1s"): 37 | select_cells = ", ".join('mean(voltage_cell%03i) as "%i"' % (ci, ci) for ci in range(cell_index, cell_index+num_cells)) 38 | q = """ 39 | SELECT 40 | %s, 41 | mean(current) as i, 42 | mean(soc) as soc, 43 | mean(temperatures_0) as temp0, 44 | mean(temperatures_1) as temp1 45 | FROM "autogen"."batmon" 46 | WHERE %s and device = '%s' 47 | GROUP BY time(%s), "device"::tag, "cell_index"::tag fill(null) 48 | """ % (select_cells, ql_time_range(tr), device, freq) 49 | r = influxdb_client().query(q) 50 | 51 | points = r.get_points(tags=dict(device=device)) 52 | points = pd.DataFrame(points) 53 | assert not points.empty 54 | for ci in range(cell_index, num_cells): 55 | points.loc[:, str(ci)] = points.loc[:, str(ci)].ffill(limit=200, inplace=False) 56 | points.i.ffill(limit=200, inplace=True) 57 | points.soc.ffill(limit=200, inplace=True) 58 | points.temp0.ffill(limit=200, inplace=True) 59 | points.temp1.ffill(limit=200, inplace=True) 60 | points.set_index(pd.DatetimeIndex(points.time), inplace=True) 61 | points.drop(columns='time', inplace=True) 62 | dn = points.dropna(axis=1, how='all').dropna(how="any") 63 | points = points.loc[dn.first_valid_index():dn.last_valid_index(), :] 64 | return points[points.i.first_valid_index():] 65 | 66 | 67 | @disk_cache_deco() 68 | def daly22(num_cells, freq): 69 | """ 70 | this is the first data i collected with 280ah lifepo4 from aliexpress 71 | 72 | note#1: there is more data later the year (after a gap) 73 | note#2: jbd22 is connected to the same battery (after daly bms) 74 | 75 | :param num_cells: 76 | :param freq: 77 | :return: 78 | """ 79 | df = fetch_batmon_ha_sensors(("2022-01-05", "2022-05-05"), 'daly_bms', num_cells=num_cells, freq=freq) 80 | df.loc[:, 'i'] *= -1 81 | return df 82 | 83 | 84 | @disk_cache_deco() 85 | def daly22_1(num_cells, freq): 86 | """ 87 | this is the first data i collected with 280ah lifepo4 from aliexpress 88 | 89 | note#1: there is more data later the year (after a gap) 90 | note#2: jbd22 is connected to the same battery (after daly bms) 91 | 92 | :param num_cells: 93 | :param freq: 94 | :return: 95 | """ 96 | df = fetch_batmon_ha_sensors(("2022-01-05", "2022-05-05"), 'daly_bms', num_cells=num_cells, freq=freq) 97 | df.loc[:, 'i'] *= -1 98 | return df 99 | 100 | 101 | @disk_cache_deco() 102 | def daly22_full(num_cells, freq): 103 | df = fetch_batmon_ha_sensors(("2022-01-05", "2022-12-01"), 'daly_bms', num_cells=num_cells, freq=freq) 104 | df.loc[:, 'i'] *= -1 105 | return df 106 | 107 | 108 | @disk_cache_deco() 109 | def daly22_idle(num_cells, freq): 110 | df = fetch_batmon_ha_sensors(("2022-06-05", "2022-11-01"), 'daly_bms', num_cells=num_cells, freq=freq) 111 | df.loc[:, 'i'] *= -1 112 | return df 113 | 114 | 115 | @disk_cache_deco() 116 | def jbd22(num_cells, freq): 117 | df = fetch_batmon_ha_sensors(("2022-01-05", "2022-05-05"), 'jbd_bms', num_cells=num_cells, freq=freq) 118 | df.loc[:, 'i'] *= -1 119 | return df 120 | 121 | 122 | @disk_cache_deco() 123 | def jbd_full(num_cells, freq): 124 | df = fetch_batmon_ha_sensors(("2022-01-05", "2023-04-17"), 'jbd_bms', num_cells=num_cells, freq=freq) 125 | df.loc[:, 'i'] *= -1 126 | return df 127 | 128 | 129 | @disk_cache_deco() 130 | def jbd22_full(num_cells, freq): 131 | df = fetch_batmon_ha_sensors(("2022-01-05", "2022-12-01"), 'jbd_bms', num_cells=num_cells, freq=freq) 132 | df.loc[:, 'i'] *= -1 133 | return df 134 | 135 | 136 | @disk_cache_deco() 137 | def jk_full(num_cells, freq): 138 | df = fetch_batmon_ha_sensors(("2023-04-12", "2023-10-26"), 'jk_bms', num_cells=num_cells, freq=freq) 139 | df.loc[:"2023-06-29T14:00:00Z", 'i'] *= -1 # here we changed the settings (invert_current) 140 | return df 141 | 142 | 143 | # @disk_cache_deco() 144 | def ant24_23_11_12_fry(num_cells, freq='1s', **kwargs): 145 | """ AC load (pulsed induction hob) various periods 5 - 10s """ 146 | df = batmon(tr=('2023-11-12 14:56:42', '2023-11-12 15:26:37'), device="ant24", num_cells=num_cells, freq=freq, **kwargs) 147 | df = df.ffill(limit=1800) 148 | dn = df.dropna(axis=1, how='all').dropna(how="any") 149 | df = df.loc[dn.first_valid_index():dn.last_valid_index(), :] 150 | return df 151 | 152 | 153 | def ant24_23_11_12_dc(num_cells, freq='1s', **kwargs): 154 | """ Some DC load changes +-25A """ 155 | # https://h.fabi.me/grafana/d/f3466d95-2c89-43ee-b9dd-3e722d26fcbd/batmon?orgId=1&var-device_name=daly_bms&from=1699803467980&to=1699803759112 156 | df = batmon(tr=('2023-11-12 15:37:47', '2023-11-12 15:42:39'), device="ant24", num_cells=num_cells, freq=freq, **kwargs) 157 | return df 158 | 159 | 160 | def ant24_23_11_11_fridge(freq='1s', **kwargs): 161 | # https://h.fabi.me/grafana/d/f3466d95-2c89-43ee-b9dd-3e722d26fcbd/batmon?orgId=1&var-device_name=daly_bms&from=1699732460000&to=1699772063942 162 | df = batmon(tr=('2023-11-11 19:54:20', '2023-11-12 06:54:23'), device="ant24", freq=freq, **kwargs) 163 | df = df.ffill(limit=1800) 164 | dn = df.dropna(axis=1, how='all').dropna(how="any") 165 | df = df.loc[dn.first_valid_index():dn.last_valid_index(), :] 166 | return df 167 | 168 | 169 | def ant24_23_11_21_pulse_coffee(): 170 | pass 171 | # 2023-11-21 08:00:12 172 | # 2023-11-21 08:15:12 173 | # 174 | 175 | 176 | # def ant24_and daly() 177 | # tesla charging using edecoa 3500w 8A-> 5A 178 | # 2023-11-23 17:00:00 179 | # 2023-11-23 18:00:00 -------------------------------------------------------------------------------- /tools/impedance/energy.py: -------------------------------------------------------------------------------- 1 | import math 2 | 3 | import pandas as pd 4 | from matplotlib import pyplot as plt 5 | 6 | import tools.impedance.datasets as datasets 7 | 8 | num_cells = 8 9 | #df = datasets.daly22_1(num_cells=num_cells, freq='5s') 10 | df = datasets.batmon(("2023-11-13", "2023-11-18"), 'daly_bms', num_cells=num_cells, freq='5s') 11 | 12 | df.loc[:,'i'] = df.loc[:,'i'].fillna(0) 13 | #df = datasets.jbd22(num_cells=num_cells, freq='5s') 14 | #df = df.iloc[18000:25000] 15 | 16 | 17 | cv = df.loc[:, tuple(str(ci) for ci in range(num_cells))] 18 | cf_filt = cv.rolling(3).median() 19 | is_empty = cf_filt.min(axis=1) < 2700 20 | is_full = cf_filt.max(axis=1) > 3450 21 | ef = pd.concat([is_empty, is_full], axis=1) 22 | 23 | q = df.i.fillna(0).cumsum() * 5 / 3600 24 | q.asfreq("15min").plot() 25 | 26 | plt.figure() 27 | df.soc.ffill().diff().cumsum().asfreq("15min").plot() 28 | 29 | df.loc[:,'soc'] = df.soc.ffill() 30 | 31 | #plt.figure() 32 | #cv.asfreq("15min").plot() 33 | plt.show() 34 | 35 | soc = -1 36 | t_full = df.index[0] 37 | q_full = math.nan 38 | ci_full = -1 39 | t_empty = df.index[0] 40 | q_empty = math.nan 41 | ci_empty = -1 42 | 43 | for (t, e, f) in ef.itertuples(): 44 | if e and f: 45 | # print('empty and full', t, df.loc[t]) 46 | continue 47 | # break 48 | if e and cv.loc[t].min() < 2000: 49 | # open sense wire 50 | continue 51 | if e: 52 | if soc: 53 | print(t, cv.loc[t].min(), cv.loc[t].argmin(), df.soc.loc[t], "empty after", t - t_full, 54 | 'q=%.2f Ah' % (q.loc[t] - q_full), 55 | 'C=%.2f Ah' % (-float(q.loc[t] - q_full) * 100. / (100. - float(df.soc.loc[t]))), 56 | ) 57 | t_empty = t 58 | q_empty = q.loc[t] 59 | ci_empty = cv.loc[t].argmin() 60 | if ci_empty == ci_full: 61 | print('balanced, weak cell', ci_empty) 62 | soc = 0 63 | 64 | if f: 65 | if soc < 100: 66 | print(t, cv.loc[t].max(),cv.loc[t].argmax(), df.soc.loc[t], "full after", t - t_empty, 'q=%.2f Ah' % (q.loc[t] - q_empty)) 67 | t_full = t 68 | q_full = q.loc[t] 69 | ci_full = cv.loc[t].argmax() 70 | if ci_empty == ci_full: 71 | print('balanced, weak cell', ci_full) 72 | soc = 100 73 | -------------------------------------------------------------------------------- /tools/impedance/imp.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import matplotlib 4 | import matplotlib.pyplot as plt 5 | import numpy as np 6 | import pandas as pd 7 | 8 | from bmslib.pwmath import EWM 9 | 10 | if False: # read from csv files 11 | df = pd.read_csv('mppt_scan_I.csv') 12 | I = pd.Series(df.A.values, index=pd.DatetimeIndex(df.time.values)) 13 | 14 | df = pd.read_csv('mppt_scan_V.csv') 15 | U = df.pivot_table(values='V', index=pd.DatetimeIndex(df.time.values), columns='entity_id') 16 | U = U['bat_caravan_cell_voltages_1'].ffill(limit=20) 17 | 18 | else: # query influxdb (configuration influxdb_server.json) 19 | import influxdb 20 | 21 | with open('influxdb_server.json') as fp: 22 | influxdb_client = influxdb.InfluxDBClient(**{k[9:]: v for k, v in json.load(fp).items()}) 23 | 24 | # r = influxdb_client.query(""" 25 | # SELECT mean("voltage") as u, mean(current) as i FROM "autogen"."cells" 26 | # WHERE cell_index = '2' and time >= 1694703222983ms and time <= 1694708766302ms 27 | # GROUP BY time(3s), "device"::tag, "cell_index"::tag fill(null) 28 | # """) 29 | # WHERE time >= 1694703222983ms and time <= 1694708766302ms 30 | # WHERE time >= 1694527270071ms and time <= 1694551907278ms 31 | # WHERE time >= 1694703222983ms and time <= 1694708766302ms 32 | 33 | r = influxdb_client.query(""" 34 | SELECT mean(voltage_cell002) as u, mean(current) as i FROM "autogen"."batmon" 35 | WHERE time >= 1694527270071ms and time <= 1694551907278ms 36 | GROUP BY time(1s), "device"::tag, "cell_index"::tag fill(null) 37 | """) 38 | 39 | # r = influxdb_client.query(""" 40 | # SELECT mean(voltage_cell002) as u, mean(current) as i FROM "autogen"."batmon" 41 | # WHERE time >= '2023-11-07T20' and time <= 1694551907278ms 42 | # GROUP BY time(1s), "device"::tag, "cell_index"::tag fill(null) 43 | # """) 44 | 45 | # &from=1693810297203&to=1693852625487 46 | # https://h.fabi.me/grafana/d/f3466d95-2c89-43ee-b9dd-3e722d26fcbd/batmon?orgId=1&from=1693810297203&to=1693852625487 47 | 48 | points = r.get_points(tags=dict(device='ant24')) 49 | points = pd.DataFrame(points) 50 | U = pd.Series(points.u.values, index=pd.DatetimeIndex(points.time)).ffill(limit=20) * 1e-3 51 | I = pd.Series(points.i.values, index=pd.DatetimeIndex(points.time)).ffill(limit=200) 52 | print(U) 53 | # from=1694703222983 & to = 1694708766302 54 | 55 | matplotlib.use('MacOSX') 56 | 57 | # noise filter 58 | U = U.rolling(20).median() 59 | U = U.rolling('8s').mean() 60 | U = U.rolling('20s').mean() 61 | I = I.rolling('8s').mean() 62 | 63 | u_mask = (((U.ewm(span=60 * 5).mean().pct_change() * 1e4) ** 2).ewm(span=40).mean() < 0.2) \ 64 | & (((U.pct_change() * 1e4) ** 2).ewm(span=40).mean() < 0.2) 65 | 66 | i_mask = ((((I + 0.01).ewm(span=60 * 5).mean().pct_change() * 1e2) ** 2).ewm(span=40).mean() < 0.02) \ 67 | & ((((I + 0.01).pct_change() * 1e2) ** 2).ewm(span=40).mean() < 0.02) 68 | 69 | 70 | def normalize_std(s): 71 | return (s - s.mean()) / s.std() 72 | 73 | 74 | fig, ax = plt.subplots(2, 1) 75 | 76 | ax[0].plot(normalize_std(I), label='I') 77 | ax[0].plot(normalize_std(U), label='U') 78 | ax[0].plot(normalize_std(U)[u_mask & i_mask], label='U_masked', linewidth=0, marker='.') 79 | 80 | di = I - I.mean() 81 | # ax[0].plot(normalize_std(I)[abs(di.rolling('10s').max() - di.rolling('10s').min()) < 4]) 82 | 83 | # ax[0].plot(normalize_std(I), label='U') 84 | 85 | ax[0].legend() 86 | # ax[0].title('normalized') 87 | 88 | # ax[1].plot(I, label='I') 89 | # ax[1].plot(I.rolling(20).mean(), label='sma20') 90 | # ax[1].plot(abs(di.rolling('5min').max() - di.rolling('5min').min()), label='mask') 91 | # ax[1].legend() 92 | 93 | # ax[2].plot(U, label='U') 94 | 95 | df = pd.concat(dict(u=U - U.mean(), i=I - I.mean()), axis=1).ffill(limit=20).dropna(how='any') 96 | # relaxation: exclude areas where recent current range is above threshold 97 | # df = df[abs(df.i.rolling('10s').max() - df.i.rolling('10s').min()) < 4] 98 | df = df[i_mask & u_mask] 99 | 100 | # relaxation: exclude areas where recent current is near total average 101 | # df = df[abs(df.i) > 2] 102 | """ 103 | the previous line is a fix for the std/std pseudo-regression 104 | if U has still noise after filtering, the resistance estimate is too high 105 | need to do some sort of clustering 106 | """ 107 | 108 | x = df.i.values 109 | y = df.u.values * 1000 110 | 111 | try: 112 | ax[1].scatter(x=x, y=y, marker='x') 113 | ax[1].scatter(x=[x.mean()], y=[y.mean()], marker='x') 114 | except Exception as e: 115 | print(e) 116 | # plt.scatter(x=, y=) 117 | 118 | 119 | A = np.vstack([x, np.ones(len(x))]).T 120 | m, c = np.linalg.lstsq(A, y, rcond=None)[0] 121 | r2 = 1 - c / (y.size * y.var()) 122 | plt.plot(x, m * x + c, 'r', label='ols %.2f mOhm (r2 %.5f minmax %.2f)' % ( 123 | m, r2, (U.max() - U.min()) / (I.max() - I.min()) * 1e3)) 124 | 125 | plt.plot(x, np.std(y) / np.std(x) * x + c, 'b', label='std %.2f' % (np.std(y) / np.std(x))) 126 | 127 | df = pd.concat(dict(u=U, i=1 / -I), axis=1).ffill(limit=20).dropna(how='any') 128 | # relaxation: exclude areas where recent current range is above threshold 129 | # df = df[abs(df.i.rolling('10s').max() - df.i.rolling('10s').min()) < 4] 130 | df = df[i_mask & u_mask] 131 | corr = df[I.abs() > 2].corr().iloc[0, 1] 132 | cov = df[I.abs() > 2].cov().iloc[0, 1] * 1000 133 | plt.plot(x, cov * x + c, 'b', label='cov %.2f (corr %.3f)' % (cov, corr,)) 134 | 135 | plt.legend() 136 | 137 | plt.show() 138 | 139 | 140 | # plt.figure() 141 | # df = df[abs(df.i) > 2] 142 | # x=df.i 143 | # y=df.u 144 | # plt.plot(df.u) 145 | # plt.plot(y/x) 146 | # (y/x).plot() 147 | # plt.show() 148 | 149 | 150 | # m, c = np.linalg.lstsq(A, y, rcond=None)[0] 151 | 152 | 153 | class BatteryResistanceTrackerParams(): 154 | def __init__(self): 155 | self.transient_time = 10 # 500 156 | self.chg_current_threshold = 5 157 | 158 | 159 | 160 | 161 | class BatteryResistanceTracker(): 162 | params: BatteryResistanceTrackerParams 163 | 164 | def __init__(self, params: BatteryResistanceTrackerParams): 165 | self.params = params 166 | 167 | self.stats_short = EWM(20, std_regularisation=1e-6) 168 | self.stats_long = EWM(400, std_regularisation=1e-6) 169 | 170 | self.charging_for_sec = 0 171 | 172 | def update(self, dt: float, u: float, i: float): 173 | 174 | self.stats_short.add(i) 175 | self.stats_long.add(i) 176 | 177 | if i > self.params.chg_current_threshold: 178 | self.charging_for_sec += dt 179 | else: 180 | pass # self.c 181 | 182 | pass 183 | -------------------------------------------------------------------------------- /tools/impedance/imp2.py: -------------------------------------------------------------------------------- 1 | import matplotlib 2 | import matplotlib.pyplot as plt 3 | 4 | import tools.impedance.datasets as datasets 5 | from tools.impedance.data import fetch_batmon_ha_sensors 6 | from tools.impedance.stats import cov 7 | 8 | # df = datasets.ant24_2023_07() 9 | 10 | if 0: 11 | cell_index = 7 12 | df = datasets.ant24_23_11_12_fry(freq="1s", num_cells=1, cell_index=cell_index) 13 | df.loc[:, 'u'] = df.loc[:, str(cell_index)] 14 | df = df.loc[df.u.first_valid_index():] 15 | 16 | # df = datasets.ant24_23_11_11_fridge(num_cells=1, freq="1s", cell_index=1) 17 | 18 | elif 1: 19 | df = datasets.batmon( 20 | # ('2023-11-09T03:30:00Z', '2023-11-09T06:30:00Z'), # fridge 21 | # ('2023-11-08T06:30:00Z', '2023-11-08T09:30:00Z'), # coffee 22 | # ('2023-11-04T06:30:00Z', '2023-11-04T10:30:00Z'), # 3cook 23 | # ('2023-11-09T06:30:00Z', '2023-11-09T08:30:00Z'), # pancakes 24 | # ('2023-11-10T10:30:00Z', '2023-11-10T10:50:00Z'), # ehub test 25 | # ('2023-11-11T12:00:00Z', '2023-11-11T13:00:00Z'), # varing sun 26 | ('2023-11-13T12:00:00Z', '2023-11-13T18:30:00Z'), # recent 27 | freq="1s", device='ant24', cell_index=0, 28 | ) 29 | df.loc[:, 'u'] = df.loc[:, str(0)] 30 | df.ffill(limit=1000, inplace=True) 31 | 32 | 33 | 34 | else: 35 | df = datasets.batmon( 36 | ('2023-11-10T16:30:00Z', '2023-11-10T18:10:00Z'), # dalyJKBalNoise 37 | freq="5s", device='daly_bms', num_cells=1, 38 | ) 39 | 40 | if 0: 41 | df = fetch_batmon_ha_sensors(("2022-01-23", "2022-01-25"), 'daly_bms', num_cells=1) 42 | df.loc[:, "u"] = df.loc[:, str(0)] 43 | df.loc[:, 'i'] *= -1 44 | df.loc[:, 'temp1'] = df.temp0 45 | # df = df.iloc[9000:16000] 46 | # df = df[df.i.abs() > 1] 47 | df = df.rolling(2).mean() 48 | df = df.rolling(3).mean().iloc[5:] 49 | # df = df[df.i.pct_change().abs() > 0.1] 50 | df = df[(df.u < 3330) & (df.u > 3300)] 51 | # df.dropna(how="any", inplace=True) 52 | 53 | matplotlib.use('MacOSX') 54 | fig, ax = plt.subplots(4, 1) 55 | ax[0].step(df.index, df.u, where='post', label='U', marker='.') 56 | ax[1].step(df.index, df.i, where='post', label='I', marker='.') 57 | ax[2].step(df.index, df.soc, where='post', label='soc', marker='.') 58 | ax[3].step(df.index, df.temp0, where='post', label='temp0', marker='.') 59 | ax[3].step(df.index, df.temp1, where='post', label='temp1', marker='.') 60 | plt.legend() 61 | 62 | # plt.show() 63 | 64 | # df.loc[:,'i'] = df.i.pct_change() * abs(df.i).mean() 65 | # df.loc[:,'u'] = df.u.pct_change() * df.u.mean() 66 | # df = df.iloc[1:] 67 | 68 | # plt.figure() 69 | fig, ax = plt.subplots(1, 1) 70 | try: 71 | ax.scatter(x=df.i, y=df.u, marker='.', s=1) 72 | ax.scatter(x=[df.i.mean()], y=[df.u.mean()], marker='x') 73 | except Exception as e: 74 | print(e) 75 | 76 | print('std i=%.2f u=%.2f u/i=%.1f' % (df.i.std(), df.u.std(), df.u.std() / df.i.std())) 77 | 78 | m, u0 = cov(df.i, df.u) 79 | 80 | df_nzi = df[df.i.abs() > 1] 81 | df_nzi.loc[:, 'i'] = 1 / df_nzi.i 82 | corr = df_nzi.corr().iloc[0, 1] 83 | 84 | ax.plot(df.i, m * df.i + u0, 'r', label='ols %.2f mOhm (u0=%.1f, corr %.3f)' % (m, u0, corr)) 85 | ax.plot(df.i, m * df.i + u0 + df.u.std(), '-.r', label=None, alpha=.3) 86 | ax.plot(df.i, m * df.i + u0 - df.u.std(), '-.r', label=None, alpha=.3) 87 | plt.grid() 88 | plt.legend() 89 | plt.title("nSamples %d" % len(df.u)) 90 | 91 | plt.show() 92 | -------------------------------------------------------------------------------- /tools/impedance/requirements.txt: -------------------------------------------------------------------------------- 1 | matplotlib 2 | pandas -------------------------------------------------------------------------------- /tools/impedance/stats.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | 3 | 4 | def normalize_std(s): 5 | return (s - s.mean()) / s.std() 6 | 7 | 8 | def ols(x, y=None): 9 | if y is None: 10 | y = x.iloc[:, 1] 11 | x = x.iloc[:, 0] 12 | A = np.vstack([x, np.ones(len(x))]).T 13 | m, y0 = np.linalg.lstsq(A, y, rcond=None)[0] 14 | return m, y0 15 | 16 | 17 | def cov(x, y=None): 18 | if y is None: 19 | y = x.iloc[:, 1] 20 | x = x.iloc[:, 0] 21 | if len(x) == 0: 22 | raise ValueError('empty x') 23 | (n, m), (m2, v) = np.cov(x, y) 24 | # assert _1 == 1, "%s != 1 %s" % (_1, np.cov(x, y)) 25 | assert m == m2, "m m2 %s" % m 26 | assert v, "v" # todo 27 | return m / n, np.mean(y) - m / n * np.mean(x) 28 | 29 | 30 | def cov2(x, y=None): 31 | if y is None: 32 | y = x.iloc[:, 1] 33 | x = x.iloc[:, 0] 34 | if len(x) == 0: 35 | raise ValueError('empty x') 36 | ex = np.mean(x) 37 | ey = np.mean(y) 38 | m = (np.multiply(x,y).mean() - ex * ey) / np.var(x) 39 | #m = np.mean(np.subtract(x, ex) * np.subtract(y, ey)) / np.var(x) # this is a bit slower 40 | return m, ey - m * ex 41 | 42 | def cov2_nans(x, y=None): 43 | if y is None: 44 | y = x.iloc[:, 1] 45 | x = x.iloc[:, 0] 46 | if len(x) == 0: 47 | raise ValueError('empty x') 48 | ex = np.nanmean(x ) 49 | ey = np.nanmean(y) 50 | m = (np.nanmean(np.multiply(x,y)) - ex * ey) / np.nanvar(x) 51 | #m = np.mean(np.subtract(x, ex) * np.subtract(y, ey)) / np.var(x) # this is a bit slower 52 | return m, ey - m * ex 53 | 54 | -------------------------------------------------------------------------------- /tools/impedance/test.py: -------------------------------------------------------------------------------- 1 | import math 2 | 3 | import numpy as np 4 | 5 | import stats 6 | 7 | 8 | def near(a, b, e, reg=1e-6): 9 | if isinstance(a, tuple): 10 | assert len(a) == len(b) 11 | for i in range(len(a)): 12 | if not near(a[i], b[i], e): 13 | return False 14 | return True 15 | 16 | re = abs((abs(a) + reg) / (abs(b) + reg) - 1) 17 | return re < e 18 | 19 | 20 | def test_reg_impl(x, y): 21 | r_ols = stats.ols(x, y) # reference 22 | r_cov = stats.cov(x, y) 23 | r_cov2 = stats.cov2(x, y) 24 | r_cov3 = stats.cov2_nans(x, y) 25 | 26 | assert near(r_cov, r_ols, 1e-6) 27 | assert near(r_cov2, r_ols, 1e-6) 28 | assert near(r_cov3, r_ols, 1e-6) 29 | 30 | return True 31 | 32 | 33 | def test1(): 34 | x = [1, 2, 3, 4] 35 | y = [2, 4, 6, 8] 36 | 37 | r_ols = stats.ols(x, y) 38 | r_cov = stats.cov(x, y) 39 | r_cov2 = stats.cov2(x, y) 40 | 41 | 42 | assert near(r_cov, r_ols, 1e-6) 43 | assert near(r_cov2, r_ols, 1e-6) 44 | #assert near(r_cov3, r_ols, 1e-6) 45 | 46 | for i in range(10, 2000): 47 | x = np.random.random(i) 48 | y = np.random.random(i) 49 | assert test_reg_impl(x, y) 50 | 51 | 52 | def test_nan(): 53 | x = [1, 2, 3, 4, math.nan] 54 | y = [2, 4, 6, 8, math.nan] 55 | 56 | assert near(stats.cov2_nans(x, y), stats.cov2(x[:-1], y[:-1]), 1e-6) 57 | 58 | 59 | test_nan() 60 | test1() 61 | -------------------------------------------------------------------------------- /tools/old-data.md: -------------------------------------------------------------------------------- 1 | 2 | ``` 3 | 4 | HA caravan rpi influx 5 | 6 | 7 | daly22: ("2022-01-05", "2023-11-29") 8 | jbd22: (2022-01-23 - 2023-04-17) 9 | jk22 (2022-08-05 -- -08-14) & (2023-04-12 -- 2023-10-25) 10 | 11 | 12 | http://localhost:3000/api/hassio_ingress/YlvS1K32yJnjFjPC5JM5H4BdrxqM6EqNaABWSoAVECU/goto/MmoZsqVSR?orgId=1 13 | 14 | SELECT mean("value") AS "mean_value" 15 | FROM "home_assistant"."autogen"."A" 16 | WHERE time > :dashboardTime: AND time < :upperDashboardTime: 17 | AND "entity_id"='daly_bms_soc_current' 18 | GROUP BY time(:interval:) FILL(null) 19 | 20 | # same jbd_bms_soc_current} 21 | 22 | 23 | SELECT mean("value") AS "mean_value" FROM "home_assistant"."autogen"."°C" WHERE time > :dashboardTime: AND time < :upperDashboardTime: 24 | AND ("entity_id"='daly_bms_temperatures_1' 25 | OR "entity_id"='jbd_bms_temperatures_1' 26 | OR "entity_id"='jbd_bms_temperatures_2') GROUP BY time(:interval:) FILL(null) 27 | 28 | SELECT mean("value") AS "mean_value" FROM "home_assistant"."autogen"."%" WHERE time > :dashboardTime: AND time < :upperDashboardTime: AND "entity_id"='daly_bms_soc_soc_percent' GROUP BY time(:interval:) FILL(null) 29 | SELECT mean("value") AS "mean_value" FROM "home_assistant"."autogen"."V" WHERE time > :dashboardTime: AND time < :upperDashboardTime: AND "entity_id"='daly_bms_cell_voltages_1' GROUP BY time(:interval:) FILL(null) 30 | ``` -------------------------------------------------------------------------------- /tools/service_explorer.py: -------------------------------------------------------------------------------- 1 | """ 2 | Service Explorer 3 | ---------------- 4 | 5 | An example showing how to access and print out the services, characteristics and 6 | descriptors of a connected GATT server. 7 | 8 | Created on 2019-03-25 by hbldh 9 | 10 | """ 11 | 12 | import sys 13 | import platform 14 | import asyncio 15 | import logging 16 | 17 | from bleak import BleakClient 18 | 19 | logger = logging.getLogger(__name__) 20 | 21 | ADDRESS = ( 22 | "D6:6C:0A:61:14:30" 23 | if platform.system() != "Darwin" 24 | #else '9AA68C04-9C48-4FAD-7798-13ABB4878996' 25 | else 'BB92A45B-ABA1-2EA8-1BD3-DA140771C79D' # jk-caravan 26 | ) 27 | 28 | async def enumerate_services(client: BleakClient): 29 | for service in client.services: 30 | logger.info(f"[Service] {service}") 31 | for char in service.characteristics: 32 | if "read" in char.properties: 33 | try: 34 | value = bytes(await client.read_gatt_char(char.uuid)) 35 | logger.info( 36 | f"\t[Characteristic] {char} ({','.join(char.properties)}), Value: {value}" 37 | ) 38 | except Exception as e: 39 | logger.error( 40 | f"\t[Characteristic] {char} ({','.join(char.properties)}), Value: {e}" 41 | ) 42 | 43 | else: 44 | value = None 45 | logger.info( 46 | f"\t[Characteristic] {char} ({','.join(char.properties)}), Value: {value}" 47 | ) 48 | 49 | for descriptor in char.descriptors: 50 | try: 51 | value = bytes( 52 | await client.read_gatt_descriptor(descriptor.handle) 53 | ) 54 | logger.info(f"\t\t[Descriptor] {descriptor}) | Value: {value}") 55 | except Exception as e: 56 | logger.error(f"\t\t[Descriptor] {descriptor}) | Value: {e}") 57 | 58 | async def main(address): 59 | logger.info('Connecting %s', address) 60 | async with BleakClient(address) as client: 61 | logger.info(f"Connected: {client.is_connected}") 62 | await enumerate_services(client) 63 | 64 | 65 | 66 | if __name__ == "__main__": 67 | logging.basicConfig(level=logging.INFO) 68 | asyncio.run(main(sys.argv[1] if len(sys.argv) == 2 else ADDRESS)) --------------------------------------------------------------------------------