From f0c20574288d44b46326321831f627f797d6a213 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Odd=20Str=C3=A5b=C3=B8?= Date: Sun, 10 Dec 2023 13:51:07 +0100 Subject: [PATCH 1/7] Implement more getters in ChargeController --- srnemqtt/protocol.py | 57 ++++++++----- srnemqtt/solar_types.py | 180 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 213 insertions(+), 24 deletions(-) diff --git a/srnemqtt/protocol.py b/srnemqtt/protocol.py index 50cfc06..4229a4a 100644 --- a/srnemqtt/protocol.py +++ b/srnemqtt/protocol.py @@ -8,7 +8,14 @@ from libscrc import modbus # type: ignore from .constants import ACTION_READ, ACTION_WRITE, POSSIBLE_MARKER from .interfaces import BaseInterface -from .solar_types import DATA_BATTERY_STATE, HISTORICAL_DATA, ChargerState, DataItem +from .solar_types import ( + DATA_BATTERY_STATE, + HISTORICAL_DATA, + ChargerState, + DataItem, + HistoricalData, + HistoricalExtraInfo, +) from .util import log @@ -259,23 +266,31 @@ class ChargeController: @property def state(self) -> ChargerState: - raise NotImplementedError - """ - data = try_read_parse(dev, 0x0100, 11, parse_battery_state) - if data: - data[DataName.CALCULATED_BATTERY_POWER] = float( - Decimal(str(data.get(DataName.BATTERY_VOLTAGE, 0))) - * Decimal(str(data.get(DataName.BATTERY_CURRENT, 0))) - ) - data[DataName.CALCULATED_PANEL_POWER] = float( - Decimal(str(data.get(DataName.PANEL_VOLTAGE, 0))) - * Decimal(str(data.get(DataName.PANEL_CURRENT, 0))) - ) - data[DataName.CALCULATED_LOAD_POWER] = float( - Decimal(str(data.get(DataName.LOAD_VOLTAGE, 0))) - * Decimal(str(data.get(DataName.LOAD_CURRENT, 0))) - ) - log(data) - for consumer in consumers: - consumer.write(data) - """ + data = readMemory(self.device, 0x0100, 11) + if data is None: + raise IOError # FIXME: Raise specific error in readMemory + + return ChargerState(data) + + def get_historical(self, day) -> HistoricalData: + data = readMemory(self.device, 0xF000 + day, 10) + if data is None: + raise IOError # FIXME: Raise specific error in readMemory + + return HistoricalData(data) + + @property + def today(self) -> HistoricalData: + data = readMemory(self.device, 0x010B, 10) + if data is None: + raise IOError # FIXME: Raise specific error in readMemory + + return HistoricalData(data) + + @property + def extra(self) -> HistoricalExtraInfo: + data = readMemory(self.device, 0x0115, 11) + if data is None: + raise IOError # FIXME: Raise specific error in readMemory + + return HistoricalExtraInfo(data) diff --git a/srnemqtt/solar_types.py b/srnemqtt/solar_types.py index 78276ec..daf62bc 100644 --- a/srnemqtt/solar_types.py +++ b/srnemqtt/solar_types.py @@ -1,7 +1,8 @@ # -*- coding: utf-8 -*- import struct +from abc import ABC, abstractmethod from enum import Enum, unique -from typing import Callable, Optional +from typing import Any, Callable, Dict, Optional @unique @@ -111,6 +112,7 @@ HISTORICAL_DATA = [ DataItem(DataName.DISCHARGE_AMP_HOUR, "H", "Ah"), DataItem(DataName.PRODUCTION_ENERGY, "H", "Wh"), DataItem(DataName.CONSUMPTION_ENERGY, "H", "Wh"), + # DataItem(DataName.RUN_DAYS, "H"), DataItem(DataName.DISCHARGE_COUNT, "H"), DataItem(DataName.FULL_CHARGE_COUNT, "H"), @@ -121,6 +123,178 @@ HISTORICAL_DATA = [ ] -class ChargerState: +class DecodedData(ABC): + @abstractmethod def __init__(self, data: bytes | bytearray | memoryview) -> None: - raise NotImplementedError + ... + + @abstractmethod + def as_dict(self) -> Dict[DataName, Any]: + ... + + +class ChargerState(DecodedData): + battery_charge: int + battery_voltage: float + battery_current: float + internal_temperature: int + battery_temperature: int + load_voltage: float + load_current: float + load_power: float + panel_voltage: float + panel_current: float + panel_power: float + load_enabled: bool + + def __init__(self, data: bytes | bytearray | memoryview) -> None: + ( + _battery_charge, + _battery_voltage, + _battery_current, + _internal_temperature, + _battery_temperature, + _load_voltage, + _load_current, + _load_power, + _panel_voltage, + _panel_current, + _panel_power, + _load_enabled, + ) = struct.unpack("HHHBBHHHHHHx?", data) + + self.battery_charge = _battery_charge + self.battery_voltage = _battery_voltage / 10 + self.battery_current = _battery_current / 100 + self.internal_temperature = parse_temperature(_internal_temperature) + self.battery_temperature = parse_temperature(_battery_temperature) + self.load_voltage = _load_voltage / 10 + self.load_current = _load_current / 100 + self.load_power = _load_power + self.panel_voltage = _panel_voltage / 10 + self.panel_current = _panel_current / 100 + self.panel_power = _panel_power + self.load_enabled = bool(_load_enabled) + + @property + def calculated_battery_power(self) -> float: + return self.battery_voltage * self.battery_current + + @property + def calculated_panel_power(self) -> float: + return self.panel_voltage * self.panel_current + + @property + def calculated_load_power(self) -> float: + return self.load_voltage * self.load_current + + def as_dict(self): + return { + DataName.BATTERY_CHARGE: self.battery_charge, + DataName.BATTERY_VOLTAGE: self.battery_voltage, + DataName.BATTERY_CURRENT: self.battery_current, + DataName.INTERNAL_TEMPERATURE: self.internal_temperature, + DataName.BATTERY_TEMPERATURE: self.battery_temperature, + DataName.LOAD_VOLTAGE: self.load_voltage, + DataName.LOAD_CURRENT: self.load_current, + DataName.LOAD_POWER: self.load_power, + DataName.PANEL_VOLTAGE: self.panel_voltage, + DataName.PANEL_CURRENT: self.panel_current, + DataName.PANEL_POWER: self.panel_power, + DataName.LOAD_ENABLED: self.load_enabled, + DataName.CALCULATED_BATTERY_POWER: self.calculated_battery_power, + DataName.CALCULATED_PANEL_POWER: self.calculated_panel_power, + DataName.CALCULATED_LOAD_POWER: self.calculated_load_power, + } + + +class HistoricalData(DecodedData): + battery_voltage_min: float + battery_voltage_max: float + charge_max_current: float + _discharge_max_current: float + charge_max_power: int + discharge_max_power: int + charge_amp_hour: int + discharge_amp_hour: int + production_energy: int + consumption_energy: int + + def __init__(self, data: bytes | bytearray | memoryview) -> None: + ( + _battery_voltage_min, + _battery_voltage_max, + _charge_max_current, + __discharge_max_current, + _charge_max_power, + _discharge_max_power, + _charge_amp_hour, + _discharge_amp_hour, + _production_energy, + _consumption_energy, + ) = struct.unpack("HHHHHHHHHH", data) + + self.battery_voltage_min = _battery_voltage_min / 10 + self.battery_voltage_max = _battery_voltage_max / 10 + self.charge_max_current = _charge_max_current / 100 + self._discharge_max_current = __discharge_max_current / 100 + self.charge_max_power = _charge_max_power + self.discharge_max_power = _discharge_max_power + self.charge_amp_hour = _charge_amp_hour + self.discharge_amp_hour = _discharge_amp_hour + self.production_energy = _production_energy + self.consumption_energy = _consumption_energy + + def as_dict(self): + return { + DataName.BATTERY_VOLTAGE_MIN: self.battery_voltage_min, + DataName.BATTERY_VOLTAGE_MAX: self.battery_voltage_max, + DataName.CHARGE_MAX_CURRENT: self.charge_max_current, + DataName._DISCHARGE_MAX_CURRENT: self._discharge_max_current, + DataName.CHARGE_MAX_POWER: self.charge_max_power, + DataName.DISCHARGE_MAX_POWER: self.discharge_max_power, + DataName.CHARGE_AMP_HOUR: self.charge_amp_hour, + DataName.DISCHARGE_AMP_HOUR: self.discharge_amp_hour, + DataName.PRODUCTION_ENERGY: self.production_energy, + DataName.CONSUMPTION_ENERGY: self.consumption_energy, + } + + +class HistoricalExtraInfo(DecodedData): + run_days: int + discharge_count: int + full_charge_count: int + total_charge_amp_hours: int + total_discharge_amp_hours: int + total_production_energy: int + total_consumption_energy: int + + def __init__(self, data: bytes | bytearray | memoryview) -> None: + ( + _run_days, + _discharge_count, + _full_charge_count, + _total_charge_amp_hours, + _total_discharge_amp_hours, + _total_production_energy, + _total_consumption_energy, + ) = struct.unpack("HHHLLLL", data) + + self.run_days = _run_days + self.discharge_count = _discharge_count + self.full_charge_count = _full_charge_count + self.total_charge_amp_hours = _total_charge_amp_hours + self.total_discharge_amp_hours = _total_discharge_amp_hours + self.total_production_energy = _total_production_energy + self.total_consumption_energy = _total_consumption_energy + + def as_dict(self): + return { + DataName.RUN_DAYS: self.run_days, + DataName.DISCHARGE_COUNT: self.discharge_count, + DataName.FULL_CHARGE_COUNT: self.full_charge_count, + DataName.TOTAL_CHARGE_AMP_HOURS: self.total_charge_amp_hours, + DataName.TOTAL_DISCHARGE_AMP_HOURS: self.total_discharge_amp_hours, + DataName.TOTAL_PRODUCTION_ENERGY: self.total_production_energy, + DataName.TOTAL_CONSUMPTION_ENERGY: self.total_consumption_energy, + } From fe9c6a82ffe892c6fd773e723f38f336c04042c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Odd=20Str=C3=A5b=C3=B8?= Date: Sun, 10 Dec 2023 16:08:28 +0100 Subject: [PATCH 2/7] Fix unpack, rework main to use ChargeController --- misc/render_rrd.py | 4 +-- srnemqtt/__main__.py | 71 ++++++++++++++------------------------ srnemqtt/consumers/mqtt.py | 2 +- srnemqtt/solar_types.py | 18 +++++----- 4 files changed, 38 insertions(+), 57 deletions(-) diff --git a/misc/render_rrd.py b/misc/render_rrd.py index 20d3553..aebf051 100644 --- a/misc/render_rrd.py +++ b/misc/render_rrd.py @@ -20,7 +20,7 @@ HISTORICAL_KEYS = { DataName.BATTERY_VOLTAGE_MIN, DataName.BATTERY_VOLTAGE_MAX, DataName.CHARGE_MAX_CURRENT, - DataName._DISCHARGE_MAX_CURRENT, + DataName.DISCHARGE_MAX_CURRENT, DataName.CHARGE_MAX_POWER, DataName.DISCHARGE_MAX_POWER, DataName.CHARGE_AMP_HOUR, @@ -58,7 +58,7 @@ KNOWN_KEYS = HISTORICAL_KEYS.union(INSTANT_KEYS) MAP = { "_internal_temperature?": "internal_temp", "unknown1": "charge_max_current", - "unknown2": "_discharge_max_current?", + "unknown2": "discharge_max_current", "internal_temperature": "internal_temp", "battery_temperature": "battery_temp", } diff --git a/srnemqtt/__main__.py b/srnemqtt/__main__.py index 339bd0e..9fc1c3b 100755 --- a/srnemqtt/__main__.py +++ b/srnemqtt/__main__.py @@ -2,15 +2,12 @@ # -*- coding: utf-8 -*- import time -from decimal import Decimal -from typing import cast from bluepy.btle import BTLEDisconnectError # type: ignore from serial import SerialException # type: ignore from .config import get_config, get_consumers, get_interface -from .protocol import parse_battery_state, parse_historical_entry, try_read_parse -from .solar_types import DataName +from .protocol import ChargeController from .util import Periodical, log @@ -35,67 +32,51 @@ def main(): with get_interface() as dev: log("Connected.") + cc = ChargeController(dev) + # write(dev, construct_request(0, 32)) # Memory dump # for address in range(0, 0x10000, 16): # log(f"Reading 0x{address:04X}...") # write(wd, construct_request(address, 16)) - days = 7 - res = try_read_parse(dev, 0x010B, 21, parse_historical_entry) - if res: - log(res) - for consumer in consumers: - consumer.write(res) - days = cast(int, res.get("run_days", 7)) + extra = cc.extra + days = extra.run_days + + res = cc.today.as_dict() + res.update(extra.as_dict()) + for consumer in consumers: + consumer.write(res) + del extra for i in range(days): - res = try_read_parse( - dev, 0xF000 + i, 10, parse_historical_entry - ) - if res: - log({i: res}) - for consumer in consumers: - consumer.write({str(i): res}) + hist = cc.get_historical(i) + res = hist.as_dict() + log({i: res}) + for consumer in consumers: + consumer.write({str(i): res}) while True: now = time.time() if per_voltages(now): - data = try_read_parse(dev, 0x0100, 11, parse_battery_state) - if data: - data[DataName.CALCULATED_BATTERY_POWER] = float( - Decimal(str(data.get(DataName.BATTERY_VOLTAGE, 0))) - * Decimal( - str(data.get(DataName.BATTERY_CURRENT, 0)) - ) - ) - data[DataName.CALCULATED_PANEL_POWER] = float( - Decimal(str(data.get(DataName.PANEL_VOLTAGE, 0))) - * Decimal(str(data.get(DataName.PANEL_CURRENT, 0))) - ) - data[DataName.CALCULATED_LOAD_POWER] = float( - Decimal(str(data.get(DataName.LOAD_VOLTAGE, 0))) - * Decimal(str(data.get(DataName.LOAD_CURRENT, 0))) - ) - log(data) - for consumer in consumers: - consumer.write(data) + data = cc.state.as_dict() + log(data) + for consumer in consumers: + consumer.write(data) if per_current_hist(now): - data = try_read_parse( - dev, 0x010B, 21, parse_historical_entry - ) - if data: - log(data) - for consumer in consumers: - consumer.write(data) + data = cc.today.as_dict() + data.update(cc.extra.as_dict()) + log(data) + for consumer in consumers: + consumer.write(data) # print(".") for consumer in consumers: consumer.poll() - time.sleep(max(0, 1 - time.time() - now)) + time.sleep(max(0, 1 - (time.time() - now))) # if STATUS.get('load_enabled'): # write(wd, CMD_DISABLE_LOAD) diff --git a/srnemqtt/consumers/mqtt.py b/srnemqtt/consumers/mqtt.py index 21a29a2..51d26b1 100644 --- a/srnemqtt/consumers/mqtt.py +++ b/srnemqtt/consumers/mqtt.py @@ -13,7 +13,7 @@ MAP_VALUES: Dict[DataName, Dict[str, Any]] = { # DataName.BATTERY_VOLTAGE_MIN: {}, # DataName.BATTERY_VOLTAGE_MAX: {}, # DataName.CHARGE_MAX_CURRENT: {}, - # DataName._DISCHARGE_MAX_CURRENT: {}, + # DataName.DISCHARGE_MAX_CURRENT: {}, # DataName.CHARGE_MAX_POWER: {}, # DataName.DISCHARGE_MAX_POWER: {}, # DataName.CHARGE_AMP_HOUR: {}, diff --git a/srnemqtt/solar_types.py b/srnemqtt/solar_types.py index daf62bc..94c387f 100644 --- a/srnemqtt/solar_types.py +++ b/srnemqtt/solar_types.py @@ -22,7 +22,7 @@ class DataName(str, Enum): BATTERY_VOLTAGE_MIN = "battery_voltage_min" BATTERY_VOLTAGE_MAX = "battery_voltage_max" CHARGE_MAX_CURRENT = "charge_max_current" - _DISCHARGE_MAX_CURRENT = "_discharge_max_current?" + DISCHARGE_MAX_CURRENT = "discharge_max_current" CHARGE_MAX_POWER = "charge_max_power" DISCHARGE_MAX_POWER = "discharge_max_power" CHARGE_AMP_HOUR = "charge_amp_hour" @@ -105,7 +105,7 @@ HISTORICAL_DATA = [ DataItem(DataName.BATTERY_VOLTAGE_MIN, "H", "V", lambda n: n / 10), DataItem(DataName.BATTERY_VOLTAGE_MAX, "H", "V", lambda n: n / 10), DataItem(DataName.CHARGE_MAX_CURRENT, "H", "A", lambda n: n / 100), - DataItem(DataName._DISCHARGE_MAX_CURRENT, "H", "A", lambda n: n / 100), + DataItem(DataName.DISCHARGE_MAX_CURRENT, "H", "A", lambda n: n / 100), DataItem(DataName.CHARGE_MAX_POWER, "H", "W"), DataItem(DataName.DISCHARGE_MAX_POWER, "H", "W"), DataItem(DataName.CHARGE_AMP_HOUR, "H", "Ah"), @@ -161,7 +161,7 @@ class ChargerState(DecodedData): _panel_current, _panel_power, _load_enabled, - ) = struct.unpack("HHHBBHHHHHHx?", data) + ) = struct.unpack("!HHHBBHHHHHHx?", data) self.battery_charge = _battery_charge self.battery_voltage = _battery_voltage / 10 @@ -212,7 +212,7 @@ class HistoricalData(DecodedData): battery_voltage_min: float battery_voltage_max: float charge_max_current: float - _discharge_max_current: float + discharge_max_current: float charge_max_power: int discharge_max_power: int charge_amp_hour: int @@ -225,19 +225,19 @@ class HistoricalData(DecodedData): _battery_voltage_min, _battery_voltage_max, _charge_max_current, - __discharge_max_current, + _discharge_max_current, _charge_max_power, _discharge_max_power, _charge_amp_hour, _discharge_amp_hour, _production_energy, _consumption_energy, - ) = struct.unpack("HHHHHHHHHH", data) + ) = struct.unpack("!HHHHHHHHHH", data) self.battery_voltage_min = _battery_voltage_min / 10 self.battery_voltage_max = _battery_voltage_max / 10 self.charge_max_current = _charge_max_current / 100 - self._discharge_max_current = __discharge_max_current / 100 + self.discharge_max_current = _discharge_max_current / 100 self.charge_max_power = _charge_max_power self.discharge_max_power = _discharge_max_power self.charge_amp_hour = _charge_amp_hour @@ -250,7 +250,7 @@ class HistoricalData(DecodedData): DataName.BATTERY_VOLTAGE_MIN: self.battery_voltage_min, DataName.BATTERY_VOLTAGE_MAX: self.battery_voltage_max, DataName.CHARGE_MAX_CURRENT: self.charge_max_current, - DataName._DISCHARGE_MAX_CURRENT: self._discharge_max_current, + DataName.DISCHARGE_MAX_CURRENT: self.discharge_max_current, DataName.CHARGE_MAX_POWER: self.charge_max_power, DataName.DISCHARGE_MAX_POWER: self.discharge_max_power, DataName.CHARGE_AMP_HOUR: self.charge_amp_hour, @@ -278,7 +278,7 @@ class HistoricalExtraInfo(DecodedData): _total_discharge_amp_hours, _total_production_energy, _total_consumption_energy, - ) = struct.unpack("HHHLLLL", data) + ) = struct.unpack("!HHHLLLL", data) self.run_days = _run_days self.discharge_count = _discharge_count From 71919fc406bf44f54bf8dfaf50ad0ee865e15a5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Odd=20Str=C3=A5b=C3=B8?= Date: Sun, 10 Dec 2023 23:50:34 +0100 Subject: [PATCH 3/7] Make consumer aware of the charge controller --- srnemqtt/__main__.py | 5 +++++ srnemqtt/consumers/__init__.py | 3 +++ 2 files changed, 8 insertions(+) diff --git a/srnemqtt/__main__.py b/srnemqtt/__main__.py index 9fc1c3b..6d3a1bf 100755 --- a/srnemqtt/__main__.py +++ b/srnemqtt/__main__.py @@ -33,6 +33,11 @@ def main(): log("Connected.") cc = ChargeController(dev) + log(f"Controller model: {cc.model}") + log(f"Controller version: {cc.version}") + log(f"Controller serial: {cc.serial}") + for consumer in consumers: + consumer.controller = cc # write(dev, construct_request(0, 32)) diff --git a/srnemqtt/consumers/__init__.py b/srnemqtt/consumers/__init__.py index f1b8cf9..bd21596 100644 --- a/srnemqtt/consumers/__init__.py +++ b/srnemqtt/consumers/__init__.py @@ -2,9 +2,12 @@ from abc import ABC, abstractmethod from typing import Any, Dict +from ..protocol import ChargeController + class BaseConsumer(ABC): settings: Dict[str, Any] + controller: ChargeController | None = None @abstractmethod def __init__(self, settings: Dict[str, Any]) -> None: From 3aa6b13615eaf46e2a5d11788a92337e08b1ff6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Odd=20Str=C3=A5b=C3=B8?= Date: Sun, 10 Dec 2023 23:52:38 +0100 Subject: [PATCH 4/7] Fix writing of multiple words to charge controller --- srnemqtt/protocol.py | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/srnemqtt/protocol.py b/srnemqtt/protocol.py index 4229a4a..ff52a13 100644 --- a/srnemqtt/protocol.py +++ b/srnemqtt/protocol.py @@ -153,8 +153,8 @@ def readMemory(fh: BaseInterface, address: int, words: int = 1) -> Optional[byte def writeMemory(fh: BaseInterface, address: int, data: bytes): - if len(data) % 2: - raise ValueError(f"Data must consist of two-byte words, got {len(data)} bytes") + if len(data) != 2: + raise ValueError(f"Data must consist of a two-byte word, got {len(data)} bytes") header = construct_write_request(address) write(fh, header + data) @@ -166,7 +166,11 @@ def writeMemory(fh: BaseInterface, address: int, data: bytes): header = fh.read(3) if header and len(header) == 3: operation, size, address = header - rdata = fh.read(size * 2) + log(header) + # size field is zero when writing device name for whatever reason + # write command seems to only accept a single word, so this is fine; + # we just hardcode the number of bytes read to two here. + rdata = fh.read(2) _crc = fh.read(2) if rdata and _crc: try: @@ -183,6 +187,19 @@ def writeMemory(fh: BaseInterface, address: int, data: bytes): return None +def writeMemoryMultiple(fh: BaseInterface, address: int, data: bytes): + if len(data) % 2: + raise ValueError(f"Data must consist of two-byte words, got {len(data)} bytes") + res = bytearray() + for i in range(len(data) // 2): + d = data[i * 2 : (i + 1) * 2] + log(address + i, d) + r = writeMemory(fh, address + i, d) + if r: + res.extend(r) + return res + + def try_read_parse( dev: BaseInterface, address: int, From 4dc42ee6f5a9990078b548ef609ca2438233ba83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Odd=20Str=C3=A5b=C3=B8?= Date: Sun, 10 Dec 2023 23:54:13 +0100 Subject: [PATCH 5/7] Aggressively cache properties which are not expected to change at run time --- srnemqtt/protocol.py | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/srnemqtt/protocol.py b/srnemqtt/protocol.py index ff52a13..fab654a 100644 --- a/srnemqtt/protocol.py +++ b/srnemqtt/protocol.py @@ -232,8 +232,13 @@ class ChargeController: def __init__(self, device: BaseInterface): self.device = device + _cached_serial: str | None = None + @property def serial(self) -> str: + if self._cached_serial is not None: + return self._cached_serial + data = readMemory(self.device, 0x18, 3) if data is None: raise IOError # FIXME: Raise specific error in readMemory @@ -241,18 +246,31 @@ class ChargeController: p1 = data[0] p2 = data[1] p3 = (data[2] << 8) + data[3] - return f"{p1}-{p2}-{p3}" + + self._cached_serial = f"{p1}-{p2}-{p3}" + return self._cached_serial + + _cached_model: str | None = None @property def model(self) -> str: + if self._cached_model is not None: + return self._cached_model + data = readMemory(self.device, 0x0C, 8) if data is None: raise IOError # FIXME: Raise specific error in readMemory - return data.decode("utf-8").strip() + self._cached_model = data.decode("utf-8").strip() + return self._cached_model + + _cached_version: str | None = None @property def version(self) -> str: + if self._cached_version is not None: + return self._cached_version + data = readMemory(self.device, 0x14, 4) if data is None: raise IOError # FIXME: Raise specific error in readMemory @@ -261,7 +279,8 @@ class ChargeController: minor = data[2] patch = data[3] - return f"{major}.{minor}.{patch}" + self._cached_version = f"{major}.{minor}.{patch}" + return self._cached_version @property def load_enabled(self) -> bool: From 6c0f1c3d13d3601403b571284f7ab1e4c3ec79dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Odd=20Str=C3=A5b=C3=B8?= Date: Sun, 10 Dec 2023 23:55:26 +0100 Subject: [PATCH 6/7] Allow reading and writing device name --- misc/test_load_switch.py | 4 ++++ srnemqtt/protocol.py | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/misc/test_load_switch.py b/misc/test_load_switch.py index c5d7234..91a39ee 100644 --- a/misc/test_load_switch.py +++ b/misc/test_load_switch.py @@ -20,3 +20,7 @@ if __name__ == "__main__": sleep(5) cc.load_enabled = False print(f"Load enabled: {cc.load_enabled}") + + # print(f"Name: {cc.name}") + # cc.name = "☀️ 🔌🔋Charger" + # print(f"Name: {cc.name}") diff --git a/srnemqtt/protocol.py b/srnemqtt/protocol.py index fab654a..5d536e7 100644 --- a/srnemqtt/protocol.py +++ b/srnemqtt/protocol.py @@ -229,6 +229,9 @@ def try_read_parse( class ChargeController: device: BaseInterface + manufacturer: str = "SRNE Solar Co., Ltd." + manufacturer_id: str = "srne" + def __init__(self, device: BaseInterface): self.device = device @@ -282,6 +285,40 @@ class ChargeController: self._cached_version = f"{major}.{minor}.{patch}" return self._cached_version + _cached_name: str | None = None + + @property + def name(self) -> str: + if self._cached_name is not None: + return self._cached_name + data = readMemory(self.device, 0x0049, 16) + if data is None: + raise IOError + res = data.decode("UTF-16BE").strip() + return res + + @name.setter + def name(self, value: str): + bin_value = bytearray(value.encode("UTF-16BE")) + if len(bin_value) > 32: + raise ValueError( + f"value must be no more than 32 bytes once encoded as UTF-16BE. {len(bin_value)} bytes supplied" + ) + + # Pad name to 32 bytes to ensure ensure nothing is left of old name + while len(bin_value) < 32: + bin_value.extend(b"\x00\x20") + print(len(bin_value), bin_value) + + data = writeMemoryMultiple(self.device, 0x0049, bin_value) + if data is None: + raise IOError # FIXME: Raise specific error in readMemory + + res = data.decode("UTF-16BE").strip() + if res != value: + log(f"setting device name failed; {res!r} != {value!r}") + self._cached_name = value + @property def load_enabled(self) -> bool: data = readMemory(self.device, 0x010A, 1) From 67a25eeef9a76e847590de5a59804470f908e9e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Odd=20Str=C3=A5b=C3=B8?= Date: Sun, 10 Dec 2023 23:59:50 +0100 Subject: [PATCH 7/7] Rework and restructure MQTT --- srnemqtt/consumers/mqtt.py | 81 +++++++++++++++++++++++++------------- 1 file changed, 54 insertions(+), 27 deletions(-) diff --git a/srnemqtt/consumers/mqtt.py b/srnemqtt/consumers/mqtt.py index 51d26b1..6600db3 100644 --- a/srnemqtt/consumers/mqtt.py +++ b/srnemqtt/consumers/mqtt.py @@ -116,28 +116,37 @@ PayloadType: TypeAlias = str | bytes | bytearray | int | float | None class MqttConsumer(BaseConsumer): - client: mqtt.Client initialized: List[str] + _client: mqtt.Client | None = None + def __init__(self, settings: Dict[str, Any]) -> None: self.initialized = [] super().__init__(settings) - self.client = mqtt.Client(client_id=settings["client"]["id"], userdata=self) - self.client.on_connect = self.on_connect - self.client.on_message = self.on_message - self.client.on_disconnect = self.on_disconnect - self.client.on_connect_fail = self.on_connect_fail + + @property + def client(self) -> mqtt.Client: + if self._client is not None: + return self._client + + self._client = mqtt.Client( + client_id=self.settings["client"]["id"], userdata=self + ) + self._client.on_connect = self.on_connect + self._client.on_message = self.on_message + self._client.on_disconnect = self.on_disconnect + self._client.on_connect_fail = self.on_connect_fail # Will must be set before connecting!! - self.client.will_set( + self._client.will_set( f"{self.topic_prefix}/available", payload="offline", retain=True ) while True: try: - self.client.connect( - settings["client"]["host"], - settings["client"]["port"], - settings["client"]["keepalive"], + self._client.connect( + self.settings["client"]["host"], + self.settings["client"]["port"], + self.settings["client"]["keepalive"], ) break except OSError as err: @@ -151,6 +160,7 @@ class MqttConsumer(BaseConsumer): raise print(err) sleep(0.1) + return self._client def config(self, settings: Dict[str, Any]): super().config(settings) @@ -167,9 +177,19 @@ class MqttConsumer(BaseConsumer): settings.setdefault("discovery_prefix", "homeassistant") + _controller_id: str | None = None + + @property + def controller_id(self) -> str: + assert self.controller is not None + # Controller serial is fetched from device, cache it. + if self._controller_id is None: + self._controller_id = self.controller.serial + return f"{self.controller.manufacturer_id}_{self._controller_id}" + @property def topic_prefix(self): - return f"{self.settings['prefix']}/{self.settings['device_id']}" + return f"{self.settings['prefix']}/{self.controller_id}" def get_ha_config( self, @@ -181,21 +201,25 @@ class MqttConsumer(BaseConsumer): state_class: Optional[str] = None, ): assert state_class in [None, "measurement", "total", "total_increasing"] + assert self.controller is not None res = { "~": f"{self.topic_prefix}", - "unique_id": f"{self.settings['device_id']}_{id}", + "unique_id": f"{self.controller_id}_{id}", + "object_id": f"{self.controller_id}_{id}", "availability_topic": "~/available", "state_topic": f"~/{id}", "name": name, "device": { "identifiers": [ - self.settings["device_id"], + self.controller_id, ], - # TODO: Get charger serial and use for identifier instead - # See: https://www.home-assistant.io/integrations/sensor.mqtt/#device - # "via_device": self.settings["device_id"], + "manufacturer": self.controller.manufacturer, + "model": self.controller.model, + "hw_version": self.controller.version, + "via_device": self.settings["device_id"], "suggested_area": "Solar panel", + "name": self.controller.name, }, "force_update": True, "expire_after": expiry, @@ -253,22 +277,25 @@ class MqttConsumer(BaseConsumer): def write(self, data: Dict[str, PayloadType]): self.client.publish(f"{self.topic_prefix}/raw", payload=json.dumps(data)) - for k, v in data.items(): - if k in MAP_VALUES: - if k not in self.initialized: - km = MAP_VALUES[DataName(k)] - pretty_name = k.replace("_", " ").capitalize() + for dataname, data_value in data.items(): + if dataname in MAP_VALUES: + if dataname not in self.initialized: + km = MAP_VALUES[DataName(dataname)] + pretty_name = dataname.replace("_", " ").capitalize() disc_prefix = self.settings["discovery_prefix"] - device_id = self.settings["device_id"] self.client.publish( - f"{disc_prefix}/sensor/{device_id}_{k}/config", - payload=json.dumps(self.get_ha_config(k, pretty_name, **km)), + f"{disc_prefix}/sensor/{self.controller_id}/{dataname}/config", + payload=json.dumps( + self.get_ha_config(dataname, pretty_name, **km) + ), retain=True, ) - self.initialized.append(k) + self.initialized.append(dataname) - self.client.publish(f"{self.topic_prefix}/{k}", v, retain=True) + self.client.publish( + f"{self.topic_prefix}/{dataname}", data_value, retain=True + ) def exit(self): self.client.publish(