From 11b10bb3ff5573046057f06c3f4b7197b755862c Mon Sep 17 00:00:00 2001 From: Stephan Fudeus Date: Sat, 4 Apr 2026 20:28:59 +0200 Subject: [PATCH] Add explicit support for battery cell data and battery module history --- CHANGELOG.md | 1 + src/rctclient/cli.py | 44 +++++++++++++++- src/rctclient/registry.py | 28 +++++----- src/rctclient/types.py | 69 +++++++++++++++++++++++- src/rctclient/utils.py | 63 +++++++++++++++++++++- tests/test_decode_value.py | 105 ++++++++++++++++++++++++++++++++++++- 6 files changed, 291 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eb4547d..8b56a84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ All notable changes to this project will be documented in this file. - Raise minimum Python version to `3.11` (Debian Bookworm and newer). - Switch to `pyproject.toml` with the standard `setuptools` build-backend. - Switch to using [uv](https://docs.astral.sh/uv/) for managing the code. +- Add decoding for battery cell status and battery module statistics ### Documentation diff --git a/src/rctclient/cli.py b/src/rctclient/cli.py index ea71fbd..ed0d3c5 100644 --- a/src/rctclient/cli.py +++ b/src/rctclient/cli.py @@ -8,6 +8,7 @@ # SPDX-License-Identifier: GPL-3.0-only import logging +import json import select import socket import sys @@ -99,6 +100,44 @@ def receive_frame(sock: socket.socket, timeout: int = 2) -> ReceiveFrame: raise TimeoutError +def _format_cli_value(data_type: DataType, value: object) -> str: + ''' + Formats decoded values for CLI output. + ''' + if data_type == DataType.BATTERY_MODULE_STATUS: + # Compact JSON representation keyed by cell id. + return json.dumps( + { + str(cell_id): { + 'temperature_c': cell.temperature_c, + 'voltage_v': cell.voltage_v, + } + for cell_id, cell in sorted(value.cells.items()) + }, + separators=(',', ':'), + ) + if data_type == DataType.BATTERY_MODULE_STATISTICS: + def _encode_extreme(extreme: object, unit: str) -> dict[str, object]: + timestamp = extreme.timestamp.isoformat() if extreme.timestamp is not None else None + return { + 'cell': extreme.cell, + 'timestamp': timestamp, + 'value': extreme.value, + 'unit': unit, + } + + return json.dumps( + { + 'u_min': _encode_extreme(value.u_min, 'V'), + 'u_max': _encode_extreme(value.u_max, 'V'), + 't_min': _encode_extreme(value.t_min, 'degC'), + 't_max': _encode_extreme(value.t_max, 'degC'), + }, + separators=(',', ':'), + ) + return str(value) + + @cli.command('read-value') @click.pass_context @click.option('-p', '--port', default=8899, type=click.INT, help='Port at which the device listens, default 8899', @@ -216,10 +255,11 @@ def read_value(ctx, port: int, host: str, id: Optional[str], name: Optional[str] if verbose: description = oinfo.description if oinfo.description is not None else '' unit = oinfo.unit if oinfo.unit is not None else '' + value_out = _format_cli_value(oinfo.response_data_type, value) click.echo(f'#{oinfo.index:3} 0x{oinfo.object_id:8X} {oinfo.name:{R.name_max_length()}} ' - f'{description:75} {value} {unit}') + f'{description:75} {value_out} {unit}') else: - click.echo(f'{value}') + click.echo(_format_cli_value(oinfo.response_data_type, value)) try: sock.close() diff --git a/src/rctclient/registry.py b/src/rctclient/registry.py index 66b5d2a..c19b196 100644 --- a/src/rctclient/registry.py +++ b/src/rctclient/registry.py @@ -289,7 +289,7 @@ def name_max_length(self) -> int: ObjectInfo(group=ObjectGroup.TEMPERATURE, object_id=0x90B53336, index=520, request_data_type=DataType.FLOAT, unit='°C', name='temperature.sink_temp_power_reduction', description='Heat sink temperature target'), ObjectInfo(group=ObjectGroup.TEMPERATURE, object_id=0xA7447FC4, index=595, request_data_type=DataType.FLOAT, unit='°C', name='temperature.bat_temp_power_reduction', description='Battery actuator temperature target'), - ObjectInfo(group=ObjectGroup.BATTERY, object_id=0x1676FA6, index=3, request_data_type=DataType.UNKNOWN, name='battery.cells_stat[3]'), + ObjectInfo(group=ObjectGroup.BATTERY, object_id=0x1676FA6, index=3, request_data_type=DataType.BATTERY_MODULE_STATISTICS, name='battery.cells_stat[3]'), ObjectInfo(group=ObjectGroup.BATTERY, object_id=0x3D9C51F, index=10, request_data_type=DataType.FLOAT, name='battery.cells_stat[0].u_max.value'), ObjectInfo(group=ObjectGroup.BATTERY, object_id=0x56162CA, index=15, request_data_type=DataType.UINT32, name='battery.cells_stat[4].u_min.time'), ObjectInfo(group=ObjectGroup.BATTERY, object_id=0x56417DF, index=16, request_data_type=DataType.UINT8, name='battery.cells_stat[3].t_max.index'), @@ -299,11 +299,11 @@ def name_max_length(self) -> int: ObjectInfo(group=ObjectGroup.BATTERY, object_id=0x86C75B0, index=30, request_data_type=DataType.UINT32, name='battery.stack_software_version[3]', description='Software version stack 3'), ObjectInfo(group=ObjectGroup.BATTERY, object_id=0x9923C1E, index=35, request_data_type=DataType.UINT8, name='battery.cells_stat[3].t_min.index'), ObjectInfo(group=ObjectGroup.BATTERY, object_id=0xCFA8BC4, index=47, request_data_type=DataType.UINT16, name='battery.stack_cycles[1]'), - ObjectInfo(group=ObjectGroup.BATTERY, object_id=0xDACF21B, index=49, request_data_type=DataType.UNKNOWN, name='battery.cells_stat[4]'), + ObjectInfo(group=ObjectGroup.BATTERY, object_id=0xDACF21B, index=49, request_data_type=DataType.BATTERY_MODULE_STATISTICS, name='battery.cells_stat[4]'), ObjectInfo(group=ObjectGroup.BATTERY, object_id=0xDE3D20D, index=51, request_data_type=DataType.INT32, name='battery.status2', description='Battery extra status'), ObjectInfo(group=ObjectGroup.BATTERY, object_id=0xEF60C7E, index=58, request_data_type=DataType.FLOAT, name='battery.cells_stat[3].u_max.value'), ObjectInfo(group=ObjectGroup.BATTERY, object_id=0x120EC3B4, index=68, request_data_type=DataType.UINT8, name='battery.cells_stat[4].u_min.index'), - ObjectInfo(group=ObjectGroup.BATTERY, object_id=0x1348AB07, index=71, request_data_type=DataType.UNKNOWN, name='battery.cells[4]'), + ObjectInfo(group=ObjectGroup.BATTERY, object_id=0x1348AB07, index=71, request_data_type=DataType.BATTERY_MODULE_STATUS, name='battery.cells[4]'), ObjectInfo(group=ObjectGroup.BATTERY, object_id=0x162491E8, index=76, request_data_type=DataType.STRING, name='battery.module_sn[5]', description='Module 5 Serial Number'), ObjectInfo(group=ObjectGroup.BATTERY, object_id=0x16A1F844, index=78, request_data_type=DataType.STRING, name='battery.bms_sn', description='BMS Serial Number'), ObjectInfo(group=ObjectGroup.BATTERY, object_id=0x18D1E9E0, index=87, request_data_type=DataType.UINT8, name='battery.cells_stat[5].u_max.index'), @@ -312,7 +312,7 @@ def name_max_length(self) -> int: ObjectInfo(group=ObjectGroup.BATTERY, object_id=0x1E5FCA70, index=102, request_data_type=DataType.FLOAT, unit='A', name='battery.maximum_charge_current', description='Max. charge current'), ObjectInfo(group=ObjectGroup.BATTERY, object_id=0x1F73B6A4, index=104, request_data_type=DataType.UINT32, name='battery.cells_stat[3].t_max.time'), ObjectInfo(group=ObjectGroup.BATTERY, object_id=0x21961B58, index=113, request_data_type=DataType.FLOAT, unit='A', name='battery.current', description='Battery current'), - ObjectInfo(group=ObjectGroup.BATTERY, object_id=0x23E55DA0, index=125, request_data_type=DataType.UNKNOWN, name='battery.cells_stat[5]'), + ObjectInfo(group=ObjectGroup.BATTERY, object_id=0x23E55DA0, index=125, request_data_type=DataType.BATTERY_MODULE_STATISTICS, name='battery.cells_stat[5]'), ObjectInfo(group=ObjectGroup.BATTERY, object_id=0x257B5945, index=132, request_data_type=DataType.UINT8, name='battery.cells_stat[2].u_min.index'), ObjectInfo(group=ObjectGroup.BATTERY, object_id=0x257B7612, index=133, request_data_type=DataType.STRING, name='battery.module_sn[3]', description='Module 3 Serial Number'), ObjectInfo(group=ObjectGroup.BATTERY, object_id=0x26363AAE, index=135, request_data_type=DataType.UINT8, name='battery.cells_stat[1].t_max.index'), @@ -323,14 +323,14 @@ def name_max_length(self) -> int: ObjectInfo(group=ObjectGroup.BATTERY, object_id=0x2BC1E72B, index=153, request_data_type=DataType.FLOAT, unit='Ah', name='battery.discharged_amp_hours', description='Total charge flow from battery'), ObjectInfo(group=ObjectGroup.BATTERY, object_id=0x331D0689, index=169, request_data_type=DataType.FLOAT, name='battery.cells_stat[2].t_max.value'), ObjectInfo(group=ObjectGroup.BATTERY, object_id=0x336415EA, index=170, request_data_type=DataType.UINT32, name='battery.cells_stat[0].t_max.time'), - ObjectInfo(group=ObjectGroup.BATTERY, object_id=0x34A164E7, index=173, request_data_type=DataType.UNKNOWN, name='battery.cells_stat[0]'), + ObjectInfo(group=ObjectGroup.BATTERY, object_id=0x34A164E7, index=173, request_data_type=DataType.BATTERY_MODULE_STATISTICS, name='battery.cells_stat[0]'), ObjectInfo(group=ObjectGroup.BATTERY, object_id=0x34E33726, index=174, request_data_type=DataType.UINT8, name='battery.cells_stat[2].u_max.index'), ObjectInfo(group=ObjectGroup.BATTERY, object_id=0x3503B92D, index=177, request_data_type=DataType.UINT32, name='battery.cells_stat[3].u_max.time'), ObjectInfo(group=ObjectGroup.BATTERY, object_id=0x381B8BF9, index=187, request_data_type=DataType.FLOAT, unit='%', name='battery.soh', description='SOH (State of Health)'), ObjectInfo(group=ObjectGroup.BATTERY, object_id=0x3A7D5F53, index=198, request_data_type=DataType.FLOAT, name='battery.cells_stat[1].u_max.value'), ObjectInfo(group=ObjectGroup.BATTERY, object_id=0x3BA1B77B, index=206, request_data_type=DataType.FLOAT, name='battery.cells_stat[3].t_min.value'), ObjectInfo(group=ObjectGroup.BATTERY, object_id=0x3F98F58A, index=218, request_data_type=DataType.UINT8, name='battery.cells_stat[5].t_max.index'), - ObjectInfo(group=ObjectGroup.BATTERY, object_id=0x40FF01B7, index=222, request_data_type=DataType.UNKNOWN, name='battery.cells[6]'), + ObjectInfo(group=ObjectGroup.BATTERY, object_id=0x40FF01B7, index=222, request_data_type=DataType.BATTERY_MODULE_STATUS, name='battery.cells[6]'), ObjectInfo(group=ObjectGroup.BATTERY, object_id=0x41B11ECF, index=224, request_data_type=DataType.UINT8, name='battery.cells_stat[3].u_min.index'), ObjectInfo(group=ObjectGroup.BATTERY, object_id=0x428CCF46, index=225, request_data_type=DataType.FLOAT, name='battery.cells_stat[5].u_min.value'), ObjectInfo(group=ObjectGroup.BATTERY, object_id=0x442A3409, index=233, request_data_type=DataType.UINT32, name='battery.cells_stat[4].t_min.time'), @@ -353,14 +353,14 @@ def name_max_length(self) -> int: ObjectInfo(group=ObjectGroup.BATTERY, object_id=0x60749E5E, index=333, request_data_type=DataType.UINT32, name='battery.cells_stat[6].u_min.time'), ObjectInfo(group=ObjectGroup.BATTERY, object_id=0x61EAC702, index=336, request_data_type=DataType.FLOAT, name='battery.cells_stat[0].t_min.value'), ObjectInfo(group=ObjectGroup.BATTERY, object_id=0x6213589B, index=337, request_data_type=DataType.FLOAT, name='battery.cells_stat[6].u_min.value'), - ObjectInfo(group=ObjectGroup.BATTERY, object_id=0x62D645D9, index=340, request_data_type=DataType.UNKNOWN, name='battery.cells[5]'), + ObjectInfo(group=ObjectGroup.BATTERY, object_id=0x62D645D9, index=340, request_data_type=DataType.BATTERY_MODULE_STATUS, name='battery.cells[5]'), ObjectInfo(group=ObjectGroup.BATTERY, object_id=0x6388556C, index=344, request_data_type=DataType.UINT32, name='battery.stack_software_version[0]', description='Software version stack 0'), ObjectInfo(group=ObjectGroup.BATTERY, object_id=0x6445D856, index=345, request_data_type=DataType.UINT8, name='battery.cells_stat[1].u_min.index'), ObjectInfo(group=ObjectGroup.BATTERY, object_id=0x649B10DA, index=347, request_data_type=DataType.STRING, name='battery.cells_resist[0]'), ObjectInfo(group=ObjectGroup.BATTERY, object_id=0x4E04DD55, index=266, request_data_type=DataType.FLOAT, name='battery.soc_update_since'), ObjectInfo(group=ObjectGroup.BATTERY, object_id=0x65EED11B, index=353, request_data_type=DataType.FLOAT, unit='V', name='battery.voltage', description='Battery voltage'), ObjectInfo(group=ObjectGroup.BATTERY, object_id=0x6974798A, index=369, request_data_type=DataType.UINT32, name='battery.stack_software_version[6]', description='Software version stack 6'), - ObjectInfo(group=ObjectGroup.BATTERY, object_id=0x69B8FF28, index=371, request_data_type=DataType.UNKNOWN, name='battery.cells[2]'), + ObjectInfo(group=ObjectGroup.BATTERY, object_id=0x69B8FF28, index=371, request_data_type=DataType.BATTERY_MODULE_STATUS, name='battery.cells[2]'), ObjectInfo(group=ObjectGroup.BATTERY, object_id=0x6DB1FDDC, index=385, request_data_type=DataType.FLOAT, name='battery.cells_stat[4].u_min.value'), ObjectInfo(group=ObjectGroup.BATTERY, object_id=0x6E24632E, index=388, request_data_type=DataType.UINT32, name='battery.cells_stat[5].u_max.time'), ObjectInfo(group=ObjectGroup.BATTERY, object_id=0x6E491B50, index=390, request_data_type=DataType.FLOAT, unit='V', name='battery.maximum_charge_voltage', description='Max. charge voltage'), @@ -372,7 +372,7 @@ def name_max_length(self) -> int: ObjectInfo(group=ObjectGroup.BATTERY, object_id=0x71CB0B57, index=406, request_data_type=DataType.STRING, name='battery.cells_resist[1]'), ObjectInfo(group=ObjectGroup.BATTERY, object_id=0x7268CE4D, index=409, request_data_type=DataType.UINT32, name='battery.inv_cmd'), ObjectInfo(group=ObjectGroup.BATTERY, object_id=0x73489528, index=412, request_data_type=DataType.STRING, name='battery.module_sn[2]', description='Module 2 Serial Number'), - ObjectInfo(group=ObjectGroup.BATTERY, object_id=0x74FD4609, index=415, request_data_type=DataType.UNKNOWN, name='battery.cells_stat[2]'), + ObjectInfo(group=ObjectGroup.BATTERY, object_id=0x74FD4609, index=415, request_data_type=DataType.BATTERY_MODULE_STATISTICS, name='battery.cells_stat[2]'), ObjectInfo(group=ObjectGroup.BATTERY, object_id=0x770A6E7C, index=422, request_data_type=DataType.UINT8, name='battery.cells_stat[0].u_max.index'), ObjectInfo(group=ObjectGroup.BATTERY, object_id=0x7E590128, index=455, request_data_type=DataType.UINT32, name='battery.cells_stat[0].u_max.time'), ObjectInfo(group=ObjectGroup.BATTERY, object_id=0x7F42BB82, index=457, request_data_type=DataType.UINT8, name='battery.cells_stat[6].u_max.index'), @@ -387,7 +387,7 @@ def name_max_length(self) -> int: ObjectInfo(group=ObjectGroup.BATTERY, object_id=0x8BB08839, index=498, request_data_type=DataType.UINT32, name='battery.cells_stat[6].t_min.time'), ObjectInfo(group=ObjectGroup.BATTERY, object_id=0x8DFFDD33, index=504, request_data_type=DataType.UINT32, name='battery.cells_stat[3].u_min.time'), ObjectInfo(group=ObjectGroup.BATTERY, object_id=0x8EC23427, index=507, request_data_type=DataType.UINT32, name='battery.cells_stat[4].u_max.time'), - ObjectInfo(group=ObjectGroup.BATTERY, object_id=0x8EF6FBBD, index=509, request_data_type=DataType.UNKNOWN, name='battery.cells[1]'), + ObjectInfo(group=ObjectGroup.BATTERY, object_id=0x8EF6FBBD, index=509, request_data_type=DataType.BATTERY_MODULE_STATUS, name='battery.cells[1]'), ObjectInfo(group=ObjectGroup.BATTERY, object_id=0x8EF9C9B8, index=510, request_data_type=DataType.UINT32, name='battery.cells_stat[6].t_max.time'), ObjectInfo(group=ObjectGroup.BATTERY, object_id=0x902AFAFB, index=513, request_data_type=DataType.FLOAT, unit='°C', name='battery.temperature', description='Battery temperature'), ObjectInfo(group=ObjectGroup.BATTERY, object_id=0x90832471, index=518, request_data_type=DataType.UINT32, name='battery.cells_stat[1].u_max.time'), @@ -422,7 +422,7 @@ def name_max_length(self) -> int: ObjectInfo(group=ObjectGroup.BATTERY, object_id=0xC0DF2978, index=684, request_data_type=DataType.INT32, name='battery.cycles', description='Battery charge / discharge cycles'), ObjectInfo(group=ObjectGroup.BATTERY, object_id=0xC42F5807, index=695, request_data_type=DataType.UINT8, name='battery.cells_stat[1].u_max.index'), ObjectInfo(group=ObjectGroup.BATTERY, object_id=0xC6DA81A0, index=704, request_data_type=DataType.UINT32, name='battery.cells_stat[6].u_max.time'), - ObjectInfo(group=ObjectGroup.BATTERY, object_id=0xC8609C8E, index=712, request_data_type=DataType.UNKNOWN, name='battery.cells[3]'), + ObjectInfo(group=ObjectGroup.BATTERY, object_id=0xC8609C8E, index=712, request_data_type=DataType.BATTERY_MODULE_STATUS, name='battery.cells[3]'), ObjectInfo(group=ObjectGroup.BATTERY, object_id=0xC88EB032, index=713, request_data_type=DataType.UINT32, name='battery.cells_stat[0].u_min.time'), ObjectInfo(group=ObjectGroup.BATTERY, object_id=0xC8BA1729, index=714, request_data_type=DataType.UINT32, name='battery.stack_software_version[2]', description='Software version stack 2'), ObjectInfo(group=ObjectGroup.BATTERY, object_id=0xD0C47326, index=736, request_data_type=DataType.FLOAT, name='battery.cells_stat[1].t_min.value'), @@ -441,10 +441,10 @@ def name_max_length(self) -> int: ObjectInfo(group=ObjectGroup.BATTERY, object_id=0xF257D342, index=842, request_data_type=DataType.FLOAT, name='battery.cells_stat[1].t_max.value'), ObjectInfo(group=ObjectGroup.BATTERY, object_id=0xF3FD8CE6, index=848, request_data_type=DataType.STRING, name='battery.cells_resist[2]'), ObjectInfo(group=ObjectGroup.BATTERY, object_id=0xF54BC06D, index=854, request_data_type=DataType.FLOAT, name='battery.cells_stat[4].u_max.value'), - ObjectInfo(group=ObjectGroup.BATTERY, object_id=0xF8C0D255, index=864, request_data_type=DataType.UNKNOWN, name='battery.cells[0]'), - ObjectInfo(group=ObjectGroup.BATTERY, object_id=0xF99E8CC8, index=866, request_data_type=DataType.UNKNOWN, name='battery.cells_stat[6]'), + ObjectInfo(group=ObjectGroup.BATTERY, object_id=0xF8C0D255, index=864, request_data_type=DataType.BATTERY_MODULE_STATUS, name='battery.cells[0]'), + ObjectInfo(group=ObjectGroup.BATTERY, object_id=0xF99E8CC8, index=866, request_data_type=DataType.BATTERY_MODULE_STATISTICS, name='battery.cells_stat[6]'), ObjectInfo(group=ObjectGroup.BATTERY, object_id=0xFA3276DC, index=868, request_data_type=DataType.UINT32, name='battery.cells_stat[3].t_min.time'), - ObjectInfo(group=ObjectGroup.BATTERY, object_id=0xFB796780, index=874, request_data_type=DataType.UNKNOWN, name='battery.cells_stat[1]'), + ObjectInfo(group=ObjectGroup.BATTERY, object_id=0xFB796780, index=874, request_data_type=DataType.BATTERY_MODULE_STATISTICS, name='battery.cells_stat[1]'), ObjectInfo(group=ObjectGroup.BATTERY, object_id=0xFBF6D834, index=877, request_data_type=DataType.STRING, name='battery.module_sn[0]', description='Module 0 Serial Number'), ObjectInfo(group=ObjectGroup.BATTERY, object_id=0xFDBD9EE9, index=889, request_data_type=DataType.UINT8, name='battery.cells_stat[3].u_max.index'), ObjectInfo(group=ObjectGroup.BATTERY, object_id=0xFE44BA26, index=892, request_data_type=DataType.UINT8, name='battery.cells_stat[0].u_min.index'), diff --git a/src/rctclient/types.py b/src/rctclient/types.py index cd6dda6..24b59bc 100644 --- a/src/rctclient/types.py +++ b/src/rctclient/types.py @@ -9,9 +9,10 @@ # pylint: disable=too-many-arguments,too-few-public-methods +from dataclasses import dataclass from datetime import datetime from enum import IntEnum -from typing import Optional +from typing import Iterator, Optional class Command(IntEnum): @@ -188,6 +189,72 @@ class DataType(IntEnum): #: Non-native: Event table entries consisting of a tuple of a timestamp for the record (usually the day) and a dict #: mapping values to timestamps. Can not be used for encoding. EVENT_TABLE = 21 + #: Non-native: Battery module status data for 24 cells. Can not be used for encoding. + BATTERY_MODULE_STATUS = 22 + #: Non-native: Battery module min/max statistics with timestamps and cell indexes. + BATTERY_MODULE_STATISTICS = 23 + + +@dataclass(frozen=True) +class BatteryModuleCellStatus: + ''' + Decoded status of a single battery cell within a module. + + The raw wire format is 4 bytes per cell: one byte temperature, two bytes little-endian voltage in millivolts and + one trailing status or reserved byte. + ''' + + temperature_c: int + voltage_mv: int + status: int + + @property + def voltage_v(self) -> float: + ''' + Returns the cell voltage in volts. + ''' + return self.voltage_mv / 1000.0 + + +@dataclass(frozen=True) +class BatteryModuleStatus: + ''' + Decoded status payload for a battery module containing 24 cells. + ''' + + cells: dict[int, BatteryModuleCellStatus] + + def __iter__(self) -> Iterator[BatteryModuleCellStatus]: + return iter(self.cells.values()) + + def __len__(self) -> int: + return len(self.cells) + + def __getitem__(self, cell_id: int) -> BatteryModuleCellStatus: + return self.cells[cell_id] + + +@dataclass(frozen=True) +class BatteryModuleHistoryEntry: + ''' + A single min/max extreme with cell number, timestamp and value. + ''' + + cell: int + timestamp: Optional[datetime] + value: float + + +@dataclass(frozen=True) +class BatteryModuleStatistics: + ''' + Historic min/max statistics for one battery module. + ''' + + u_min: BatteryModuleHistoryEntry + u_max: BatteryModuleHistoryEntry + t_min: BatteryModuleHistoryEntry + t_max: BatteryModuleHistoryEntry class EventEntry: diff --git a/src/rctclient/utils.py b/src/rctclient/utils.py index 81b38bc..9bf6b9c 100644 --- a/src/rctclient/utils.py +++ b/src/rctclient/utils.py @@ -14,7 +14,7 @@ # Python < 3.8 from typing_extensions import Literal -from .types import DataType, EventEntry +from .types import BatteryModuleCellStatus, BatteryModuleHistoryEntry, BatteryModuleStatistics, BatteryModuleStatus, DataType, EventEntry # pylint: disable=invalid-name def CRC16(data: Union[bytes, bytearray]) -> int: @@ -134,8 +134,20 @@ def decode_value(data_type: Literal[DataType.EVENT_TABLE], data: bytes) -> Tuple ... +@overload +def decode_value(data_type: Literal[DataType.BATTERY_MODULE_STATUS], data: bytes) -> BatteryModuleStatus: + ... + + +@overload +def decode_value(data_type: Literal[DataType.BATTERY_MODULE_STATISTICS], data: bytes) -> BatteryModuleStatistics: + ... + + # pylint: disable=too-many-branches,too-many-return-statements def decode_value(data_type: DataType, data: bytes) -> Union[bool, bytes, float, int, str, + BatteryModuleStatistics, + BatteryModuleStatus, Tuple[datetime, Dict[datetime, int]], Tuple[datetime, Dict[datetime, EventEntry]]]: ''' @@ -179,9 +191,58 @@ def decode_value(data_type: DataType, data: bytes) -> Union[bool, bytes, float, return _decode_timeseries(data) if data_type == DataType.EVENT_TABLE: return _decode_event_table(data) + if data_type == DataType.BATTERY_MODULE_STATUS: + return _decode_battery_module_status(data) + if data_type == DataType.BATTERY_MODULE_STATISTICS: + return _decode_battery_module_statistics(data) raise KeyError(f'Undefined or unknown type {data_type}') +def _decode_battery_module_status(data: bytes) -> BatteryModuleStatus: + ''' + Helper function to decode the battery module status payload. + ''' + cells_per_module = 24 + cell_record_size = 4 + expected_len = cells_per_module * cell_record_size + + if len(data) != expected_len: + raise ValueError(f'Battery module status payload must be {expected_len} bytes, got {len(data)}') + + cells: dict[int, BatteryModuleCellStatus] = {} + for cell_id, offset in enumerate(range(0, len(data), cell_record_size)): + temperature_c = data[offset] + voltage_mv = data[offset + 1] | (data[offset + 2] << 8) + status = data[offset + 3] + cells[cell_id] = BatteryModuleCellStatus(temperature_c=temperature_c, voltage_mv=voltage_mv, status=status) + + return BatteryModuleStatus(cells=cells) + + +def _decode_battery_module_statistics(data: bytes) -> BatteryModuleStatistics: + ''' + Helper function to decode the battery module statistics payload. + ''' + expected_len = 48 + if len(data) != expected_len: + raise ValueError(f'Battery module statistics payload must be {expected_len} bytes, got {len(data)}') + + values = [struct.unpack(' datetime | None: + if value == 0: + return None + return datetime.fromtimestamp(value, UTC) + + return BatteryModuleStatistics( + u_min=BatteryModuleHistoryEntry(cell=values[0], timestamp=_ts(values[1]), value=floats[2]), + u_max=BatteryModuleHistoryEntry(cell=values[3], timestamp=_ts(values[4]), value=floats[5]), + t_min=BatteryModuleHistoryEntry(cell=values[6], timestamp=_ts(values[7]), value=floats[8]), + t_max=BatteryModuleHistoryEntry(cell=values[9], timestamp=_ts(values[10]), value=floats[11]), + ) + + def _decode_timeseries(data: bytes) -> Tuple[datetime, Dict[datetime, int]]: ''' Helper function to decode the timeseries type. diff --git a/tests/test_decode_value.py b/tests/test_decode_value.py index 9427476..4d6f821 100644 --- a/tests/test_decode_value.py +++ b/tests/test_decode_value.py @@ -3,8 +3,11 @@ Tests for decode_value. ''' +from datetime import UTC, datetime + import pytest -from rctclient.types import DataType +from rctclient.registry import REGISTRY as R +from rctclient.types import BatteryModuleStatistics, BatteryModuleStatus, DataType from rctclient.utils import decode_value # pylint: disable=invalid-name,no-self-use @@ -50,3 +53,103 @@ def test_STRING_happy_nonull(self) -> None: result = decode_value(data_type=DataType.STRING, data=data) assert isinstance(result, str), 'The resulting type should be a string' assert result == plain + + def test_BATTERY_MODULE_STATUS_happy(self) -> None: + ''' + Tests decoding of the battery module status payload. + ''' + data = bytes.fromhex( + '19 f6 0c 00 19 f6 0c 00 1a f6 0c 00 1a f7 0c 00 ' + '1a f6 0c 00 1a f6 0c 00 1b f6 0c 00 1a f7 0c 00 ' + '1b f7 0c 00 1b f6 0c 00 1b f6 0c 00 1b f6 0c 00 ' + '1c fd 0c 00 1b fd 0c 00 1c fd 0c 00 1b ff 0c 00 ' + '1b fd 0c 00 1b fd 0c 00 1b fd 0c 00 1a fd 0c 00 ' + '1a fd 0c 00 1a fd 0c 00 1a ff 0c 00 19 fd 0c 00' + ) + + result = decode_value(data_type=DataType.BATTERY_MODULE_STATUS, data=data) + + assert isinstance(result, BatteryModuleStatus) + assert isinstance(result.cells, dict) + assert set(result.cells.keys()) == set(range(24)) + assert len(result) == 24 + assert result[0].temperature_c == 25 + assert result[0].voltage_mv == 3318 + assert result[0].voltage_v == pytest.approx(3.318) + assert result[0].status == 0 + assert result[15].temperature_c == 27 + assert result[15].voltage_mv == 3327 + assert result[23].temperature_c == 25 + assert result[23].voltage_mv == 3325 + + def test_BATTERY_MODULE_STATUS_invalid_length(self) -> None: + ''' + Tests that invalid battery module payload lengths are rejected. + ''' + with pytest.raises(ValueError): + decode_value(data_type=DataType.BATTERY_MODULE_STATUS, data=b'\x00' * 4) + + def test_BATTERY_MODULE_STATISTICS_happy(self) -> None: + ''' + Tests decoding of the battery module statistics payload. + ''' + data = bytes.fromhex('0000000046d25b6223db414000000000f163ab6619045e4000000000baeea065000090410e0000001ba4e26000002442') + + result = decode_value(data_type=DataType.BATTERY_MODULE_STATISTICS, data=data) + + assert isinstance(result, BatteryModuleStatistics) + assert result.u_min.cell == 0 + assert result.u_min.timestamp == datetime.fromtimestamp(1650184774, UTC) + assert result.u_min.value == pytest.approx(3.029) + assert result.u_max.cell == 0 + assert result.u_max.timestamp == datetime.fromtimestamp(1722508273, UTC) + assert result.u_max.value == pytest.approx(3.469) + assert result.t_min.cell == 0 + assert result.t_min.timestamp == datetime.fromtimestamp(1705045690, UTC) + assert result.t_min.value == pytest.approx(18.0) + assert result.t_max.cell == 14 + assert result.t_max.timestamp == datetime.fromtimestamp(1625465883, UTC) + assert result.t_max.value == pytest.approx(41.0) + + def test_BATTERY_MODULE_STATISTICS_invalid_length(self) -> None: + ''' + Tests that invalid battery module statistics payload lengths are rejected. + ''' + with pytest.raises(ValueError): + decode_value(data_type=DataType.BATTERY_MODULE_STATISTICS, data=b'\x00' * 4) + + @pytest.mark.parametrize( + 'name', + [ + 'battery.cells[0]', + 'battery.cells[1]', + 'battery.cells[2]', + 'battery.cells[3]', + 'battery.cells[4]', + 'battery.cells[5]', + 'battery.cells[6]', + ], + ) + def test_battery_cells_registry_uses_battery_module_status(self, name: str) -> None: + ''' + Tests that battery.cells objects use the structured battery module status type. + ''' + assert R.get_by_name(name).response_data_type == DataType.BATTERY_MODULE_STATUS + + @pytest.mark.parametrize( + 'name', + [ + 'battery.cells_stat[0]', + 'battery.cells_stat[1]', + 'battery.cells_stat[2]', + 'battery.cells_stat[3]', + 'battery.cells_stat[4]', + 'battery.cells_stat[5]', + 'battery.cells_stat[6]', + ], + ) + def test_battery_cells_stat_registry_uses_battery_module_statistics(self, name: str) -> None: + ''' + Tests that battery.cells_stat objects use the structured battery module statistics type. + ''' + assert R.get_by_name(name).response_data_type == DataType.BATTERY_MODULE_STATISTICS