├── .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 |
4 |
5 |
--------------------------------------------------------------------------------
/.idea/codeStyles/codeStyleConfig.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/.idea/csv-plugin.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
22 |
23 |
--------------------------------------------------------------------------------
/.idea/deployment.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
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 |
4 |
5 |
6 |
7 |
--------------------------------------------------------------------------------
/.idea/misc.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
--------------------------------------------------------------------------------
/.idea/modules.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/.idea/runConfigurations/block_compute.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
--------------------------------------------------------------------------------
/.idea/runConfigurations/main.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
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))
--------------------------------------------------------------------------------