From dd7c43f7e71c3692e9c148279fe5297a1eb7ad81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Odd=20Str=C3=A5b=C3=B8?= <oddstr13@openshell.no> Date: Mon, 10 Apr 2023 03:39:19 +0200 Subject: [PATCH 01/25] Add support for the load switch Rework mqtt structure --- .pre-commit-config.yaml | 1 + requirements.txt | 1 + srnemqtt/__main__.py | 62 +++++++------------- srnemqtt/config.py | 12 ++-- srnemqtt/constants.py | 2 +- srnemqtt/consumers/__init__.py | 6 +- srnemqtt/consumers/mqtt.py | 58 ++++++++++++++++--- srnemqtt/consumers/stdio.py | 5 +- srnemqtt/protocol.py | 50 ++++++++++++++-- srnemqtt/solar_types.py | 3 + srnemqtt/srne.py | 103 +++++++++++++++++++++++++++++++++ 11 files changed, 239 insertions(+), 64 deletions(-) create mode 100644 srnemqtt/srne.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b615718..c63df7c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -41,6 +41,7 @@ repos: args: - "--install-types" - "--non-interactive" + - "--ignore-missing-imports" - repo: https://github.com/psf/black rev: 23.3.0 diff --git a/requirements.txt b/requirements.txt index 5a919f0..dafbd39 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,3 +6,4 @@ paho-mqtt pyserial types-PyYAML +types-paho-mqtt diff --git a/srnemqtt/__main__.py b/srnemqtt/__main__.py index 38d8058..f13b40a 100755 --- a/srnemqtt/__main__.py +++ b/srnemqtt/__main__.py @@ -2,47 +2,42 @@ # -*- coding: utf-8 -*- import time -from decimal import Decimal -from typing import cast +from typing import List, Optional, cast from bluepy.btle import BTLEDisconnectError from serial import SerialException +from srnemqtt.consumers import BaseConsumer + 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 .srne import Srne from .util import Periodical, log -class CommunicationError(BTLEDisconnectError, SerialException, IOError): +class CommunicationError(BTLEDisconnectError, SerialException, TimeoutError): pass -def main(): +def main() -> None: conf = get_config() - consumers = get_consumers(conf) + consumers: Optional[List[BaseConsumer]] = None per_voltages = Periodical(interval=15) per_current_hist = Periodical(interval=60) - # import serial - - # ser = serial.Serial() try: while True: try: log("Connecting...") with get_interface() as dev: + srne = Srne(dev) log("Connected.") - # write(dev, construct_request(0, 32)) + if consumers is None: + consumers = get_consumers(srne, conf) - # 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) + res = srne.get_historical_entry() if res: log(res) for consumer in consumers: @@ -50,9 +45,7 @@ def main(): days = cast(int, res.get("run_days", 7)) for i in range(days): - res = try_read_parse( - dev, 0xF000 + i, 10, parse_historical_entry - ) + res = srne.get_historical_entry(i) if res: log({i: res}) for consumer in consumers: @@ -62,40 +55,26 @@ def main(): now = time.time() if per_voltages(now): - data = try_read_parse(dev, 0x0100, 11, parse_battery_state) + data = srne.get_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) if per_current_hist(now): - data = try_read_parse( - dev, 0x010B, 21, parse_historical_entry - ) - if data: + try: + data = srne.get_historical_entry() log(data) for consumer in consumers: consumer.write(data) + except TimeoutError: + pass # print(".") for consumer in consumers: consumer.poll() - time.sleep(max(0, 1 - time.time() - now)) + time.sleep(max(0, 1 - time.time() - now)) # 1s loop # if STATUS.get('load_enabled'): # write(wd, CMD_DISABLE_LOAD) @@ -107,8 +86,9 @@ def main(): time.sleep(1) except (KeyboardInterrupt, SystemExit, Exception) as e: - for consumer in consumers: - consumer.exit() + if consumers is not None: + for consumer in consumers: + consumer.exit() if type(e) is not KeyboardInterrupt: raise diff --git a/srnemqtt/config.py b/srnemqtt/config.py index 4b3a4c1..301f2f7 100644 --- a/srnemqtt/config.py +++ b/srnemqtt/config.py @@ -6,9 +6,9 @@ from typing import Any, Dict, List, Optional, Type import yaml -from srnemqtt.interfaces import BaseInterface - from .consumers import BaseConsumer +from .interfaces import BaseInterface +from .srne import Srne def get_consumer(name: str) -> Optional[Type[BaseConsumer]]: @@ -38,7 +38,9 @@ def write_config(conf: Dict[str, Any]): os.rename(".config.yaml~writing", "config.yaml") -def get_consumers(conf: Optional[Dict[str, Any]] = None) -> List[BaseConsumer]: +def get_consumers( + srne: Srne, conf: Optional[Dict[str, Any]] = None +) -> List[BaseConsumer]: if conf is None: conf = get_config() @@ -48,7 +50,7 @@ def get_consumers(conf: Optional[Dict[str, Any]] = None) -> List[BaseConsumer]: mod = get_consumer(name) if mod: # print(mod) - consumers.append(mod(consumer_config)) + consumers.append(mod(settings=consumer_config, srne=srne)) write_config(conf) return consumers @@ -81,7 +83,7 @@ def get_interface(conf: Optional[Dict[str, Any]] = None) -> BaseInterface: if __name__ == "__main__": conf = get_config() - consumers = get_consumers(conf) + consumers = get_consumers(Srne(BaseInterface()), conf) try: while True: diff --git a/srnemqtt/constants.py b/srnemqtt/constants.py index 2a13eac..a68bb5c 100644 --- a/srnemqtt/constants.py +++ b/srnemqtt/constants.py @@ -5,7 +5,7 @@ MAC = "DC:0D:30:9C:61:BA" # read_service = "0000fff0-0000-1000-8000-00805f9b34fb" ACTION_READ = 0x03 -ACTION_WRITE = 0x03 +ACTION_WRITE = 0x06 POSSIBLE_MARKER = (0x01, 0xFD, 0xFE, 0xFF) diff --git a/srnemqtt/consumers/__init__.py b/srnemqtt/consumers/__init__.py index f1b8cf9..bc12ed4 100644 --- a/srnemqtt/consumers/__init__.py +++ b/srnemqtt/consumers/__init__.py @@ -2,12 +2,16 @@ from abc import ABC, abstractmethod from typing import Any, Dict +from ..srne import Srne + class BaseConsumer(ABC): settings: Dict[str, Any] + srne: Srne @abstractmethod - def __init__(self, settings: Dict[str, Any]) -> None: + def __init__(self, settings: Dict[str, Any], srne: Srne) -> None: + self.srne = srne self.config(settings) @abstractmethod diff --git a/srnemqtt/consumers/mqtt.py b/srnemqtt/consumers/mqtt.py index 6cd7497..772600a 100644 --- a/srnemqtt/consumers/mqtt.py +++ b/srnemqtt/consumers/mqtt.py @@ -7,6 +7,7 @@ from uuid import uuid4 import paho.mqtt.client as mqtt from ..solar_types import DataName +from ..srne import Srne from . import BaseConsumer MAP_VALUES: Dict[DataName, Dict[str, Any]] = { @@ -81,7 +82,15 @@ MAP_VALUES: Dict[DataName, Dict[str, Any]] = { "type": "current", "state_class": "measurement", }, - DataName.LOAD_POWER: {"unit": "W", "type": "power", "state_class": "measurement"}, + DataName.LOAD_POWER: { + "unit": "W", + "type": "power", + "state_class": "measurement", + }, + DataName.LOAD_ENABLED: { + "type": "outlet", + "platform": "switch", + }, DataName.PANEL_VOLTAGE: { "unit": "V", "type": "voltage", @@ -115,11 +124,12 @@ MAP_VALUES: Dict[DataName, Dict[str, Any]] = { class MqttConsumer(BaseConsumer): client: mqtt.Client initialized: List[str] + srne: Srne - def __init__(self, settings: Dict[str, Any]) -> None: + def __init__(self, settings: Dict[str, Any], srne: Srne) -> None: self.initialized = [] - super().__init__(settings) + super().__init__(settings, srne) 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 @@ -166,7 +176,7 @@ class MqttConsumer(BaseConsumer): @property def topic_prefix(self): - return f"{self.settings['prefix']}/{self.settings['device_id']}" + return f"{self.settings['prefix']}/{self.srne.serial}" def get_ha_config( self, @@ -176,23 +186,30 @@ class MqttConsumer(BaseConsumer): type: Optional[str] = None, expiry: int = 90, state_class: Optional[str] = None, + platform: str = "sensor", ): assert state_class in [None, "measurement", "total", "total_increasing"] res = { "~": f"{self.topic_prefix}", - "unique_id": f"{self.settings['device_id']}_{id}", + "unique_id": f"srne_{self.srne.serial}_{id}", + "object_id": f"srne_{self.srne.serial}_{id}", # Used for entity id "availability_topic": "~/available", "state_topic": f"~/{id}", "name": name, "device": { "identifiers": [ - self.settings["device_id"], + self.srne.serial, ], # 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"], "suggested_area": "Solar panel", + "manufacturer": "SRNE Solar", + "model": self.srne.model, + "name": self.srne.name, + "sw_version": self.srne.version, + "via_device": self.settings["device_id"], }, "force_update": True, "expire_after": expiry, @@ -204,6 +221,10 @@ class MqttConsumer(BaseConsumer): res["dev_cla"] = type if state_class: res["state_class"] = state_class + if platform == "switch": + res["command_topic"] = f"{res['state_topic']}/set" + res["payload_on"] = True + res["payload_off"] = False return res @@ -219,6 +240,27 @@ class MqttConsumer(BaseConsumer): f"{userdata.topic_prefix}/available", payload="online", retain=True ) + load_set_topic = f"{userdata.topic_prefix}/load_enabled/set" + client.message_callback_add(load_set_topic, userdata.on_load_switch) + client.subscribe(load_set_topic) + + @staticmethod + def on_load_switch( + client: mqtt.Client, userdata: "MqttConsumer", message: mqtt.MQTTMessage + ): + print(message) + print(message.info) + print(message.state) + print(message.payload) + payload = message.payload.decode().upper() in ("ON", "TRUE", "ENABLE", "YES") + if type(payload) is bool: + res = userdata.srne.enable_load(payload) + client.publish( + f"{userdata.topic_prefix}/load_enabled", payload=res, retain=True + ) + else: + print(f"!!! Unknown payload for switch callback: {message.payload!r}") + @staticmethod def on_connect_fail(client: mqtt.Client, userdata: "MqttConsumer"): print(userdata.__class__.__name__, "on_connect_fail") @@ -256,9 +298,9 @@ class MqttConsumer(BaseConsumer): km = MAP_VALUES[DataName(k)] pretty_name = k.replace("_", " ").capitalize() disc_prefix = self.settings["discovery_prefix"] - device_id = self.settings["device_id"] + platform = km.get("platform", "sensor") self.client.publish( - f"{disc_prefix}/sensor/{device_id}_{k}/config", + f"{disc_prefix}/{platform}/srne_{self.srne.serial}_{k}/config", payload=json.dumps(self.get_ha_config(k, pretty_name, **km)), retain=True, ) diff --git a/srnemqtt/consumers/stdio.py b/srnemqtt/consumers/stdio.py index df63e70..bf5a8e2 100644 --- a/srnemqtt/consumers/stdio.py +++ b/srnemqtt/consumers/stdio.py @@ -2,12 +2,13 @@ import json from typing import Any, Dict +from ..srne import Srne from . import BaseConsumer class StdoutConsumer(BaseConsumer): - def __init__(self, settings: Dict[str, Any]) -> None: - super().__init__(settings) + def __init__(self, settings: Dict[str, Any], srne: Srne) -> None: + super().__init__(settings, srne) def poll(self): return super().poll() diff --git a/srnemqtt/protocol.py b/srnemqtt/protocol.py index 160da0b..11dcd73 100644 --- a/srnemqtt/protocol.py +++ b/srnemqtt/protocol.py @@ -7,8 +7,8 @@ from typing import Callable, Collection, Optional from libscrc import modbus -from .constants import ACTION_READ, POSSIBLE_MARKER -from .lib.feasycom_ble import BTLEUart +from .constants import ACTION_READ, ACTION_WRITE, POSSIBLE_MARKER +from .interfaces import BaseInterface from .solar_types import DATA_BATTERY_STATE, HISTORICAL_DATA, DataItem from .util import log @@ -25,6 +25,11 @@ def construct_request(address, words=1, action=ACTION_READ, marker=0xFF): return struct.pack("!BBHH", marker, action, address, words) +def construct_write(address, data: bytes, action=ACTION_WRITE, marker=0xFF): + assert marker in POSSIBLE_MARKER, f"marker should be one of {POSSIBLE_MARKER}" + return struct.pack("!BBH", marker, action, address) + data + + def parse(data: bytes, items: Collection[DataItem], offset: int = 0) -> dict: pos = offset res = {} @@ -84,7 +89,6 @@ def discardUntil(fh: RawIOBase, byte: int, timeout=10) -> Optional[int]: discarded = 0 read_byte = expand(fh.read(1)) while read_byte != byte: - if read_byte is not None: if not discarded: log("Discarding", end="") @@ -105,7 +109,7 @@ def discardUntil(fh: RawIOBase, byte: int, timeout=10) -> Optional[int]: return read_byte -def readMemory(fh: RawIOBase, address: int, words: int = 1) -> Optional[bytes]: +def readMemory(fh: BaseInterface, address: int, words: int = 1) -> Optional[bytes]: # log(f"Reading {words} words from 0x{address:04X}") request = construct_request(address, words=words) # log("Request:", request) @@ -135,8 +139,42 @@ def readMemory(fh: RawIOBase, address: int, words: int = 1) -> Optional[bytes]: return None +def writeMemory(fh: BaseInterface, address: int, output_data: bytes) -> Optional[bytes]: + # TODO: Verify behavior on multi-word writes + # log(f"Reading {words} words from 0x{address:04X}") + request = construct_write(address, data=output_data) + # log("Request:", request) + write(fh, request) + + tag = discardUntil(fh, 0xFF) + if tag is None: + return None + + _operation = fh.read(1) + result_addr = fh.read(2) + # log("Operation:", _operation) + if _operation is not None and result_addr is not None: + operation = _operation[0] + data = fh.read(2) + # log("Data:", data) + _crc = fh.read(2) + if data and _crc: + try: + crc = struct.unpack_from("<H", _crc)[0] + except struct.error: + log(f"readMemory: CRC error; read {len(_crc)} bytes (2 expected)") + return None + calculated_crc = modbus(bytes([tag, operation, *result_addr, *data])) + if crc == calculated_crc: + return data + else: + log(f"readMemory: CRC error; {crc:04X} != {calculated_crc:04X}") + log("data or crc is falsely", operation, result_addr, data, _crc) + return None + + def try_read_parse( - dev: BTLEUart, + dev: BaseInterface, address: int, words: int = 1, parser: Optional[Callable] = None, @@ -152,7 +190,7 @@ def try_read_parse( except struct.error as e: log(e) log("0x0100 Unpack error:", len(res), res) - log("Flushed from read buffer; ", dev.read(timeout=0.5)) + log("Flushed from read buffer; ", dev.read()) # TODO: timeout=0.5 else: log(f"No data read, expected {words*2} bytes (attempts left: {attempts})") return None diff --git a/srnemqtt/solar_types.py b/srnemqtt/solar_types.py index 8fdcb83..a4833ae 100644 --- a/srnemqtt/solar_types.py +++ b/srnemqtt/solar_types.py @@ -43,6 +43,9 @@ class DataName(str, Enum): def __repr__(self): return repr(self.value) + def __str__(self): + return str(self.value) + class DataItem: name: DataName diff --git a/srnemqtt/srne.py b/srnemqtt/srne.py new file mode 100644 index 0000000..58b063e --- /dev/null +++ b/srnemqtt/srne.py @@ -0,0 +1,103 @@ +# -*- coding: utf-8 -*- +import struct +from decimal import Decimal +from functools import cached_property +from typing import Optional + +from .interfaces import BaseInterface +from .protocol import ( + parse_battery_state, + parse_historical_entry, + readMemory, + try_read_parse, + writeMemory, +) +from .solar_types import DataName + + +class Srne: + _dev: BaseInterface + + def __init__(self, dev: BaseInterface) -> None: + self._dev = dev + + def get_historical_entry(self, day: Optional[int] = None) -> dict: + address = 0x010B + words = 21 + if day is not None: + address = 0xF000 + day + res = try_read_parse(self._dev, address, words, parse_historical_entry) + + if res is None: + raise TimeoutError("Timeout reading historical entry") + return res + + def run_days(self) -> int: + return self.get_historical_entry()["run_days"] + + def get_battery_state(self) -> dict: + data = try_read_parse(self._dev, 0x0100, 11, parse_battery_state) + + if data is None: + raise TimeoutError("Timeout reading battery state") + + 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))) + ) + return data + + @cached_property + def model(self) -> str: + data = readMemory(self._dev, address=0x000C, words=8) + if data is None: + raise TimeoutError("Timeout reading model") + + return data.decode().strip() + + @cached_property + def version(self) -> str: + data = readMemory(self._dev, address=0x0014, words=2) + if data is None: + raise TimeoutError("Timeout reading version") + + return "{}.{}.{}".format(*struct.unpack("!HBB", data)) + + @cached_property + def serial(self) -> str: + data = readMemory(self._dev, address=0x0018, words=2) + if data is None: + raise TimeoutError("Timeout reading serial") + + return "{:02n}-{:02n}-{:04n}".format(*struct.unpack("!BBH", data)) + + @property + def load_enabled(self) -> bool: + data = readMemory(self._dev, address=0x010A) + if data is None: + raise TimeoutError("Timeout reading serial") + + return bool(struct.unpack("!xB", data)[0]) + + def enable_load(self, enable: bool) -> bool: + data = writeMemory(self._dev, 0x010A, bytes((0, enable))) + if data is None: + raise TimeoutError("Timeout reading serial") + print(data) + return bool(struct.unpack("!xB", data)[0]) + + @cached_property + def name(self) -> str: + data = readMemory(self._dev, address=0x0049, words=16) + if data is None: + raise TimeoutError("Timeout reading name") + + return data.decode("utf-16be").strip() From b6a62d123d6581771565e2577b740182dc62719c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Odd=20Str=C3=A5b=C3=B8?= <oddstr13@openshell.no> Date: Fri, 8 Dec 2023 12:36:26 +0100 Subject: [PATCH 02/25] Autoformat --- .vscode/settings.json | 5 +---- misc/render_rrd.py | 1 - misc/test_bleuart.py | 1 - srnemqtt/protocol.py | 1 - 4 files changed, 1 insertion(+), 7 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index f9808e9..1295ee6 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,6 +1,3 @@ { - "python.linting.mypyEnabled": true, - "python.formatting.provider": "black", - "editor.formatOnSave": true, - "python.linting.flake8Enabled": true + "editor.formatOnSave": true } diff --git a/misc/render_rrd.py b/misc/render_rrd.py index 2ca196e..769047b 100644 --- a/misc/render_rrd.py +++ b/misc/render_rrd.py @@ -147,7 +147,6 @@ def rrdupdate(file: str, timestamp: int, data: dict): def re_read(): - rrdtool.create( RRDFILE, # "--no-overwrite", diff --git a/misc/test_bleuart.py b/misc/test_bleuart.py index 1f28414..0d074d6 100644 --- a/misc/test_bleuart.py +++ b/misc/test_bleuart.py @@ -4,7 +4,6 @@ from srnemqtt.lib.feasycom_ble import BTLEUart from srnemqtt.protocol import construct_request, write with BTLEUart(MAC, timeout=1) as x: - print(x) write(x, construct_request(0x0E, words=3)) diff --git a/srnemqtt/protocol.py b/srnemqtt/protocol.py index 160da0b..8944ecd 100644 --- a/srnemqtt/protocol.py +++ b/srnemqtt/protocol.py @@ -84,7 +84,6 @@ def discardUntil(fh: RawIOBase, byte: int, timeout=10) -> Optional[int]: discarded = 0 read_byte = expand(fh.read(1)) while read_byte != byte: - if read_byte is not None: if not discarded: log("Discarding", end="") From 7977d89abf80126ae8996d9fafc10fb010c588b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Odd=20Str=C3=A5b=C3=B8?= <oddstr13@openshell.no> Date: Fri, 8 Dec 2023 13:45:05 +0100 Subject: [PATCH 03/25] mypy: ignore missing hints in external --- misc/render_rrd.py | 2 +- misc/test_serial.py | 2 +- misc/test_serial_loopback.py | 2 +- srnemqtt/__main__.py | 4 ++-- srnemqtt/interfaces/serial.py | 2 +- srnemqtt/lib/feasycom_ble.py | 2 +- srnemqtt/protocol.py | 2 +- 7 files changed, 8 insertions(+), 8 deletions(-) diff --git a/misc/render_rrd.py b/misc/render_rrd.py index 769047b..20d3553 100644 --- a/misc/render_rrd.py +++ b/misc/render_rrd.py @@ -5,7 +5,7 @@ from ast import literal_eval from collections import namedtuple from typing import Any, Dict -import rrdtool +import rrdtool # type: ignore from srnemqtt.solar_types import DataName diff --git a/misc/test_serial.py b/misc/test_serial.py index 7ea6505..49c6f11 100644 --- a/misc/test_serial.py +++ b/misc/test_serial.py @@ -3,7 +3,7 @@ import os import sys from time import sleep -from serial import Serial +from serial import Serial # type: ignore print(sys.path) sys.path.insert(1, os.path.dirname(os.path.dirname(sys.argv[0]))) diff --git a/misc/test_serial_loopback.py b/misc/test_serial_loopback.py index 3351171..590a14f 100644 --- a/misc/test_serial_loopback.py +++ b/misc/test_serial_loopback.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -from serial import Serial +from serial import Serial # type: ignore with Serial("/dev/ttyUSB0", baudrate=9600, timeout=2) as x: x.write(b"Hello, World!") diff --git a/srnemqtt/__main__.py b/srnemqtt/__main__.py index 38d8058..339bd0e 100755 --- a/srnemqtt/__main__.py +++ b/srnemqtt/__main__.py @@ -5,8 +5,8 @@ import time from decimal import Decimal from typing import cast -from bluepy.btle import BTLEDisconnectError -from serial import SerialException +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 diff --git a/srnemqtt/interfaces/serial.py b/srnemqtt/interfaces/serial.py index bee3ff6..82af005 100644 --- a/srnemqtt/interfaces/serial.py +++ b/srnemqtt/interfaces/serial.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -import serial +import serial # type: ignore from . import BaseInterface diff --git a/srnemqtt/lib/feasycom_ble.py b/srnemqtt/lib/feasycom_ble.py index 2f7f262..63317c2 100644 --- a/srnemqtt/lib/feasycom_ble.py +++ b/srnemqtt/lib/feasycom_ble.py @@ -4,7 +4,7 @@ import queue import time from typing import TYPE_CHECKING, Optional, cast -from bluepy import btle +from bluepy import btle # type: ignore if TYPE_CHECKING: from _typeshed import ReadableBuffer, WriteableBuffer diff --git a/srnemqtt/protocol.py b/srnemqtt/protocol.py index 8944ecd..c8c2241 100644 --- a/srnemqtt/protocol.py +++ b/srnemqtt/protocol.py @@ -5,7 +5,7 @@ import time from io import RawIOBase from typing import Callable, Collection, Optional -from libscrc import modbus +from libscrc import modbus # type: ignore from .constants import ACTION_READ, POSSIBLE_MARKER from .lib.feasycom_ble import BTLEUart From 3c8942b4857156653d18b14508c606606f856860 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Odd=20Str=C3=A5b=C3=B8?= <oddstr13@openshell.no> Date: Fri, 8 Dec 2023 13:45:38 +0100 Subject: [PATCH 04/25] Configure pytest --- .editorconfig | 3 +++ .vscode/settings.json | 9 +++++++-- tests/test_protocol.py | 11 +++++++++++ tox.ini | 4 ++++ 4 files changed, 25 insertions(+), 2 deletions(-) create mode 100644 tests/test_protocol.py diff --git a/.editorconfig b/.editorconfig index dc806e6..d9c54e2 100644 --- a/.editorconfig +++ b/.editorconfig @@ -16,3 +16,6 @@ indent_style = space [*.{yaml,yml,md}] indent_size = 2 + +[.vscode/*.json] +insert_final_newline = false diff --git a/.vscode/settings.json b/.vscode/settings.json index 1295ee6..2148ad5 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,3 +1,8 @@ { - "editor.formatOnSave": true -} + "editor.formatOnSave": true, + "pylint.args": [ + "--disable=missing-function-docstring,missing-class-docstring,missing-module-docstring" + ], + "python.testing.unittestEnabled": false, + "python.testing.pytestEnabled": true +} \ No newline at end of file diff --git a/tests/test_protocol.py b/tests/test_protocol.py new file mode 100644 index 0000000..f375394 --- /dev/null +++ b/tests/test_protocol.py @@ -0,0 +1,11 @@ +from io import BytesIO + +from srnemqtt.protocol import write as protocol_write + + +def test_write(): + fh = BytesIO() + protocol_write(fh, b"Hello, World!") + fh.seek(0) + + assert fh.read() == b"Hello, World!\x4E\x11" diff --git a/tox.ini b/tox.ini index edfab12..8a5a3ef 100644 --- a/tox.ini +++ b/tox.ini @@ -1,3 +1,7 @@ [flake8] max-line-length = 88 extend-ignore = E203, I201, I101 + +[pytest] +pythonpath = . +testpaths = tests From abbdd47c9df7ec45ae313c4be0885a430eb06548 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Odd=20Str=C3=A5b=C3=B8?= <oddstr13@openshell.no> Date: Fri, 8 Dec 2023 14:33:08 +0100 Subject: [PATCH 05/25] Add systemd unit --- solarmppt.service | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 solarmppt.service diff --git a/solarmppt.service b/solarmppt.service new file mode 100644 index 0000000..1dbb085 --- /dev/null +++ b/solarmppt.service @@ -0,0 +1,14 @@ +[Unit] +Description=Daemon for bridging a Serial SRNE MPPT charge controller to MQTT + +[Service] +Type=exec +Restart=on-failure +StandardOutput=append:/home/pi/z_solar_systemd.log +StandardError=append:/home/pi/z_solar_systemd.log +WorkingDirectory=/home/pi/ +Environment=PYTHONPATH=/home/pi/ble/ +ExecStart=/home/pi/ble-venv/bin/python -m srnemqtt + +[Install] +WantedBy=default.target From f2d59fac94b812f041d417a5bb34446e65844c77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Odd=20Str=C3=A5b=C3=B8?= <oddstr13@openshell.no> Date: Fri, 8 Dec 2023 20:51:58 +0100 Subject: [PATCH 06/25] Correct ACTION_WRITE --- srnemqtt/constants.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/srnemqtt/constants.py b/srnemqtt/constants.py index 2a13eac..a68bb5c 100644 --- a/srnemqtt/constants.py +++ b/srnemqtt/constants.py @@ -5,7 +5,7 @@ MAC = "DC:0D:30:9C:61:BA" # read_service = "0000fff0-0000-1000-8000-00805f9b34fb" ACTION_READ = 0x03 -ACTION_WRITE = 0x03 +ACTION_WRITE = 0x06 POSSIBLE_MARKER = (0x01, 0xFD, 0xFE, 0xFF) From 8282ec395623b831eceb0e0232a1cf1d4bf975b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Odd=20Str=C3=A5b=C3=B8?= <oddstr13@openshell.no> Date: Sat, 9 Dec 2023 11:48:53 +0100 Subject: [PATCH 07/25] Add tool for dumping memory map --- misc/draw_memory_map.py | 22 +++--- misc/dump_memory_map.py | 66 ++++++++++++++++ misc/memory_dump_MT2410.txt | 153 ++++++++++++++++++++++++++++++++++++ 3 files changed, 230 insertions(+), 11 deletions(-) create mode 100644 misc/dump_memory_map.py create mode 100644 misc/memory_dump_MT2410.txt diff --git a/misc/draw_memory_map.py b/misc/draw_memory_map.py index 37a3513..5aedbbd 100644 --- a/misc/draw_memory_map.py +++ b/misc/draw_memory_map.py @@ -99,20 +99,20 @@ def parse_log(fh, chunksize=32): yield None -with open("z_solar copy.log") as fh: - data = list(parse_log(fh)) - # print(data) +if __name__ == "__main__": + with open("z_solar copy.log") as fh: + data = list(parse_log(fh)) + # print(data) -# data = list(range(256)) + # data = list(range(256)) - -print( - memory_table( - data, - wordsize=2, - skip_nullrows=True, + print( + memory_table( + data, + wordsize=2, + skip_nullrows=True, + ) ) -) # diff --git a/misc/dump_memory_map.py b/misc/dump_memory_map.py new file mode 100644 index 0000000..36d0206 --- /dev/null +++ b/misc/dump_memory_map.py @@ -0,0 +1,66 @@ +# -*- coding: utf-8 -*- +import os +import sys +from io import RawIOBase +from typing import List + +sys.path.insert(1, os.path.dirname(os.path.dirname(sys.argv[0]))) + +from draw_memory_map import memory_table # noqa: E402 + +from srnemqtt.config import get_config, get_interface # noqa: E402 +from srnemqtt.protocol import readMemory # noqa: E402 + + +def get_device_name(iface: RawIOBase) -> str | None: + data = readMemory(iface, 0x0C, 8) + if data is None: + return None + + return data.decode("utf-8").strip() + + +def get_device_version(iface: RawIOBase) -> str | None: + data = readMemory(iface, 0x14, 4) + if data is None: + return None + + major = (data[0] << 8) + data[1] + minor = data[2] + patch = data[3] + + return f"{major}.{minor}.{patch}" + + +def get_device_serial(iface: RawIOBase) -> str | None: + data = readMemory(iface, 0x18, 3) + if data is None: + return None + + p1 = data[0] + p2 = data[1] + p3 = (data[2] << 8) + data[3] + return f"{p1}-{p2}-{p3}" + + +if __name__ == "__main__": + conf = get_config() + iface = get_interface(conf) + + print(get_device_name(iface)) + print(get_device_version(iface)) + print(get_device_serial(iface)) + + data: List[int] = [] + for i in range(0, 0xFFFF, 16): + newdata = readMemory(iface, i, 16) + if newdata: + data.extend(newdata) + # !!! FIXME: Naively assumes all queries return the exact words requested + print( + memory_table( + data, + wordsize=2, + skip_nullrows=True, + ) + ) diff --git a/misc/memory_dump_MT2410.txt b/misc/memory_dump_MT2410.txt new file mode 100644 index 0000000..a329446 --- /dev/null +++ b/misc/memory_dump_MT2410.txt @@ -0,0 +1,153 @@ +MT2410N10 +1.1.0 +13-19-740 +┌────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┐ +│ │ ···0│ ···1│ ···2│ ···3│ ···4│ ···5│ ···6│ ···7│ ···8│ ···9│ ···A│ ···B│ ···C│ ···D│ ···E│ ···F│ +├────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┤ +│000·│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│18 0A│0A 00│20 20│20 20│4D 54│32 34│ +├────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┤ +│ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ M T│ 2 4│ +├────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┤ +│001·│31 30│4E 31│30 20│20 20│00 01│01 00│02 00│00 01│0D 13│02 E4│00 01│00 00│00 00│03 09│14 02│0A 00│ +├────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┤ +│ │ 1 0│ N 1│ 0 │ │ │ │ │ │ │ ä│ │ │ │ │ │ │ +├────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┤ +│002·│00 02│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│ +├────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┤ +│003·│00 00│00 00│00 00│00 00│00 00│00 31│00 32│00 33│00 34│00 35│00 36│00 37│00 38│00 39│00 3A│00 3B│ +├────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┤ +│ │ │ │ │ │ │ 1│ 2│ 3│ 4│ 5│ 6│ 7│ 8│ 9│ :│ ;│ +├────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┤ +│004·│00 3C│00 3D│00 3E│00 3F│00 40│00 41│00 42│00 43│00 44│00 53│00 6F│00 6C│00 61│00 72│00 20│00 43│ +├────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┤ +│ │ <│ =│ >│ ?│ @│ A│ B│ C│ D│ S│ o│ l│ a│ r│ │ C│ +├────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┤ +│005·│00 68│00 61│00 72│00 67│00 65│00 72│00 20│00 20│00 20│00 00│00 00│00 00│00 00│00 00│00 00│00 00│ +├────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┤ +│ │ h│ a│ r│ g│ e│ r│ │ │ │ │ │ │ │ │ │ │ +├────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┤ +├────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┤ +│010·│00 64│00 85│00 00│15 19│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 7E│00 86│00 00│00 00│00 00│ +├────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┤ +│ │ d│ │ │ │ │ │ │ │ │ │ │ ~│ │ │ │ │ +├────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┤ +│011·│00 00│00 00│00 00│00 00│00 00│00 01│00 01│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│ +├────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┤ +├────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┤ +│DF0·│00 01│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│ +├────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┤ +├────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┤ +│DF2·│44 44│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│ +├────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┤ +│ │ D D│ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ +├────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┤ +├────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┤ +│DF4·│00 00│00 00│00 00│00 00│00 00│00 00│00 00│44 44│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│ +├────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┤ +│ │ │ │ │ │ │ │ │ D D│ │ │ │ │ │ │ │ │ +├────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┤ +├────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┤ +│E00·│00 00│03 E8│00 C8│FF 0C│00 02│00 A0│00 9B│00 92│00 90│00 8A│00 84│00 7E│00 78│00 6F│00 6A│64 32│ +├────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┤ +│ │ │ è│ È│ ÿ │ │ │ │ │ │ │ │ ~│ x│ o│ j│ d 2│ +├────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┤ +│E01·│00 05│00 78│00 78│00 1E│00 03│00 41│00 A3│00 4B│00 A3│00 00│00 00│00 00│00 00│00 0F│00 05│00 05│ +├────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┤ +│ │ │ x│ x│ │ │ A│ £│ K│ £│ │ │ │ │ │ │ │ +├────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┤ +│E02·│00 04│01 00│00 00│00 01│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│ +├────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┤ +├────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┤ +│E30·│66 66│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│ +├────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┤ +│ │ f f│ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ +├────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┤ +│E31·│00 00│00 00│00 00│00 64│00 32│00 64│00 32│00 3C│00 05│00 C8│00 02│02 BC│00 0A│03 84│03 84│02 58│ +├────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┤ +│ │ │ │ │ d│ 2│ d│ 2│ <│ │ È│ │ ¼│ │ │ │ X│ +├────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┤ +│E32·│00 14│00 60│00 00│00 00│00 00│00 00│00 00│00 00│00 01│66 66│00 00│00 00│00 00│00 00│00 00│00 00│ +├────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┤ +│ │ │ `│ │ │ │ │ │ │ │ f f│ │ │ │ │ │ │ +├────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┤ +├────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┤ +│F00·│00 7E│00 86│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│ +├────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┤ +│ │ ~│ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ +├────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┤ +├────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┤ +│F0A·│00 7E│00 86│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│ +├────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┤ +│ │ ~│ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ +├────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┤ +├────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┤ +│F14·│00 7E│00 86│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│ +├────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┤ +│ │ ~│ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ +├────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┤ +├────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┤ +│F1E·│00 7E│00 86│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│ +├────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┤ +│ │ ~│ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ +├────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┤ +├────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┤ +│F28·│00 7E│00 86│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│ +├────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┤ +│ │ ~│ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ +├────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┤ +├────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┤ +│F32·│00 7E│00 86│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│ +├────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┤ +│ │ ~│ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ +├────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┤ +├────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┤ +│F3C·│00 7E│00 86│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│ +├────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┤ +│ │ ~│ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ +├────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┤ +├────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┤ +│F46·│00 7E│00 86│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│ +├────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┤ +│ │ ~│ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ +├────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┤ +├────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┤ +│F50·│00 7E│00 86│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│ +├────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┤ +│ │ ~│ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ +├────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┤ +├────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┤ +│F5A·│00 7E│00 86│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│ +├────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┤ +│ │ ~│ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ +├────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┤ +├────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┤ +│F64·│00 7E│00 86│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│ +├────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┤ +│ │ ~│ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ +├────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┤ +├────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┤ +│F6E·│00 7E│00 86│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│ +├────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┤ +│ │ ~│ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ +├────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┤ +├────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┤ +│F78·│00 7E│00 86│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│ +├────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┤ +│ │ ~│ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ +├────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┤ +├────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┤ +│F82·│00 7E│00 86│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│ +├────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┤ +│ │ ~│ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ +├────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┤ +├────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┤ +│F8C·│00 7E│00 86│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│ +├────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┤ +│ │ ~│ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ +├────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┤ +├────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┤ +│F96·│00 7E│00 86│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│00 00│ +├────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┤ +│ │ ~│ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ +├────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┤ +└────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┘ From 457e7cf8a3b4f0949e8c6ce41eaca5099f9bfd51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Odd=20Str=C3=A5b=C3=B8?= <oddstr13@openshell.no> Date: Sat, 9 Dec 2023 16:35:45 +0100 Subject: [PATCH 08/25] Fix mypy issues --- .pre-commit-config.yaml | 5 ++++- .vscode/settings.json | 8 +++++++- misc/dump_memory_map.py | 8 ++++---- misc/test_serial.py | 3 ++- srnemqtt/config.py | 3 +-- srnemqtt/consumers/mqtt.py | 12 ++++++++---- srnemqtt/interfaces/__init__.py | 2 +- srnemqtt/lib/feasycom_ble.py | 32 +++++++++++--------------------- srnemqtt/protocol.py | 31 +++++++++++++++++-------------- tox.ini | 2 +- 10 files changed, 56 insertions(+), 50 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b615718..0103a8e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -5,7 +5,7 @@ repos: rev: v4.4.0 hooks: - id: trailing-whitespace - - id: end-of-file-fixer + #- id: end-of-file-fixer - id: fix-byte-order-marker - id: fix-encoding-pragma - id: check-executables-have-shebangs @@ -41,6 +41,9 @@ repos: args: - "--install-types" - "--non-interactive" + - "--check-untyped-defs" + additional_dependencies: + - typing_extensions==4.8.0 - repo: https://github.com/psf/black rev: 23.3.0 diff --git a/.vscode/settings.json b/.vscode/settings.json index 2148ad5..727a3b0 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -4,5 +4,11 @@ "--disable=missing-function-docstring,missing-class-docstring,missing-module-docstring" ], "python.testing.unittestEnabled": false, - "python.testing.pytestEnabled": true + "python.testing.pytestEnabled": true, + "mypy-type-checker.importStrategy": "fromEnvironment", + "mypy-type-checker.reportingScope": "workspace", + "mypy-type-checker.preferDaemon": true, + "mypy-type-checker.args": [ + "--check-untyped-defs" + ] } \ No newline at end of file diff --git a/misc/dump_memory_map.py b/misc/dump_memory_map.py index 36d0206..c69ef64 100644 --- a/misc/dump_memory_map.py +++ b/misc/dump_memory_map.py @@ -1,7 +1,6 @@ # -*- coding: utf-8 -*- import os import sys -from io import RawIOBase from typing import List sys.path.insert(1, os.path.dirname(os.path.dirname(sys.argv[0]))) @@ -9,10 +8,11 @@ sys.path.insert(1, os.path.dirname(os.path.dirname(sys.argv[0]))) from draw_memory_map import memory_table # noqa: E402 from srnemqtt.config import get_config, get_interface # noqa: E402 +from srnemqtt.interfaces import BaseInterface # noqa: E402 from srnemqtt.protocol import readMemory # noqa: E402 -def get_device_name(iface: RawIOBase) -> str | None: +def get_device_name(iface: BaseInterface) -> str | None: data = readMemory(iface, 0x0C, 8) if data is None: return None @@ -20,7 +20,7 @@ def get_device_name(iface: RawIOBase) -> str | None: return data.decode("utf-8").strip() -def get_device_version(iface: RawIOBase) -> str | None: +def get_device_version(iface: BaseInterface) -> str | None: data = readMemory(iface, 0x14, 4) if data is None: return None @@ -32,7 +32,7 @@ def get_device_version(iface: RawIOBase) -> str | None: return f"{major}.{minor}.{patch}" -def get_device_serial(iface: RawIOBase) -> str | None: +def get_device_serial(iface: BaseInterface) -> str | None: data = readMemory(iface, 0x18, 3) if data is None: return None diff --git a/misc/test_serial.py b/misc/test_serial.py index 49c6f11..c55be89 100644 --- a/misc/test_serial.py +++ b/misc/test_serial.py @@ -11,7 +11,8 @@ sys.path.insert(1, os.path.dirname(os.path.dirname(sys.argv[0]))) # from srnemqtt.lib.feasycom_ble import BTLEUart from srnemqtt.protocol import construct_request, write # noqa: E402 -for rate in [1200, 2400, 4800, 9600, 115200]: +# for rate in [1200, 2400, 4800, 9600, 115200]: +for rate in [9600]: print(rate) with Serial("/dev/ttyUSB0", baudrate=rate, timeout=2) as x: sleep(2) diff --git a/srnemqtt/config.py b/srnemqtt/config.py index 4b3a4c1..fd5ec8c 100644 --- a/srnemqtt/config.py +++ b/srnemqtt/config.py @@ -6,9 +6,8 @@ from typing import Any, Dict, List, Optional, Type import yaml -from srnemqtt.interfaces import BaseInterface - from .consumers import BaseConsumer +from .interfaces import BaseInterface def get_consumer(name: str) -> Optional[Type[BaseConsumer]]: diff --git a/srnemqtt/consumers/mqtt.py b/srnemqtt/consumers/mqtt.py index 6cd7497..21a29a2 100644 --- a/srnemqtt/consumers/mqtt.py +++ b/srnemqtt/consumers/mqtt.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- import json from time import sleep -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Optional, TypeAlias from uuid import uuid4 import paho.mqtt.client as mqtt @@ -112,6 +112,9 @@ MAP_VALUES: Dict[DataName, Dict[str, Any]] = { } +PayloadType: TypeAlias = str | bytes | bytearray | int | float | None + + class MqttConsumer(BaseConsumer): client: mqtt.Client initialized: List[str] @@ -170,8 +173,8 @@ class MqttConsumer(BaseConsumer): def get_ha_config( self, - id, - name, + id: str, + name: str, unit: Optional[str] = None, type: Optional[str] = None, expiry: int = 90, @@ -247,7 +250,7 @@ class MqttConsumer(BaseConsumer): return super().poll() - def write(self, data: Dict[str, Any]): + 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(): @@ -257,6 +260,7 @@ class MqttConsumer(BaseConsumer): pretty_name = k.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)), diff --git a/srnemqtt/interfaces/__init__.py b/srnemqtt/interfaces/__init__.py index e8ecc37..5b3bdbd 100644 --- a/srnemqtt/interfaces/__init__.py +++ b/srnemqtt/interfaces/__init__.py @@ -4,4 +4,4 @@ from io import RawIOBase class BaseInterface(RawIOBase, metaclass=ABCMeta): - pass + timeout: float | None diff --git a/srnemqtt/lib/feasycom_ble.py b/srnemqtt/lib/feasycom_ble.py index 63317c2..624093d 100644 --- a/srnemqtt/lib/feasycom_ble.py +++ b/srnemqtt/lib/feasycom_ble.py @@ -6,10 +6,6 @@ from typing import TYPE_CHECKING, Optional, cast from bluepy import btle # type: ignore -if TYPE_CHECKING: - from _typeshed import ReadableBuffer, WriteableBuffer - - WRITE_DEVICE = "0000ffd1-0000-1000-8000-00805f9b34fb" READ_DEVICE = "0000fff1-0000-1000-8000-00805f9b34fb" @@ -18,7 +14,7 @@ class BTLEUart(io.RawIOBase): mac: str write_endpoint: str read_endpoint: str - timeout: float + timeout: float | None device: Optional[btle.Peripheral] = None _write_handle: Optional[btle.Characteristic] = None @@ -86,13 +82,12 @@ class BTLEUart(io.RawIOBase): self._write_handle = self.device.getCharacteristics(uuid=self.write_endpoint)[0] # print("Handles:", self._read_handle.handle, self._write_handle.handle) - def _read(self, num: Optional[int] = None, timeout: Optional[float] = None): + def _read(self, num: Optional[int] = None): self._ensure_connected() if TYPE_CHECKING: self.device = cast(btle.Peripheral, self.device) - if timeout is None: - timeout = self.timeout + timeout = self.timeout or 30 if num is None: start = time.time() @@ -132,7 +127,9 @@ class BTLEUart(io.RawIOBase): del self._read_buffer[:num] return data or None - def readinto(self, buffer: "WriteableBuffer") -> Optional[int]: + def readinto(self, buffer: bytearray | memoryview) -> Optional[int]: # type: ignore [override] + # Buffer does not provide Sized, and bytes is read only. + # bytearray | memoryview is the default implementations that provide WriteableBuffer data = self._read(len(buffer)) if data is None: @@ -144,23 +141,15 @@ class BTLEUart(io.RawIOBase): def readall(self) -> bytes: return self._read() - def read( - self, size: Optional[int] = None, timeout: Optional[float] = None - ) -> Optional[bytes]: - if timeout: - _timeout = self.timeout - self.timeout = timeout - + def read(self, size: Optional[int] = None) -> Optional[bytes]: if size is None: res = super().read() else: res = super().read(size) - if timeout: - self.timeout = _timeout return res - def write(self, b: "ReadableBuffer") -> Optional[int]: + def write(self, b: bytes | bytearray | memoryview) -> Optional[int]: # type: ignore [override] self._ensure_connected() if TYPE_CHECKING: self.device = cast(btle.Peripheral, self.device) @@ -174,8 +163,9 @@ class BTLEUart(io.RawIOBase): return self def __exit__(self, type, value, traceback): - self.device.disconnect() - del self.device + if self.device is not None: + self.device.disconnect() + self.device = None def seekable(self) -> bool: return False diff --git a/srnemqtt/protocol.py b/srnemqtt/protocol.py index c8c2241..f371db3 100644 --- a/srnemqtt/protocol.py +++ b/srnemqtt/protocol.py @@ -2,13 +2,12 @@ import struct import sys import time -from io import RawIOBase from typing import Callable, Collection, Optional from libscrc import modbus # type: ignore from .constants import ACTION_READ, POSSIBLE_MARKER -from .lib.feasycom_ble import BTLEUart +from .interfaces import BaseInterface from .solar_types import DATA_BATTERY_STATE, HISTORICAL_DATA, DataItem from .util import log @@ -61,23 +60,23 @@ def parse_packet(data): if crc != calculated_crc: e = ValueError(f"CRC missmatch: expected {crc:04X}, got {calculated_crc:04X}.") - e.tag = tag - e.operation = operation - e.size = size - e.payload = payload - e.crc = crc - e.calculated_crc = calculated_crc + # e.tag = tag + # e.operation = operation + # e.size = size + # e.payload = payload + # e.crc = crc + # e.calculated_crc = calculated_crc raise e return payload -def discardUntil(fh: RawIOBase, byte: int, timeout=10) -> Optional[int]: +def discardUntil(fh: BaseInterface, byte: int, timeout=10) -> Optional[int]: assert byte >= 0 and byte < 256, f"byte: Expected 8bit unsigned int, got {byte}" def expand(b: Optional[bytes]): - if b is None: - return b + if not b: + return None return b[0] start = time.time() @@ -88,6 +87,7 @@ def discardUntil(fh: RawIOBase, byte: int, timeout=10) -> Optional[int]: if not discarded: log("Discarding", end="") discarded += 1 + print(read_byte) print(f" {read_byte:02X}", end="") sys.stdout.flush() @@ -104,7 +104,7 @@ def discardUntil(fh: RawIOBase, byte: int, timeout=10) -> Optional[int]: return read_byte -def readMemory(fh: RawIOBase, address: int, words: int = 1) -> Optional[bytes]: +def readMemory(fh: BaseInterface, address: int, words: int = 1) -> Optional[bytes]: # log(f"Reading {words} words from 0x{address:04X}") request = construct_request(address, words=words) # log("Request:", request) @@ -135,7 +135,7 @@ def readMemory(fh: RawIOBase, address: int, words: int = 1) -> Optional[bytes]: def try_read_parse( - dev: BTLEUart, + dev: BaseInterface, address: int, words: int = 1, parser: Optional[Callable] = None, @@ -151,7 +151,10 @@ def try_read_parse( except struct.error as e: log(e) log("0x0100 Unpack error:", len(res), res) - log("Flushed from read buffer; ", dev.read(timeout=0.5)) + _timeout = dev.timeout + dev.timeout = 0.5 + log("Flushed from read buffer; ", dev.read()) + dev.timeout = _timeout else: log(f"No data read, expected {words*2} bytes (attempts left: {attempts})") return None diff --git a/tox.ini b/tox.ini index 8a5a3ef..c24b36c 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [flake8] -max-line-length = 88 +max-line-length = 120 extend-ignore = E203, I201, I101 [pytest] From 9bb8e2e02eb4540fbea42560d1bd861513a4595a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Odd=20Str=C3=A5b=C3=B8?= <oddstr13@openshell.no> Date: Sat, 9 Dec 2023 16:36:25 +0100 Subject: [PATCH 09/25] Make sure DataName returns the correct value --- srnemqtt/solar_types.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/srnemqtt/solar_types.py b/srnemqtt/solar_types.py index 8fdcb83..ee926a0 100644 --- a/srnemqtt/solar_types.py +++ b/srnemqtt/solar_types.py @@ -43,6 +43,9 @@ class DataName(str, Enum): def __repr__(self): return repr(self.value) + def __str__(self): + return repr(self) + class DataItem: name: DataName From b33a466c4fb0b84907ae44e8fe240bee2af6d138 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Odd=20Str=C3=A5b=C3=B8?= <oddstr13@openshell.no> Date: Sat, 9 Dec 2023 16:37:09 +0100 Subject: [PATCH 10/25] Add restarting systemd service to deploy --- deploy.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/deploy.sh b/deploy.sh index dc92d5b..79979b7 100755 --- a/deploy.sh +++ b/deploy.sh @@ -1,2 +1,3 @@ #!/bin/bash rsync --exclude=.git --exclude='packet format.java' --exclude-from=.gitignore -ravC . pi@solarpi:ble +ssh pi@solarpi 'systemctl --user daemon-reload; systemctl --user restart solarmppt' From 654486474159488b0b2822be9b05098703dc1883 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Odd=20Str=C3=A5b=C3=B8?= <oddstr13@openshell.no> Date: Sat, 9 Dec 2023 16:46:52 +0100 Subject: [PATCH 11/25] Make sure DataName returns the correct value --- srnemqtt/solar_types.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/srnemqtt/solar_types.py b/srnemqtt/solar_types.py index ee926a0..c79b967 100644 --- a/srnemqtt/solar_types.py +++ b/srnemqtt/solar_types.py @@ -44,7 +44,7 @@ class DataName(str, Enum): return repr(self.value) def __str__(self): - return repr(self) + return self.value class DataItem: From 5599cb6f43e4d0082679e03a432738961d2bc0a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Odd=20Str=C3=A5b=C3=B8?= <oddstr13@openshell.no> Date: Sat, 9 Dec 2023 18:57:37 +0100 Subject: [PATCH 12/25] Properly implement writing memory Start implementing abstraction class --- misc/dump_memory_map.py | 37 ----------- misc/test_bleuart.py | 4 +- misc/test_load_switch.py | 22 +++++++ misc/test_serial.py | 4 +- srnemqtt/protocol.py | 133 +++++++++++++++++++++++++++++++++++++-- srnemqtt/solar_types.py | 5 ++ 6 files changed, 158 insertions(+), 47 deletions(-) create mode 100644 misc/test_load_switch.py diff --git a/misc/dump_memory_map.py b/misc/dump_memory_map.py index c69ef64..f9cbc2a 100644 --- a/misc/dump_memory_map.py +++ b/misc/dump_memory_map.py @@ -8,49 +8,12 @@ sys.path.insert(1, os.path.dirname(os.path.dirname(sys.argv[0]))) from draw_memory_map import memory_table # noqa: E402 from srnemqtt.config import get_config, get_interface # noqa: E402 -from srnemqtt.interfaces import BaseInterface # noqa: E402 from srnemqtt.protocol import readMemory # noqa: E402 - -def get_device_name(iface: BaseInterface) -> str | None: - data = readMemory(iface, 0x0C, 8) - if data is None: - return None - - return data.decode("utf-8").strip() - - -def get_device_version(iface: BaseInterface) -> str | None: - data = readMemory(iface, 0x14, 4) - if data is None: - return None - - major = (data[0] << 8) + data[1] - minor = data[2] - patch = data[3] - - return f"{major}.{minor}.{patch}" - - -def get_device_serial(iface: BaseInterface) -> str | None: - data = readMemory(iface, 0x18, 3) - if data is None: - return None - - p1 = data[0] - p2 = data[1] - p3 = (data[2] << 8) + data[3] - return f"{p1}-{p2}-{p3}" - - if __name__ == "__main__": conf = get_config() iface = get_interface(conf) - print(get_device_name(iface)) - print(get_device_version(iface)) - print(get_device_serial(iface)) - data: List[int] = [] for i in range(0, 0xFFFF, 16): newdata = readMemory(iface, i, 16) diff --git a/misc/test_bleuart.py b/misc/test_bleuart.py index 0d074d6..7e79f1f 100644 --- a/misc/test_bleuart.py +++ b/misc/test_bleuart.py @@ -1,12 +1,12 @@ # -*- coding: utf-8 -*- from srnemqtt.constants import MAC from srnemqtt.lib.feasycom_ble import BTLEUart -from srnemqtt.protocol import construct_request, write +from srnemqtt.protocol import construct_read_request, write with BTLEUart(MAC, timeout=1) as x: print(x) - write(x, construct_request(0x0E, words=3)) + write(x, construct_read_request(0x0E, words=3)) x.read(3, timeout=1) print(x.read(6, timeout=0.01)) x.read(2, timeout=0.01) diff --git a/misc/test_load_switch.py b/misc/test_load_switch.py new file mode 100644 index 0000000..c5d7234 --- /dev/null +++ b/misc/test_load_switch.py @@ -0,0 +1,22 @@ +# -*- coding: utf-8 -*- +import os +import sys +from time import sleep + +sys.path.insert(1, os.path.dirname(os.path.dirname(sys.argv[0]))) + +from srnemqtt.config import get_config, get_interface # noqa: E402 +from srnemqtt.protocol import ChargeController # noqa: E402 + +if __name__ == "__main__": + conf = get_config() + iface = get_interface(conf) + cc = ChargeController(iface) + + print(f"Serial: {cc.serial}") + print(f"Load enabled: {cc.load_enabled}") + cc.load_enabled = True + print(f"Load enabled: {cc.load_enabled}") + sleep(5) + cc.load_enabled = False + print(f"Load enabled: {cc.load_enabled}") diff --git a/misc/test_serial.py b/misc/test_serial.py index c55be89..dacf5b8 100644 --- a/misc/test_serial.py +++ b/misc/test_serial.py @@ -9,7 +9,7 @@ print(sys.path) sys.path.insert(1, os.path.dirname(os.path.dirname(sys.argv[0]))) # from srnemqtt.constants import MAC # from srnemqtt.lib.feasycom_ble import BTLEUart -from srnemqtt.protocol import construct_request, write # noqa: E402 +from srnemqtt.protocol import construct_read_request, write # noqa: E402 # for rate in [1200, 2400, 4800, 9600, 115200]: for rate in [9600]: @@ -19,7 +19,7 @@ for rate in [9600]: print(x) - write(x, construct_request(0x0E, words=3)) + write(x, construct_read_request(0x0E, words=3)) print(x.read(3)) print(x.read(6)) print(x.read(2)) diff --git a/srnemqtt/protocol.py b/srnemqtt/protocol.py index f371db3..be711ef 100644 --- a/srnemqtt/protocol.py +++ b/srnemqtt/protocol.py @@ -6,9 +6,9 @@ from typing import Callable, Collection, Optional from libscrc import modbus # type: ignore -from .constants import ACTION_READ, POSSIBLE_MARKER +from .constants import ACTION_READ, ACTION_WRITE, POSSIBLE_MARKER from .interfaces import BaseInterface -from .solar_types import DATA_BATTERY_STATE, HISTORICAL_DATA, DataItem +from .solar_types import DATA_BATTERY_STATE, HISTORICAL_DATA, ChargerState, DataItem from .util import log @@ -19,9 +19,14 @@ def write(fh, data): fh.write(data + bcrc) -def construct_request(address, words=1, action=ACTION_READ, marker=0xFF): +def construct_read_request(address, words=1, marker=0xFF): assert marker in POSSIBLE_MARKER, f"marker should be one of {POSSIBLE_MARKER}" - return struct.pack("!BBHH", marker, action, address, words) + return struct.pack("!BBHH", marker, ACTION_READ, address, words) + + +def construct_write_request(address, marker=0xFF): + assert marker in POSSIBLE_MARKER, f"marker should be one of {POSSIBLE_MARKER}" + return struct.pack("!BBH", marker, ACTION_WRITE, address) def parse(data: bytes, items: Collection[DataItem], offset: int = 0) -> dict: @@ -87,7 +92,6 @@ def discardUntil(fh: BaseInterface, byte: int, timeout=10) -> Optional[int]: if not discarded: log("Discarding", end="") discarded += 1 - print(read_byte) print(f" {read_byte:02X}", end="") sys.stdout.flush() @@ -106,7 +110,7 @@ def discardUntil(fh: BaseInterface, byte: int, timeout=10) -> Optional[int]: def readMemory(fh: BaseInterface, address: int, words: int = 1) -> Optional[bytes]: # log(f"Reading {words} words from 0x{address:04X}") - request = construct_request(address, words=words) + request = construct_read_request(address, words=words) # log("Request:", request) write(fh, request) @@ -134,6 +138,44 @@ def readMemory(fh: BaseInterface, address: int, words: int = 1) -> Optional[byte return None +# set(255, 266, 1 or 0) +# ff 06 01 0a 00 01 +# CMD_ENABLE_LOAD = b"\xff\x06\x01\x0a\x00\x01" +# CMD_DISABLE_LOAD = b"\xff\x06\x01\x0a\x00\x00" +# REG_LOAD_ENABLE = 0x010A + + +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") + + header = construct_write_request(address) + write(fh, header + data) + + tag = discardUntil(fh, 0xFF) + if tag is None: + return None + + header = fh.read(3) + if header and len(header) == 3: + operation, size, address = header + rdata = fh.read(size * 2) + _crc = fh.read(2) + if rdata and _crc: + try: + crc = struct.unpack_from("<H", _crc)[0] + except struct.error: + log(f"writeMemory: CRC error; read {len(_crc)} bytes (2 expected)") + return None + calculated_crc = modbus(bytes([tag, operation, size, address, *rdata])) + if crc == calculated_crc: + return rdata + else: + log(f"writeMemory: CRC error; {crc:04X} != {calculated_crc:04X}") + log("data or crc is falsely", header, rdata, _crc) + return None + + def try_read_parse( dev: BaseInterface, address: int, @@ -158,3 +200,82 @@ def try_read_parse( else: log(f"No data read, expected {words*2} bytes (attempts left: {attempts})") return None + + +class ChargeController: + device: BaseInterface + + def __init__(self, device: BaseInterface): + self.device = device + + @property + def serial(self) -> str: + data = readMemory(self.device, 0x18, 3) + if data is None: + raise IOError + + p1 = data[0] + p2 = data[1] + p3 = (data[2] << 8) + data[3] + return f"{p1}-{p2}-{p3}" + + @property + def model(self) -> str: + data = readMemory(self.device, 0x0C, 8) + if data is None: + raise IOError + + return data.decode("utf-8").strip() + + @property + def version(self) -> str: + data = readMemory(self.device, 0x14, 4) + if data is None: + raise IOError + + major = (data[0] << 8) + data[1] + minor = data[2] + patch = data[3] + + return f"{major}.{minor}.{patch}" + + @property + def load_enabled(self) -> bool: + data = readMemory(self.device, 0x010A, 1) + if data is None: + raise IOError + + return struct.unpack("x?", data)[0] + + @load_enabled.setter + def load_enabled(self, value: bool): + data = writeMemory(self.device, 0x010A, struct.pack("x?", value)) + if data is not None: + res = struct.unpack("x?", data)[0] + if res != value: + log(f"setting load_enabled failed; {res!r} != {value!r}") + else: + log("setting load_enabled failed; communications error") + + @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) + """ diff --git a/srnemqtt/solar_types.py b/srnemqtt/solar_types.py index c79b967..78276ec 100644 --- a/srnemqtt/solar_types.py +++ b/srnemqtt/solar_types.py @@ -119,3 +119,8 @@ HISTORICAL_DATA = [ DataItem(DataName.TOTAL_PRODUCTION_ENERGY, "L", "Wh"), DataItem(DataName.TOTAL_CONSUMPTION_ENERGY, "L", "Wh"), ] + + +class ChargerState: + def __init__(self, data: bytes | bytearray | memoryview) -> None: + raise NotImplementedError From 4bb77c3bb33ddd75fa77f043aaf0083f0e54a440 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Odd=20Str=C3=A5b=C3=B8?= <oddstr13@openshell.no> Date: Sat, 9 Dec 2023 19:17:27 +0100 Subject: [PATCH 13/25] Add fixme comments --- srnemqtt/protocol.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/srnemqtt/protocol.py b/srnemqtt/protocol.py index be711ef..50cfc06 100644 --- a/srnemqtt/protocol.py +++ b/srnemqtt/protocol.py @@ -212,7 +212,7 @@ class ChargeController: def serial(self) -> str: data = readMemory(self.device, 0x18, 3) if data is None: - raise IOError + raise IOError # FIXME: Raise specific error in readMemory p1 = data[0] p2 = data[1] @@ -223,7 +223,7 @@ class ChargeController: def model(self) -> str: data = readMemory(self.device, 0x0C, 8) if data is None: - raise IOError + raise IOError # FIXME: Raise specific error in readMemory return data.decode("utf-8").strip() @@ -231,7 +231,7 @@ class ChargeController: def version(self) -> str: data = readMemory(self.device, 0x14, 4) if data is None: - raise IOError + raise IOError # FIXME: Raise specific error in readMemory major = (data[0] << 8) + data[1] minor = data[2] @@ -243,7 +243,7 @@ class ChargeController: def load_enabled(self) -> bool: data = readMemory(self.device, 0x010A, 1) if data is None: - raise IOError + raise IOError # FIXME: Raise specific error in readMemory return struct.unpack("x?", data)[0] From f0c20574288d44b46326321831f627f797d6a213 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Odd=20Str=C3=A5b=C3=B8?= <oddstr13@openshell.no> Date: Sun, 10 Dec 2023 13:51:07 +0100 Subject: [PATCH 14/25] 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?= <oddstr13@openshell.no> Date: Sun, 10 Dec 2023 16:08:28 +0100 Subject: [PATCH 15/25] 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?= <oddstr13@openshell.no> Date: Sun, 10 Dec 2023 23:50:34 +0100 Subject: [PATCH 16/25] 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?= <oddstr13@openshell.no> Date: Sun, 10 Dec 2023 23:52:38 +0100 Subject: [PATCH 17/25] 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?= <oddstr13@openshell.no> Date: Sun, 10 Dec 2023 23:54:13 +0100 Subject: [PATCH 18/25] 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?= <oddstr13@openshell.no> Date: Sun, 10 Dec 2023 23:55:26 +0100 Subject: [PATCH 19/25] 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?= <oddstr13@openshell.no> Date: Sun, 10 Dec 2023 23:59:50 +0100 Subject: [PATCH 20/25] 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( From 33acd05b8a1b77299ee6e984f5004ce29485f706 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Odd=20Str=C3=A5b=C3=B8?= <oddstr13@openshell.no> Date: Tue, 12 Dec 2023 11:29:44 +0100 Subject: [PATCH 21/25] Partial manual merge of forgotten branch output-toggle --- srnemqtt/consumers/mqtt.py | 37 +++++++++++++++++++++++++++++++++---- srnemqtt/protocol.py | 2 +- 2 files changed, 34 insertions(+), 5 deletions(-) diff --git a/srnemqtt/consumers/mqtt.py b/srnemqtt/consumers/mqtt.py index 6600db3..db53002 100644 --- a/srnemqtt/consumers/mqtt.py +++ b/srnemqtt/consumers/mqtt.py @@ -82,6 +82,10 @@ MAP_VALUES: Dict[DataName, Dict[str, Any]] = { "state_class": "measurement", }, DataName.LOAD_POWER: {"unit": "W", "type": "power", "state_class": "measurement"}, + DataName.LOAD_ENABLED: { + "type": "outlet", + "platform": "switch", + }, DataName.PANEL_VOLTAGE: { "unit": "V", "type": "voltage", @@ -199,6 +203,7 @@ class MqttConsumer(BaseConsumer): type: Optional[str] = None, expiry: int = 90, state_class: Optional[str] = None, + platform: str = "sensor", ): assert state_class in [None, "measurement", "total", "total_increasing"] assert self.controller is not None @@ -206,7 +211,7 @@ class MqttConsumer(BaseConsumer): res = { "~": f"{self.topic_prefix}", "unique_id": f"{self.controller_id}_{id}", - "object_id": f"{self.controller_id}_{id}", + "object_id": f"{self.controller_id}_{id}", # Used for entity id "availability_topic": "~/available", "state_topic": f"~/{id}", "name": name, @@ -216,7 +221,7 @@ class MqttConsumer(BaseConsumer): ], "manufacturer": self.controller.manufacturer, "model": self.controller.model, - "hw_version": self.controller.version, + "sw_version": self.controller.version, "via_device": self.settings["device_id"], "suggested_area": "Solar panel", "name": self.controller.name, @@ -231,7 +236,10 @@ class MqttConsumer(BaseConsumer): res["dev_cla"] = type if state_class: res["state_class"] = state_class - + if platform == "switch": + res["command_topic"] = f"{res['state_topic']}/set" + res["payload_on"] = True + res["payload_off"] = False return res # The callback for when the client receives a CONNACK response from the server. @@ -246,6 +254,26 @@ class MqttConsumer(BaseConsumer): f"{userdata.topic_prefix}/available", payload="online", retain=True ) + load_set_topic = f"{userdata.topic_prefix}/load_enabled/set" + client.message_callback_add(load_set_topic, userdata.on_load_switch) + client.subscribe(load_set_topic) + + @staticmethod + def on_load_switch( + client: mqtt.Client, userdata: "MqttConsumer", message: mqtt.MQTTMessage + ): + assert userdata.controller is not None + print(message) + print(message.info) + print(message.state) + print(message.payload) + payload = message.payload.decode().upper() in ("ON", "TRUE", "ENABLE", "YES") + + res = userdata.controller.load_enabled = payload + client.publish( + f"{userdata.topic_prefix}/load_enabled", payload=res, retain=True + ) + @staticmethod def on_connect_fail(client: mqtt.Client, userdata: "MqttConsumer"): print(userdata.__class__.__name__, "on_connect_fail") @@ -283,9 +311,10 @@ class MqttConsumer(BaseConsumer): km = MAP_VALUES[DataName(dataname)] pretty_name = dataname.replace("_", " ").capitalize() disc_prefix = self.settings["discovery_prefix"] + platform = km.get("platform", "sensor") self.client.publish( - f"{disc_prefix}/sensor/{self.controller_id}/{dataname}/config", + f"{disc_prefix}/{platform}/{self.controller_id}/{dataname}/config", payload=json.dumps( self.get_ha_config(dataname, pretty_name, **km) ), diff --git a/srnemqtt/protocol.py b/srnemqtt/protocol.py index 5d536e7..bdb1c97 100644 --- a/srnemqtt/protocol.py +++ b/srnemqtt/protocol.py @@ -250,7 +250,7 @@ class ChargeController: p2 = data[1] p3 = (data[2] << 8) + data[3] - self._cached_serial = f"{p1}-{p2}-{p3}" + self._cached_serial = f"{p1:02n}-{p2:02n}-{p3:04n}" return self._cached_serial _cached_model: str | None = None From 5524d16f687a4e251fe4d57e3dbadc76f8b3f868 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Odd=20Str=C3=A5b=C3=B8?= <oddstr13@openshell.no> Date: Tue, 12 Dec 2023 11:32:45 +0100 Subject: [PATCH 22/25] Limit amount of historical data loaded on start --- srnemqtt/__main__.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/srnemqtt/__main__.py b/srnemqtt/__main__.py index 6d3a1bf..9cd4316 100755 --- a/srnemqtt/__main__.py +++ b/srnemqtt/__main__.py @@ -54,7 +54,9 @@ def main(): consumer.write(res) del extra - for i in range(days): + # Historical data isn't actually used anywhere yet + # Limit to 4 days for now + for i in range(min(days, 4)): hist = cc.get_historical(i) res = hist.as_dict() log({i: res}) From d38abe28babc905e1c170cedcdd1c3c5c362f0c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Odd=20Str=C3=A5b=C3=B8?= <oddstr13@openshell.no> Date: Sat, 16 Dec 2023 23:36:53 +0100 Subject: [PATCH 23/25] Move to python logging --- srnemqtt/__main__.py | 29 ++++++++++--------- srnemqtt/config.py | 27 ++++++++++++++++- srnemqtt/consumers/mqtt.py | 27 +++++++++-------- srnemqtt/protocol.py | 59 +++++++++++++++++++++----------------- srnemqtt/util.py | 15 ++++------ 5 files changed, 95 insertions(+), 62 deletions(-) diff --git a/srnemqtt/__main__.py b/srnemqtt/__main__.py index 9cd4316..2a520d7 100755 --- a/srnemqtt/__main__.py +++ b/srnemqtt/__main__.py @@ -2,13 +2,17 @@ # -*- coding: utf-8 -*- import time +from logging import getLogger +from logging.config import dictConfig as loggingDictConfig 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 ChargeController -from .util import Periodical, log +from .util import Periodical + +logger = getLogger(__name__) class CommunicationError(BTLEDisconnectError, SerialException, IOError): @@ -17,25 +21,24 @@ class CommunicationError(BTLEDisconnectError, SerialException, IOError): def main(): conf = get_config() + + loggingDictConfig(conf.get("logging", {})) consumers = get_consumers(conf) per_voltages = Periodical(interval=15) per_current_hist = Periodical(interval=60) - # import serial - - # ser = serial.Serial() try: while True: try: - log("Connecting...") + logger.info("Connecting...") with get_interface() as dev: - log("Connected.") + logger.info("Connected.") cc = ChargeController(dev) - log(f"Controller model: {cc.model}") - log(f"Controller version: {cc.version}") - log(f"Controller serial: {cc.serial}") + logger.info(f"Controller model: {cc.model}") + logger.info(f"Controller version: {cc.version}") + logger.info(f"Controller serial: {cc.serial}") for consumer in consumers: consumer.controller = cc @@ -59,7 +62,7 @@ def main(): for i in range(min(days, 4)): hist = cc.get_historical(i) res = hist.as_dict() - log({i: res}) + logger.debug({i: res}) for consumer in consumers: consumer.write({str(i): res}) @@ -68,14 +71,14 @@ def main(): if per_voltages(now): data = cc.state.as_dict() - log(data) + logger.debug(data) for consumer in consumers: consumer.write(data) if per_current_hist(now): data = cc.today.as_dict() data.update(cc.extra.as_dict()) - log(data) + logger.debug(data) for consumer in consumers: consumer.write(data) @@ -91,7 +94,7 @@ def main(): # write(wd, CMD_ENABLE_LOAD) except CommunicationError: - log("ERROR: Disconnected") + logger.error("Disconnected") time.sleep(1) except (KeyboardInterrupt, SystemExit, Exception) as e: diff --git a/srnemqtt/config.py b/srnemqtt/config.py index fd5ec8c..b82357b 100644 --- a/srnemqtt/config.py +++ b/srnemqtt/config.py @@ -27,6 +27,29 @@ def get_config() -> Dict[str, Any]: with open("config.yaml", "r") as fh: conf: dict = yaml.safe_load(fh) conf.setdefault("consumers", {}) + logging = conf.setdefault("logging", {}) + logging.setdefault("version", 1) + logging.setdefault("disable_existing_loggers", False) + logging.setdefault( + "handlers", + { + "console": { + "class": "logging.StreamHandler", + "formatter": "default", + "level": "INFO", + "stream": "ext://sys.stdout", + } + }, + ) + logging.setdefault( + "formatters", + { + "format": "%(asctime)s %(levelname)-8s %(name)-15s %(message)s", + "datefmt": "%Y-%m-%d %H:%M:%S", + }, + ) + loggers = logging.setdefault("loggers", {}) + loggers.setdefault("root", {"handlers": ["console"], "level": "DEBUG"}) return conf @@ -34,7 +57,9 @@ def get_config() -> Dict[str, Any]: def write_config(conf: Dict[str, Any]): with open(".config.yaml~writing", "w") as fh: yaml.safe_dump(conf, fh, indent=2, encoding="utf-8") - os.rename(".config.yaml~writing", "config.yaml") + fh.flush() + os.fsync(fh.fileno()) + os.replace(".config.yaml~writing", "config.yaml") def get_consumers(conf: Optional[Dict[str, Any]] = None) -> List[BaseConsumer]: diff --git a/srnemqtt/consumers/mqtt.py b/srnemqtt/consumers/mqtt.py index db53002..7a29e1c 100644 --- a/srnemqtt/consumers/mqtt.py +++ b/srnemqtt/consumers/mqtt.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- import json +from logging import getLogger from time import sleep from typing import Any, Dict, List, Optional, TypeAlias from uuid import uuid4 @@ -9,6 +10,8 @@ import paho.mqtt.client as mqtt from ..solar_types import DataName from . import BaseConsumer +logger = getLogger(__name__) + MAP_VALUES: Dict[DataName, Dict[str, Any]] = { # DataName.BATTERY_VOLTAGE_MIN: {}, # DataName.BATTERY_VOLTAGE_MAX: {}, @@ -161,8 +164,11 @@ class MqttConsumer(BaseConsumer): elif err.errno == -3: pass else: + logger.exception("Unknown error connecting to mqtt server") raise - print(err) + logger.warning( + "Temporary failure connecting to mqtt server", exc_info=True + ) sleep(0.1) return self._client @@ -245,7 +251,7 @@ class MqttConsumer(BaseConsumer): # The callback for when the client receives a CONNACK response from the server. @staticmethod def on_connect(client: mqtt.Client, userdata: "MqttConsumer", flags, rc): - print("Connected with result code " + str(rc)) + logger.info("MQTT connected with result code %s", rc) # Subscribing in on_connect() means that if we lose the connection and # reconnect then subscriptions will be renewed. @@ -263,10 +269,7 @@ class MqttConsumer(BaseConsumer): client: mqtt.Client, userdata: "MqttConsumer", message: mqtt.MQTTMessage ): assert userdata.controller is not None - print(message) - print(message.info) - print(message.state) - print(message.payload) + logger.debug(message.payload) payload = message.payload.decode().upper() in ("ON", "TRUE", "ENABLE", "YES") res = userdata.controller.load_enabled = payload @@ -276,29 +279,29 @@ class MqttConsumer(BaseConsumer): @staticmethod def on_connect_fail(client: mqtt.Client, userdata: "MqttConsumer"): - print(userdata.__class__.__name__, "on_connect_fail") + logger.warning("on_connect_fail") # The callback for when a PUBLISH message is received from the server. @staticmethod def on_message(client, userdata, msg): - print(msg.topic + " " + str(msg.payload)) + logger.info(msg.topic + " " + str(msg.payload)) @staticmethod def on_disconnect(client: mqtt.Client, userdata: "MqttConsumer", rc, prop=None): - print(userdata.__class__.__name__, "on_disconnect", rc) + logger.warning("on_disconnect %s", rc) def poll(self): res = self.client.loop(timeout=0.1, max_packets=5) if res != mqtt.MQTT_ERR_SUCCESS: - print(self.__class__.__name__, "loop returned non-success:", res) + logger.warning("loop returned non-success: %s", res) try: sleep(1) res = self.client.reconnect() if res != mqtt.MQTT_ERR_SUCCESS: - print(self.__class__.__name__, "Reconnect failed:", res) + logger.error("Reconnect failed: %s", res) except (OSError, mqtt.WebsocketConnectionError) as err: - print(self.__class__.__name__, "Reconnect failed:", err) + logger.error("Reconnect failed: %s", err) return super().poll() diff --git a/srnemqtt/protocol.py b/srnemqtt/protocol.py index bdb1c97..5b66ea6 100644 --- a/srnemqtt/protocol.py +++ b/srnemqtt/protocol.py @@ -1,8 +1,8 @@ # -*- coding: utf-8 -*- import struct -import sys import time -from typing import Callable, Collection, Optional +from logging import getLogger +from typing import Callable, Collection, List, Optional from libscrc import modbus # type: ignore @@ -16,7 +16,8 @@ from .solar_types import ( HistoricalData, HistoricalExtraInfo, ) -from .util import log + +logger = getLogger(__name__) def write(fh, data): @@ -92,15 +93,13 @@ def discardUntil(fh: BaseInterface, byte: int, timeout=10) -> Optional[int]: return b[0] start = time.time() - discarded = 0 + discarded: List[str] = [] read_byte = expand(fh.read(1)) while read_byte != byte: if read_byte is not None: if not discarded: - log("Discarding", end="") - discarded += 1 - print(f" {read_byte:02X}", end="") - sys.stdout.flush() + discarded.append("Discarding") + discarded.append(f"{read_byte:02X}") if time.time() - start > timeout: read_byte = None @@ -109,8 +108,7 @@ def discardUntil(fh: BaseInterface, byte: int, timeout=10) -> Optional[int]: read_byte = expand(fh.read(1)) if discarded: - print() - sys.stdout.flush() + logger.debug(" ".join(discarded)) return read_byte @@ -134,14 +132,18 @@ def readMemory(fh: BaseInterface, address: int, words: int = 1) -> Optional[byte try: crc = struct.unpack_from("<H", _crc)[0] except struct.error: - log(f"readMemory: CRC error; read {len(_crc)} bytes (2 expected)") + logger.error( + "readMemory: CRC error; read %s bytes (2 expected)", len(_crc) + ) return None calculated_crc = modbus(bytes([tag, operation, size, *data])) if crc == calculated_crc: return data else: - log(f"readMemory: CRC error; {crc:04X} != {calculated_crc:04X}") - log("data or crc is falsely", header, data, _crc) + logger.error( + f"readMemory: CRC error; {crc:04X} != {calculated_crc:04X}" + ) + logger.error("data or crc is falsely %s %s %s", header, data, _crc) return None @@ -166,7 +168,7 @@ def writeMemory(fh: BaseInterface, address: int, data: bytes): header = fh.read(3) if header and len(header) == 3: operation, size, address = header - log(header) + logger.log(5, 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. @@ -176,14 +178,18 @@ def writeMemory(fh: BaseInterface, address: int, data: bytes): try: crc = struct.unpack_from("<H", _crc)[0] except struct.error: - log(f"writeMemory: CRC error; read {len(_crc)} bytes (2 expected)") + logger.error( + f"writeMemory: CRC error; read {len(_crc)} bytes (2 expected)" + ) return None calculated_crc = modbus(bytes([tag, operation, size, address, *rdata])) if crc == calculated_crc: return rdata else: - log(f"writeMemory: CRC error; {crc:04X} != {calculated_crc:04X}") - log("data or crc is falsely", header, rdata, _crc) + logger.error( + f"writeMemory: CRC error; {crc:04X} != {calculated_crc:04X}" + ) + logger.error("data or crc is falsely %s %s %s", header, rdata, _crc) return None @@ -193,7 +199,6 @@ def writeMemoryMultiple(fh: BaseInterface, address: int, 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) @@ -214,15 +219,16 @@ def try_read_parse( try: if parser: return parser(res) - except struct.error as e: - log(e) - log("0x0100 Unpack error:", len(res), res) + except struct.error: + logger.exception("0x0100 Unpack error: %s %s", len(res), res) _timeout = dev.timeout dev.timeout = 0.5 - log("Flushed from read buffer; ", dev.read()) + logger.warning("Flushed from read buffer; %s", dev.read()) dev.timeout = _timeout else: - log(f"No data read, expected {words*2} bytes (attempts left: {attempts})") + logger.warning( + f"No data read, expected {words*2} bytes (attempts left: {attempts})" + ) return None @@ -308,7 +314,6 @@ class ChargeController: # 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: @@ -316,7 +321,7 @@ class ChargeController: res = data.decode("UTF-16BE").strip() if res != value: - log(f"setting device name failed; {res!r} != {value!r}") + logger.error("setting device name failed; %r != %r", res, value) self._cached_name = value @property @@ -333,9 +338,9 @@ class ChargeController: if data is not None: res = struct.unpack("x?", data)[0] if res != value: - log(f"setting load_enabled failed; {res!r} != {value!r}") + logger.error("setting load_enabled failed; %r != %r", res, value) else: - log("setting load_enabled failed; communications error") + logger.error("setting load_enabled failed; communications error") @property def state(self) -> ChargerState: diff --git a/srnemqtt/util.py b/srnemqtt/util.py index b641e70..254e38b 100644 --- a/srnemqtt/util.py +++ b/srnemqtt/util.py @@ -1,14 +1,14 @@ # -*- coding: utf-8 -*- - -import datetime -import sys import time +from logging import getLogger from typing import Optional # Only factor of 1000 SI_PREFIXES_LARGE = "kMGTPEZY" SI_PREFIXES_SMALL = "mµnpfazy" +logger = getLogger(__name__) + def humanize_number(data, unit: str = ""): counter = 0 @@ -35,11 +35,6 @@ def humanize_number(data, unit: str = ""): return f"{data:.3g} {prefix}{unit}" -def log(*message: object, **kwargs): - print(datetime.datetime.utcnow().isoformat(" "), *message, **kwargs) - sys.stdout.flush() - - class Periodical: prev: float interval: float @@ -56,7 +51,9 @@ class Periodical: skipped, overshoot = divmod(now - self.prev, self.interval) skipped -= 1 if skipped: - log("Skipped:", skipped, overshoot, now - self.prev, self.interval) + logger.debug( + "Skipped:", skipped, overshoot, now - self.prev, self.interval + ) self.prev = now - overshoot return True From b4e8258de161e583af94c9ad00835ca5ef5916ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Odd=20Str=C3=A5b=C3=B8?= <oddstr13@openshell.no> Date: Mon, 18 Dec 2023 14:26:56 +0100 Subject: [PATCH 24/25] Improve logging of dictionaries --- srnemqtt/__main__.py | 21 +++++++++++++++------ srnemqtt/util.py | 32 +++++++++++++++++++++++++++++++- 2 files changed, 46 insertions(+), 7 deletions(-) diff --git a/srnemqtt/__main__.py b/srnemqtt/__main__.py index 2a520d7..1fed6c5 100755 --- a/srnemqtt/__main__.py +++ b/srnemqtt/__main__.py @@ -3,6 +3,7 @@ import time from logging import getLogger +from logging import root as logging_root from logging.config import dictConfig as loggingDictConfig from bluepy.btle import BTLEDisconnectError # type: ignore @@ -10,9 +11,9 @@ from serial import SerialException # type: ignore from .config import get_config, get_consumers, get_interface from .protocol import ChargeController -from .util import Periodical +from .util import LazyJSON, LoggingDictFilter, Periodical -logger = getLogger(__name__) +logger = getLogger("SolarMPPT") class CommunicationError(BTLEDisconnectError, SerialException, IOError): @@ -23,6 +24,10 @@ def main(): conf = get_config() loggingDictConfig(conf.get("logging", {})) + logging_dict_filter = LoggingDictFilter() + logging_dict_filter.data["service"] = "SolarMPPT" + logging_root.addFilter(logging_dict_filter) + consumers = get_consumers(conf) per_voltages = Periodical(interval=15) @@ -33,9 +38,13 @@ def main(): try: logger.info("Connecting...") with get_interface() as dev: + cc = ChargeController(dev) + logging_dict_filter.data["srne_model"] = cc.model + logging_dict_filter.data["srne_version"] = cc.version + logging_dict_filter.data["srne_serial"] = cc.serial + logger.info("Connected.") - cc = ChargeController(dev) logger.info(f"Controller model: {cc.model}") logger.info(f"Controller version: {cc.version}") logger.info(f"Controller serial: {cc.serial}") @@ -62,7 +71,7 @@ def main(): for i in range(min(days, 4)): hist = cc.get_historical(i) res = hist.as_dict() - logger.debug({i: res}) + logger.debug(LazyJSON({i: res})) for consumer in consumers: consumer.write({str(i): res}) @@ -71,14 +80,14 @@ def main(): if per_voltages(now): data = cc.state.as_dict() - logger.debug(data) + logger.debug(LazyJSON(data)) for consumer in consumers: consumer.write(data) if per_current_hist(now): data = cc.today.as_dict() data.update(cc.extra.as_dict()) - logger.debug(data) + logger.debug(LazyJSON(data)) for consumer in consumers: consumer.write(data) diff --git a/srnemqtt/util.py b/srnemqtt/util.py index 254e38b..a95f68f 100644 --- a/srnemqtt/util.py +++ b/srnemqtt/util.py @@ -1,7 +1,11 @@ # -*- coding: utf-8 -*- +import json import time +from logging import Filter as LoggingFilter from logging import getLogger -from typing import Optional +from typing import Dict, Optional + +__all__ = ["humanize_number", "Periodical", "LazyJSON", "LoggingDictFilter"] # Only factor of 1000 SI_PREFIXES_LARGE = "kMGTPEZY" @@ -35,6 +39,32 @@ def humanize_number(data, unit: str = ""): return f"{data:.3g} {prefix}{unit}" +class LazyJSON: + def __init__(self, data): + self.data = data + + def __str__(self) -> str: + return json.dumps(self.data) + + def __repr__(self) -> str: + return repr(self.data) + + +class LoggingDictFilter(LoggingFilter): + data: Dict[str, str] + + def __init__(self): + self.data = {} + + def filter(self, record): + print(self.data) + for key, value in self.data.items(): + print(key, value) + assert not hasattr(record, key) + setattr(record, key, value) + return True + + class Periodical: prev: float interval: float From 1890036b6c2bc5243e69754b3e84241be3bd0265 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Odd=20Str=C3=A5b=C3=B8?= <oddstr13@openshell.no> Date: Wed, 3 Jan 2024 11:28:33 +0100 Subject: [PATCH 25/25] Fix service deployment --- deploy.sh | 4 +++- requirements.txt | 1 + solarmppt.service | 4 ++-- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/deploy.sh b/deploy.sh index 79979b7..7640268 100755 --- a/deploy.sh +++ b/deploy.sh @@ -1,3 +1,5 @@ #!/bin/bash rsync --exclude=.git --exclude='packet format.java' --exclude-from=.gitignore -ravC . pi@solarpi:ble -ssh pi@solarpi 'systemctl --user daemon-reload; systemctl --user restart solarmppt' +ssh pi@solarpi './ble-venv/bin/pip install -r ble/requirements.txt' +ssh pi@solarpi 'ln -fs $(pwd)/ble/solarmppt.service ~/.config/systemd/user/solarmppt.service' +ssh pi@solarpi 'loginctl enable-linger; systemctl --user daemon-reload; systemctl --user restart solarmppt' diff --git a/requirements.txt b/requirements.txt index 5a919f0..cdc6045 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,5 +4,6 @@ bluepy libscrc paho-mqtt pyserial +graypy types-PyYAML diff --git a/solarmppt.service b/solarmppt.service index 1dbb085..bdc48a8 100644 --- a/solarmppt.service +++ b/solarmppt.service @@ -4,8 +4,8 @@ Description=Daemon for bridging a Serial SRNE MPPT charge controller to MQTT [Service] Type=exec Restart=on-failure -StandardOutput=append:/home/pi/z_solar_systemd.log -StandardError=append:/home/pi/z_solar_systemd.log +#StandardOutput=append:/home/pi/z_solar_systemd.log +#StandardError=append:/home/pi/z_solar_systemd_err.log WorkingDirectory=/home/pi/ Environment=PYTHONPATH=/home/pi/ble/ ExecStart=/home/pi/ble-venv/bin/python -m srnemqtt