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/.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 f9808e9..727a3b0 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,6 +1,14 @@ { - "python.linting.mypyEnabled": true, - "python.formatting.provider": "black", "editor.formatOnSave": true, - "python.linting.flake8Enabled": true -} + "pylint.args": [ + "--disable=missing-function-docstring,missing-class-docstring,missing-module-docstring" + ], + "python.testing.unittestEnabled": false, + "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/deploy.sh b/deploy.sh index dc92d5b..7640268 100755 --- a/deploy.sh +++ b/deploy.sh @@ -1,2 +1,5 @@ #!/bin/bash rsync --exclude=.git --exclude='packet format.java' --exclude-from=.gitignore -ravC . pi@solarpi:ble +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/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..f9cbc2a --- /dev/null +++ b/misc/dump_memory_map.py @@ -0,0 +1,29 @@ +# -*- coding: utf-8 -*- +import os +import sys +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 + +if __name__ == "__main__": + conf = get_config() + iface = get_interface(conf) + + 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│ +├────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┤ +│ │ ~│ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ +├────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┤ +└────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┘ diff --git a/misc/render_rrd.py b/misc/render_rrd.py index 2ca196e..aebf051 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 @@ -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", } @@ -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..7e79f1f 100644 --- a/misc/test_bleuart.py +++ b/misc/test_bleuart.py @@ -1,13 +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..91a39ee --- /dev/null +++ b/misc/test_load_switch.py @@ -0,0 +1,26 @@ +# -*- 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}") + + # print(f"Name: {cc.name}") + # cc.name = "☀️ 🔌🔋Charger" + # print(f"Name: {cc.name}") diff --git a/misc/test_serial.py b/misc/test_serial.py index 7ea6505..dacf5b8 100644 --- a/misc/test_serial.py +++ b/misc/test_serial.py @@ -3,22 +3,23 @@ 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]))) # 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 [1200, 2400, 4800, 9600, 115200]: +for rate in [9600]: print(rate) with Serial("/dev/ttyUSB0", baudrate=rate, timeout=2) as x: sleep(2) 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/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/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 new file mode 100644 index 0000000..bdc48a8 --- /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_err.log +WorkingDirectory=/home/pi/ +Environment=PYTHONPATH=/home/pi/ble/ +ExecStart=/home/pi/ble-venv/bin/python -m srnemqtt + +[Install] +WantedBy=default.target diff --git a/srnemqtt/__main__.py b/srnemqtt/__main__.py index 38d8058..1fed6c5 100755 --- a/srnemqtt/__main__.py +++ b/srnemqtt/__main__.py @@ -2,16 +2,18 @@ # -*- coding: utf-8 -*- import time -from decimal import Decimal -from typing import cast +from logging import getLogger +from logging import root as logging_root +from logging.config import dictConfig as loggingDictConfig -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 -from .solar_types import DataName -from .util import Periodical, log +from .protocol import ChargeController +from .util import LazyJSON, LoggingDictFilter, Periodical + +logger = getLogger("SolarMPPT") class CommunicationError(BTLEDisconnectError, SerialException, IOError): @@ -20,20 +22,34 @@ class CommunicationError(BTLEDisconnectError, SerialException, IOError): 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) 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.") + 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.") + + 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 # write(dev, construct_request(0, 32)) @@ -41,61 +57,45 @@ def main(): # 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 - 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}) + res = cc.today.as_dict() + res.update(extra.as_dict()) + for consumer in consumers: + consumer.write(res) + del extra + + # 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() + logger.debug(LazyJSON({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() + logger.debug(LazyJSON(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()) + logger.debug(LazyJSON(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) @@ -103,7 +103,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 4b3a4c1..b82357b 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]]: @@ -28,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 @@ -35,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/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..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: diff --git a/srnemqtt/consumers/mqtt.py b/srnemqtt/consumers/mqtt.py index 6cd7497..7a29e1c 100644 --- a/srnemqtt/consumers/mqtt.py +++ b/srnemqtt/consumers/mqtt.py @@ -1,7 +1,8 @@ # -*- coding: utf-8 -*- import json +from logging import getLogger 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 @@ -9,11 +10,13 @@ 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: {}, # DataName.CHARGE_MAX_CURRENT: {}, - # DataName._DISCHARGE_MAX_CURRENT: {}, + # DataName.DISCHARGE_MAX_CURRENT: {}, # DataName.CHARGE_MAX_POWER: {}, # DataName.DISCHARGE_MAX_POWER: {}, # DataName.CHARGE_AMP_HOUR: {}, @@ -82,6 +85,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", @@ -112,29 +119,41 @@ MAP_VALUES: Dict[DataName, Dict[str, Any]] = { } +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: @@ -145,9 +164,13 @@ 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 def config(self, settings: Dict[str, Any]): super().config(settings) @@ -164,35 +187,50 @@ 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, - id, - name, + id: str, + name: str, unit: Optional[str] = None, 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 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}", # Used for entity 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, + "sw_version": self.controller.version, + "via_device": self.settings["device_id"], "suggested_area": "Solar panel", + "name": self.controller.name, }, "force_update": True, "expire_after": expiry, @@ -204,13 +242,16 @@ 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. @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. @@ -219,52 +260,74 @@ 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 + logger.debug(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") + 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() - 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(): - 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"] + platform = km.get("platform", "sensor") + 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}/{platform}/{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( 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/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..624093d 100644 --- a/srnemqtt/lib/feasycom_ble.py +++ b/srnemqtt/lib/feasycom_ble.py @@ -4,11 +4,7 @@ import queue import time from typing import TYPE_CHECKING, Optional, cast -from bluepy import btle - -if TYPE_CHECKING: - from _typeshed import ReadableBuffer, WriteableBuffer - +from bluepy import btle # type: ignore 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 160da0b..5b66ea6 100644 --- a/srnemqtt/protocol.py +++ b/srnemqtt/protocol.py @@ -1,16 +1,23 @@ # -*- coding: utf-8 -*- import struct -import sys import time -from io import RawIOBase -from typing import Callable, Collection, Optional +from logging import getLogger +from typing import Callable, Collection, List, 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 .solar_types import DATA_BATTERY_STATE, HISTORICAL_DATA, DataItem -from .util import log +from .constants import ACTION_READ, ACTION_WRITE, POSSIBLE_MARKER +from .interfaces import BaseInterface +from .solar_types import ( + DATA_BATTERY_STATE, + HISTORICAL_DATA, + ChargerState, + DataItem, + HistoricalData, + HistoricalExtraInfo, +) + +logger = getLogger(__name__) def write(fh, data): @@ -20,9 +27,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: @@ -61,36 +73,33 @@ 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() - 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 @@ -99,15 +108,14 @@ def discardUntil(fh: RawIOBase, 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 -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) + request = construct_read_request(address, words=words) # log("Request:", request) write(fh, request) @@ -124,19 +132,81 @@ def readMemory(fh: RawIOBase, address: int, words: int = 1) -> Optional[bytes]: try: crc = struct.unpack_from(" 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 + + p1 = data[0] + p2 = data[1] + p3 = (data[2] << 8) + data[3] + + self._cached_serial = f"{p1:02n}-{p2:02n}-{p3:04n}" + 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 + + 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 + + major = (data[0] << 8) + data[1] + minor = data[2] + patch = data[3] + + 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") + + 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: + logger.error("setting device name failed; %r != %r", res, value) + self._cached_name = value + + @property + def load_enabled(self) -> bool: + data = readMemory(self.device, 0x010A, 1) + if data is None: + raise IOError # FIXME: Raise specific error in readMemory + + 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: + logger.error("setting load_enabled failed; %r != %r", res, value) + else: + logger.error("setting load_enabled failed; communications error") + + @property + def state(self) -> ChargerState: + 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 8fdcb83..94c387f 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 @@ -21,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" @@ -43,6 +44,9 @@ class DataName(str, Enum): def __repr__(self): return repr(self.value) + def __str__(self): + return self.value + class DataItem: name: DataName @@ -101,13 +105,14 @@ 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"), 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"), @@ -116,3 +121,180 @@ HISTORICAL_DATA = [ DataItem(DataName.TOTAL_PRODUCTION_ENERGY, "L", "Wh"), DataItem(DataName.TOTAL_CONSUMPTION_ENERGY, "L", "Wh"), ] + + +class DecodedData(ABC): + @abstractmethod + def __init__(self, data: bytes | bytearray | memoryview) -> None: + ... + + @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, + } diff --git a/srnemqtt/util.py b/srnemqtt/util.py index b641e70..a95f68f 100644 --- a/srnemqtt/util.py +++ b/srnemqtt/util.py @@ -1,14 +1,18 @@ # -*- coding: utf-8 -*- - -import datetime -import sys +import json import time -from typing import Optional +from logging import Filter as LoggingFilter +from logging import getLogger +from typing import Dict, Optional + +__all__ = ["humanize_number", "Periodical", "LazyJSON", "LoggingDictFilter"] # 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,9 +39,30 @@ 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 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: @@ -56,7 +81,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 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..c24b36c 100644 --- a/tox.ini +++ b/tox.ini @@ -1,3 +1,7 @@ [flake8] -max-line-length = 88 +max-line-length = 120 extend-ignore = E203, I201, I101 + +[pytest] +pythonpath = . +testpaths = tests