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