Compare commits

..

1 commit

Author SHA1 Message Date
dd7c43f7e7 Add support for the load switch
Rework mqtt structure
2023-04-10 03:39:19 +02:00
28 changed files with 349 additions and 933 deletions

View file

@ -16,6 +16,3 @@ indent_style = space
[*.{yaml,yml,md}] [*.{yaml,yml,md}]
indent_size = 2 indent_size = 2
[.vscode/*.json]
insert_final_newline = false

View file

@ -5,7 +5,7 @@ repos:
rev: v4.4.0 rev: v4.4.0
hooks: hooks:
- id: trailing-whitespace - id: trailing-whitespace
#- id: end-of-file-fixer - id: end-of-file-fixer
- id: fix-byte-order-marker - id: fix-byte-order-marker
- id: fix-encoding-pragma - id: fix-encoding-pragma
- id: check-executables-have-shebangs - id: check-executables-have-shebangs
@ -41,9 +41,7 @@ repos:
args: args:
- "--install-types" - "--install-types"
- "--non-interactive" - "--non-interactive"
- "--check-untyped-defs" - "--ignore-missing-imports"
additional_dependencies:
- typing_extensions==4.8.0
- repo: https://github.com/psf/black - repo: https://github.com/psf/black
rev: 23.3.0 rev: 23.3.0

16
.vscode/settings.json vendored
View file

@ -1,14 +1,6 @@
{ {
"python.linting.mypyEnabled": true,
"python.formatting.provider": "black",
"editor.formatOnSave": true, "editor.formatOnSave": true,
"pylint.args": [ "python.linting.flake8Enabled": true
"--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"
]
}

View file

@ -1,5 +1,2 @@
#!/bin/bash #!/bin/bash
rsync --exclude=.git --exclude='packet format.java' --exclude-from=.gitignore -ravC . pi@solarpi:ble 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'

View file

@ -99,20 +99,20 @@ def parse_log(fh, chunksize=32):
yield None yield None
if __name__ == "__main__": with open("z_solar copy.log") as fh:
with open("z_solar copy.log") as fh: data = list(parse_log(fh))
data = list(parse_log(fh)) # print(data)
# print(data)
# data = list(range(256)) # data = list(range(256))
print(
memory_table( print(
data, memory_table(
wordsize=2, data,
skip_nullrows=True, wordsize=2,
) skip_nullrows=True,
) )
)
# #

View file

@ -1,29 +0,0 @@
# -*- 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,
)
)

View file

@ -1,153 +0,0 @@
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│
├────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┤
│ │ ~│ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │
├────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┤
└────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┘

View file

@ -5,7 +5,7 @@ from ast import literal_eval
from collections import namedtuple from collections import namedtuple
from typing import Any, Dict from typing import Any, Dict
import rrdtool # type: ignore import rrdtool
from srnemqtt.solar_types import DataName from srnemqtt.solar_types import DataName
@ -20,7 +20,7 @@ HISTORICAL_KEYS = {
DataName.BATTERY_VOLTAGE_MIN, DataName.BATTERY_VOLTAGE_MIN,
DataName.BATTERY_VOLTAGE_MAX, DataName.BATTERY_VOLTAGE_MAX,
DataName.CHARGE_MAX_CURRENT, DataName.CHARGE_MAX_CURRENT,
DataName.DISCHARGE_MAX_CURRENT, DataName._DISCHARGE_MAX_CURRENT,
DataName.CHARGE_MAX_POWER, DataName.CHARGE_MAX_POWER,
DataName.DISCHARGE_MAX_POWER, DataName.DISCHARGE_MAX_POWER,
DataName.CHARGE_AMP_HOUR, DataName.CHARGE_AMP_HOUR,
@ -58,7 +58,7 @@ KNOWN_KEYS = HISTORICAL_KEYS.union(INSTANT_KEYS)
MAP = { MAP = {
"_internal_temperature?": "internal_temp", "_internal_temperature?": "internal_temp",
"unknown1": "charge_max_current", "unknown1": "charge_max_current",
"unknown2": "discharge_max_current", "unknown2": "_discharge_max_current?",
"internal_temperature": "internal_temp", "internal_temperature": "internal_temp",
"battery_temperature": "battery_temp", "battery_temperature": "battery_temp",
} }
@ -147,6 +147,7 @@ def rrdupdate(file: str, timestamp: int, data: dict):
def re_read(): def re_read():
rrdtool.create( rrdtool.create(
RRDFILE, RRDFILE,
# "--no-overwrite", # "--no-overwrite",

View file

@ -1,12 +1,13 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from srnemqtt.constants import MAC from srnemqtt.constants import MAC
from srnemqtt.lib.feasycom_ble import BTLEUart from srnemqtt.lib.feasycom_ble import BTLEUart
from srnemqtt.protocol import construct_read_request, write from srnemqtt.protocol import construct_request, write
with BTLEUart(MAC, timeout=1) as x: with BTLEUart(MAC, timeout=1) as x:
print(x) print(x)
write(x, construct_read_request(0x0E, words=3)) write(x, construct_request(0x0E, words=3))
x.read(3, timeout=1) x.read(3, timeout=1)
print(x.read(6, timeout=0.01)) print(x.read(6, timeout=0.01))
x.read(2, timeout=0.01) x.read(2, timeout=0.01)

View file

@ -1,26 +0,0 @@
# -*- 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}")

View file

@ -3,23 +3,22 @@ import os
import sys import sys
from time import sleep from time import sleep
from serial import Serial # type: ignore from serial import Serial
print(sys.path) print(sys.path)
sys.path.insert(1, os.path.dirname(os.path.dirname(sys.argv[0]))) sys.path.insert(1, os.path.dirname(os.path.dirname(sys.argv[0])))
# from srnemqtt.constants import MAC # from srnemqtt.constants import MAC
# from srnemqtt.lib.feasycom_ble import BTLEUart # from srnemqtt.lib.feasycom_ble import BTLEUart
from srnemqtt.protocol import construct_read_request, write # noqa: E402 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) print(rate)
with Serial("/dev/ttyUSB0", baudrate=rate, timeout=2) as x: with Serial("/dev/ttyUSB0", baudrate=rate, timeout=2) as x:
sleep(2) sleep(2)
print(x) print(x)
write(x, construct_read_request(0x0E, words=3)) write(x, construct_request(0x0E, words=3))
print(x.read(3)) print(x.read(3))
print(x.read(6)) print(x.read(6))
print(x.read(2)) print(x.read(2))

View file

@ -1,5 +1,5 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from serial import Serial # type: ignore from serial import Serial
with Serial("/dev/ttyUSB0", baudrate=9600, timeout=2) as x: with Serial("/dev/ttyUSB0", baudrate=9600, timeout=2) as x:
x.write(b"Hello, World!") x.write(b"Hello, World!")

View file

@ -4,6 +4,6 @@ bluepy
libscrc libscrc
paho-mqtt paho-mqtt
pyserial pyserial
graypy
types-PyYAML types-PyYAML
types-paho-mqtt

View file

@ -1,14 +0,0 @@
[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

View file

@ -2,33 +2,25 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
import time import time
from logging import getLogger from typing import List, Optional, cast
from logging import root as logging_root
from logging.config import dictConfig as loggingDictConfig
from bluepy.btle import BTLEDisconnectError # type: ignore from bluepy.btle import BTLEDisconnectError
from serial import SerialException # type: ignore from serial import SerialException
from srnemqtt.consumers import BaseConsumer
from .config import get_config, get_consumers, get_interface from .config import get_config, get_consumers, get_interface
from .protocol import ChargeController from .srne import Srne
from .util import LazyJSON, LoggingDictFilter, Periodical from .util import Periodical, log
logger = getLogger("SolarMPPT")
class CommunicationError(BTLEDisconnectError, SerialException, IOError): class CommunicationError(BTLEDisconnectError, SerialException, TimeoutError):
pass pass
def main(): def main() -> None:
conf = get_config() conf = get_config()
consumers: Optional[List[BaseConsumer]] = None
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_voltages = Periodical(interval=15)
per_current_hist = Periodical(interval=60) per_current_hist = Periodical(interval=60)
@ -36,66 +28,53 @@ def main():
try: try:
while True: while True:
try: try:
logger.info("Connecting...") log("Connecting...")
with get_interface() as dev: with get_interface() as dev:
cc = ChargeController(dev) srne = Srne(dev)
logging_dict_filter.data["srne_model"] = cc.model log("Connected.")
logging_dict_filter.data["srne_version"] = cc.version
logging_dict_filter.data["srne_serial"] = cc.serial
logger.info("Connected.") if consumers is None:
consumers = get_consumers(srne, conf)
logger.info(f"Controller model: {cc.model}") days = 7
logger.info(f"Controller version: {cc.version}") res = srne.get_historical_entry()
logger.info(f"Controller serial: {cc.serial}") if res:
for consumer in consumers: log(res)
consumer.controller = cc
# 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))
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
# 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: for consumer in consumers:
consumer.write({str(i): res}) consumer.write(res)
days = cast(int, res.get("run_days", 7))
for i in range(days):
res = srne.get_historical_entry(i)
if res:
log({i: res})
for consumer in consumers:
consumer.write({str(i): res})
while True: while True:
now = time.time() now = time.time()
if per_voltages(now): if per_voltages(now):
data = cc.state.as_dict() data = srne.get_battery_state()
logger.debug(LazyJSON(data)) if data:
for consumer in consumers: log(data)
consumer.write(data) for consumer in consumers:
consumer.write(data)
if per_current_hist(now): if per_current_hist(now):
data = cc.today.as_dict() try:
data.update(cc.extra.as_dict()) data = srne.get_historical_entry()
logger.debug(LazyJSON(data)) log(data)
for consumer in consumers: for consumer in consumers:
consumer.write(data) consumer.write(data)
except TimeoutError:
pass
# print(".") # print(".")
for consumer in consumers: for consumer in consumers:
consumer.poll() 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'): # if STATUS.get('load_enabled'):
# write(wd, CMD_DISABLE_LOAD) # write(wd, CMD_DISABLE_LOAD)
@ -103,12 +82,13 @@ def main():
# write(wd, CMD_ENABLE_LOAD) # write(wd, CMD_ENABLE_LOAD)
except CommunicationError: except CommunicationError:
logger.error("Disconnected") log("ERROR: Disconnected")
time.sleep(1) time.sleep(1)
except (KeyboardInterrupt, SystemExit, Exception) as e: except (KeyboardInterrupt, SystemExit, Exception) as e:
for consumer in consumers: if consumers is not None:
consumer.exit() for consumer in consumers:
consumer.exit()
if type(e) is not KeyboardInterrupt: if type(e) is not KeyboardInterrupt:
raise raise

View file

@ -8,6 +8,7 @@ import yaml
from .consumers import BaseConsumer from .consumers import BaseConsumer
from .interfaces import BaseInterface from .interfaces import BaseInterface
from .srne import Srne
def get_consumer(name: str) -> Optional[Type[BaseConsumer]]: def get_consumer(name: str) -> Optional[Type[BaseConsumer]]:
@ -27,29 +28,6 @@ def get_config() -> Dict[str, Any]:
with open("config.yaml", "r") as fh: with open("config.yaml", "r") as fh:
conf: dict = yaml.safe_load(fh) conf: dict = yaml.safe_load(fh)
conf.setdefault("consumers", {}) 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 return conf
@ -57,12 +35,12 @@ def get_config() -> Dict[str, Any]:
def write_config(conf: Dict[str, Any]): def write_config(conf: Dict[str, Any]):
with open(".config.yaml~writing", "w") as fh: with open(".config.yaml~writing", "w") as fh:
yaml.safe_dump(conf, fh, indent=2, encoding="utf-8") yaml.safe_dump(conf, fh, indent=2, encoding="utf-8")
fh.flush() os.rename(".config.yaml~writing", "config.yaml")
os.fsync(fh.fileno())
os.replace(".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: if conf is None:
conf = get_config() conf = get_config()
@ -72,7 +50,7 @@ def get_consumers(conf: Optional[Dict[str, Any]] = None) -> List[BaseConsumer]:
mod = get_consumer(name) mod = get_consumer(name)
if mod: if mod:
# print(mod) # print(mod)
consumers.append(mod(consumer_config)) consumers.append(mod(settings=consumer_config, srne=srne))
write_config(conf) write_config(conf)
return consumers return consumers
@ -105,7 +83,7 @@ def get_interface(conf: Optional[Dict[str, Any]] = None) -> BaseInterface:
if __name__ == "__main__": if __name__ == "__main__":
conf = get_config() conf = get_config()
consumers = get_consumers(conf) consumers = get_consumers(Srne(BaseInterface()), conf)
try: try:
while True: while True:

View file

@ -2,15 +2,16 @@
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from typing import Any, Dict from typing import Any, Dict
from ..protocol import ChargeController from ..srne import Srne
class BaseConsumer(ABC): class BaseConsumer(ABC):
settings: Dict[str, Any] settings: Dict[str, Any]
controller: ChargeController | None = None srne: Srne
@abstractmethod @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) self.config(settings)
@abstractmethod @abstractmethod

View file

@ -1,22 +1,20 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
import json import json
from logging import getLogger
from time import sleep from time import sleep
from typing import Any, Dict, List, Optional, TypeAlias from typing import Any, Dict, List, Optional
from uuid import uuid4 from uuid import uuid4
import paho.mqtt.client as mqtt import paho.mqtt.client as mqtt
from ..solar_types import DataName from ..solar_types import DataName
from ..srne import Srne
from . import BaseConsumer from . import BaseConsumer
logger = getLogger(__name__)
MAP_VALUES: Dict[DataName, Dict[str, Any]] = { MAP_VALUES: Dict[DataName, Dict[str, Any]] = {
# DataName.BATTERY_VOLTAGE_MIN: {}, # DataName.BATTERY_VOLTAGE_MIN: {},
# DataName.BATTERY_VOLTAGE_MAX: {}, # DataName.BATTERY_VOLTAGE_MAX: {},
# DataName.CHARGE_MAX_CURRENT: {}, # DataName.CHARGE_MAX_CURRENT: {},
# DataName.DISCHARGE_MAX_CURRENT: {}, # DataName._DISCHARGE_MAX_CURRENT: {},
# DataName.CHARGE_MAX_POWER: {}, # DataName.CHARGE_MAX_POWER: {},
# DataName.DISCHARGE_MAX_POWER: {}, # DataName.DISCHARGE_MAX_POWER: {},
# DataName.CHARGE_AMP_HOUR: {}, # DataName.CHARGE_AMP_HOUR: {},
@ -84,7 +82,11 @@ MAP_VALUES: Dict[DataName, Dict[str, Any]] = {
"type": "current", "type": "current",
"state_class": "measurement", "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: { DataName.LOAD_ENABLED: {
"type": "outlet", "type": "outlet",
"platform": "switch", "platform": "switch",
@ -119,41 +121,30 @@ MAP_VALUES: Dict[DataName, Dict[str, Any]] = {
} }
PayloadType: TypeAlias = str | bytes | bytearray | int | float | None
class MqttConsumer(BaseConsumer): class MqttConsumer(BaseConsumer):
client: mqtt.Client
initialized: List[str] initialized: List[str]
srne: Srne
_client: mqtt.Client | None = None def __init__(self, settings: Dict[str, Any], srne: Srne) -> None:
def __init__(self, settings: Dict[str, Any]) -> None:
self.initialized = [] self.initialized = []
super().__init__(settings) super().__init__(settings, srne)
self.client = mqtt.Client(client_id=settings["client"]["id"], userdata=self)
@property self.client.on_connect = self.on_connect
def client(self) -> mqtt.Client: self.client.on_message = self.on_message
if self._client is not None: self.client.on_disconnect = self.on_disconnect
return self._client self.client.on_connect_fail = self.on_connect_fail
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!! # Will must be set before connecting!!
self._client.will_set( self.client.will_set(
f"{self.topic_prefix}/available", payload="offline", retain=True f"{self.topic_prefix}/available", payload="offline", retain=True
) )
while True: while True:
try: try:
self._client.connect( self.client.connect(
self.settings["client"]["host"], settings["client"]["host"],
self.settings["client"]["port"], settings["client"]["port"],
self.settings["client"]["keepalive"], settings["client"]["keepalive"],
) )
break break
except OSError as err: except OSError as err:
@ -164,13 +155,9 @@ class MqttConsumer(BaseConsumer):
elif err.errno == -3: elif err.errno == -3:
pass pass
else: else:
logger.exception("Unknown error connecting to mqtt server")
raise raise
logger.warning( print(err)
"Temporary failure connecting to mqtt server", exc_info=True
)
sleep(0.1) sleep(0.1)
return self._client
def config(self, settings: Dict[str, Any]): def config(self, settings: Dict[str, Any]):
super().config(settings) super().config(settings)
@ -187,24 +174,14 @@ class MqttConsumer(BaseConsumer):
settings.setdefault("discovery_prefix", "homeassistant") 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 @property
def topic_prefix(self): def topic_prefix(self):
return f"{self.settings['prefix']}/{self.controller_id}" return f"{self.settings['prefix']}/{self.srne.serial}"
def get_ha_config( def get_ha_config(
self, self,
id: str, id,
name: str, name,
unit: Optional[str] = None, unit: Optional[str] = None,
type: Optional[str] = None, type: Optional[str] = None,
expiry: int = 90, expiry: int = 90,
@ -212,25 +189,27 @@ class MqttConsumer(BaseConsumer):
platform: str = "sensor", platform: str = "sensor",
): ):
assert state_class in [None, "measurement", "total", "total_increasing"] assert state_class in [None, "measurement", "total", "total_increasing"]
assert self.controller is not None
res = { res = {
"~": f"{self.topic_prefix}", "~": f"{self.topic_prefix}",
"unique_id": f"{self.controller_id}_{id}", "unique_id": f"srne_{self.srne.serial}_{id}",
"object_id": f"{self.controller_id}_{id}", # Used for entity id "object_id": f"srne_{self.srne.serial}_{id}", # Used for entity id
"availability_topic": "~/available", "availability_topic": "~/available",
"state_topic": f"~/{id}", "state_topic": f"~/{id}",
"name": name, "name": name,
"device": { "device": {
"identifiers": [ "identifiers": [
self.controller_id, self.srne.serial,
], ],
"manufacturer": self.controller.manufacturer, # TODO: Get charger serial and use for identifier instead
"model": self.controller.model, # See: https://www.home-assistant.io/integrations/sensor.mqtt/#device
"sw_version": self.controller.version, # "via_device": self.settings["device_id"],
"via_device": self.settings["device_id"],
"suggested_area": "Solar panel", "suggested_area": "Solar panel",
"name": self.controller.name, "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, "force_update": True,
"expire_after": expiry, "expire_after": expiry,
@ -246,12 +225,13 @@ class MqttConsumer(BaseConsumer):
res["command_topic"] = f"{res['state_topic']}/set" res["command_topic"] = f"{res['state_topic']}/set"
res["payload_on"] = True res["payload_on"] = True
res["payload_off"] = False res["payload_off"] = False
return res return res
# The callback for when the client receives a CONNACK response from the server. # The callback for when the client receives a CONNACK response from the server.
@staticmethod @staticmethod
def on_connect(client: mqtt.Client, userdata: "MqttConsumer", flags, rc): def on_connect(client: mqtt.Client, userdata: "MqttConsumer", flags, rc):
logger.info("MQTT connected with result code %s", rc) print("Connected with result code " + str(rc))
# Subscribing in on_connect() means that if we lose the connection and # Subscribing in on_connect() means that if we lose the connection and
# reconnect then subscriptions will be renewed. # reconnect then subscriptions will be renewed.
@ -268,66 +248,65 @@ class MqttConsumer(BaseConsumer):
def on_load_switch( def on_load_switch(
client: mqtt.Client, userdata: "MqttConsumer", message: mqtt.MQTTMessage client: mqtt.Client, userdata: "MqttConsumer", message: mqtt.MQTTMessage
): ):
assert userdata.controller is not None print(message)
logger.debug(message.payload) print(message.info)
print(message.state)
print(message.payload)
payload = message.payload.decode().upper() in ("ON", "TRUE", "ENABLE", "YES") payload = message.payload.decode().upper() in ("ON", "TRUE", "ENABLE", "YES")
if type(payload) is bool:
res = userdata.controller.load_enabled = payload res = userdata.srne.enable_load(payload)
client.publish( client.publish(
f"{userdata.topic_prefix}/load_enabled", payload=res, retain=True f"{userdata.topic_prefix}/load_enabled", payload=res, retain=True
) )
else:
print(f"!!! Unknown payload for switch callback: {message.payload!r}")
@staticmethod @staticmethod
def on_connect_fail(client: mqtt.Client, userdata: "MqttConsumer"): def on_connect_fail(client: mqtt.Client, userdata: "MqttConsumer"):
logger.warning("on_connect_fail") print(userdata.__class__.__name__, "on_connect_fail")
# The callback for when a PUBLISH message is received from the server. # The callback for when a PUBLISH message is received from the server.
@staticmethod @staticmethod
def on_message(client, userdata, msg): def on_message(client, userdata, msg):
logger.info(msg.topic + " " + str(msg.payload)) print(msg.topic + " " + str(msg.payload))
@staticmethod @staticmethod
def on_disconnect(client: mqtt.Client, userdata: "MqttConsumer", rc, prop=None): def on_disconnect(client: mqtt.Client, userdata: "MqttConsumer", rc, prop=None):
logger.warning("on_disconnect %s", rc) print(userdata.__class__.__name__, "on_disconnect", rc)
def poll(self): def poll(self):
res = self.client.loop(timeout=0.1, max_packets=5) res = self.client.loop(timeout=0.1, max_packets=5)
if res != mqtt.MQTT_ERR_SUCCESS: if res != mqtt.MQTT_ERR_SUCCESS:
logger.warning("loop returned non-success: %s", res) print(self.__class__.__name__, "loop returned non-success:", res)
try: try:
sleep(1) sleep(1)
res = self.client.reconnect() res = self.client.reconnect()
if res != mqtt.MQTT_ERR_SUCCESS: if res != mqtt.MQTT_ERR_SUCCESS:
logger.error("Reconnect failed: %s", res) print(self.__class__.__name__, "Reconnect failed:", res)
except (OSError, mqtt.WebsocketConnectionError) as err: except (OSError, mqtt.WebsocketConnectionError) as err:
logger.error("Reconnect failed: %s", err) print(self.__class__.__name__, "Reconnect failed:", err)
return super().poll() return super().poll()
def write(self, data: Dict[str, PayloadType]): def write(self, data: Dict[str, Any]):
self.client.publish(f"{self.topic_prefix}/raw", payload=json.dumps(data)) self.client.publish(f"{self.topic_prefix}/raw", payload=json.dumps(data))
for dataname, data_value in data.items(): for k, v in data.items():
if dataname in MAP_VALUES: if k in MAP_VALUES:
if dataname not in self.initialized: if k not in self.initialized:
km = MAP_VALUES[DataName(dataname)] km = MAP_VALUES[DataName(k)]
pretty_name = dataname.replace("_", " ").capitalize() pretty_name = k.replace("_", " ").capitalize()
disc_prefix = self.settings["discovery_prefix"] disc_prefix = self.settings["discovery_prefix"]
platform = km.get("platform", "sensor") platform = km.get("platform", "sensor")
self.client.publish( self.client.publish(
f"{disc_prefix}/{platform}/{self.controller_id}/{dataname}/config", f"{disc_prefix}/{platform}/srne_{self.srne.serial}_{k}/config",
payload=json.dumps( payload=json.dumps(self.get_ha_config(k, pretty_name, **km)),
self.get_ha_config(dataname, pretty_name, **km)
),
retain=True, retain=True,
) )
self.initialized.append(dataname) self.initialized.append(k)
self.client.publish( self.client.publish(f"{self.topic_prefix}/{k}", v, retain=True)
f"{self.topic_prefix}/{dataname}", data_value, retain=True
)
def exit(self): def exit(self):
self.client.publish( self.client.publish(

View file

@ -2,12 +2,13 @@
import json import json
from typing import Any, Dict from typing import Any, Dict
from ..srne import Srne
from . import BaseConsumer from . import BaseConsumer
class StdoutConsumer(BaseConsumer): class StdoutConsumer(BaseConsumer):
def __init__(self, settings: Dict[str, Any]) -> None: def __init__(self, settings: Dict[str, Any], srne: Srne) -> None:
super().__init__(settings) super().__init__(settings, srne)
def poll(self): def poll(self):
return super().poll() return super().poll()

View file

@ -4,4 +4,4 @@ from io import RawIOBase
class BaseInterface(RawIOBase, metaclass=ABCMeta): class BaseInterface(RawIOBase, metaclass=ABCMeta):
timeout: float | None pass

View file

@ -1,5 +1,5 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
import serial # type: ignore import serial
from . import BaseInterface from . import BaseInterface

View file

@ -4,7 +4,11 @@ import queue
import time import time
from typing import TYPE_CHECKING, Optional, cast from typing import TYPE_CHECKING, Optional, cast
from bluepy import btle # type: ignore from bluepy import btle
if TYPE_CHECKING:
from _typeshed import ReadableBuffer, WriteableBuffer
WRITE_DEVICE = "0000ffd1-0000-1000-8000-00805f9b34fb" WRITE_DEVICE = "0000ffd1-0000-1000-8000-00805f9b34fb"
READ_DEVICE = "0000fff1-0000-1000-8000-00805f9b34fb" READ_DEVICE = "0000fff1-0000-1000-8000-00805f9b34fb"
@ -14,7 +18,7 @@ class BTLEUart(io.RawIOBase):
mac: str mac: str
write_endpoint: str write_endpoint: str
read_endpoint: str read_endpoint: str
timeout: float | None timeout: float
device: Optional[btle.Peripheral] = None device: Optional[btle.Peripheral] = None
_write_handle: Optional[btle.Characteristic] = None _write_handle: Optional[btle.Characteristic] = None
@ -82,12 +86,13 @@ class BTLEUart(io.RawIOBase):
self._write_handle = self.device.getCharacteristics(uuid=self.write_endpoint)[0] self._write_handle = self.device.getCharacteristics(uuid=self.write_endpoint)[0]
# print("Handles:", self._read_handle.handle, self._write_handle.handle) # print("Handles:", self._read_handle.handle, self._write_handle.handle)
def _read(self, num: Optional[int] = None): def _read(self, num: Optional[int] = None, timeout: Optional[float] = None):
self._ensure_connected() self._ensure_connected()
if TYPE_CHECKING: if TYPE_CHECKING:
self.device = cast(btle.Peripheral, self.device) self.device = cast(btle.Peripheral, self.device)
timeout = self.timeout or 30 if timeout is None:
timeout = self.timeout
if num is None: if num is None:
start = time.time() start = time.time()
@ -127,9 +132,7 @@ class BTLEUart(io.RawIOBase):
del self._read_buffer[:num] del self._read_buffer[:num]
return data or None return data or None
def readinto(self, buffer: bytearray | memoryview) -> Optional[int]: # type: ignore [override] def readinto(self, buffer: "WriteableBuffer") -> Optional[int]:
# Buffer does not provide Sized, and bytes is read only.
# bytearray | memoryview is the default implementations that provide WriteableBuffer
data = self._read(len(buffer)) data = self._read(len(buffer))
if data is None: if data is None:
@ -141,15 +144,23 @@ class BTLEUart(io.RawIOBase):
def readall(self) -> bytes: def readall(self) -> bytes:
return self._read() return self._read()
def read(self, size: Optional[int] = None) -> Optional[bytes]: def read(
self, size: Optional[int] = None, timeout: Optional[float] = None
) -> Optional[bytes]:
if timeout:
_timeout = self.timeout
self.timeout = timeout
if size is None: if size is None:
res = super().read() res = super().read()
else: else:
res = super().read(size) res = super().read(size)
if timeout:
self.timeout = _timeout
return res return res
def write(self, b: bytes | bytearray | memoryview) -> Optional[int]: # type: ignore [override] def write(self, b: "ReadableBuffer") -> Optional[int]:
self._ensure_connected() self._ensure_connected()
if TYPE_CHECKING: if TYPE_CHECKING:
self.device = cast(btle.Peripheral, self.device) self.device = cast(btle.Peripheral, self.device)
@ -163,9 +174,8 @@ class BTLEUart(io.RawIOBase):
return self return self
def __exit__(self, type, value, traceback): def __exit__(self, type, value, traceback):
if self.device is not None: self.device.disconnect()
self.device.disconnect() del self.device
self.device = None
def seekable(self) -> bool: def seekable(self) -> bool:
return False return False

View file

@ -1,23 +1,16 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
import struct import struct
import sys
import time import time
from logging import getLogger from io import RawIOBase
from typing import Callable, Collection, List, Optional from typing import Callable, Collection, Optional
from libscrc import modbus # type: ignore from libscrc import modbus
from .constants import ACTION_READ, ACTION_WRITE, POSSIBLE_MARKER from .constants import ACTION_READ, ACTION_WRITE, POSSIBLE_MARKER
from .interfaces import BaseInterface from .interfaces import BaseInterface
from .solar_types import ( from .solar_types import DATA_BATTERY_STATE, HISTORICAL_DATA, DataItem
DATA_BATTERY_STATE, from .util import log
HISTORICAL_DATA,
ChargerState,
DataItem,
HistoricalData,
HistoricalExtraInfo,
)
logger = getLogger(__name__)
def write(fh, data): def write(fh, data):
@ -27,14 +20,14 @@ def write(fh, data):
fh.write(data + bcrc) fh.write(data + bcrc)
def construct_read_request(address, words=1, marker=0xFF): def construct_request(address, words=1, action=ACTION_READ, marker=0xFF):
assert marker in POSSIBLE_MARKER, f"marker should be one of {POSSIBLE_MARKER}" assert marker in POSSIBLE_MARKER, f"marker should be one of {POSSIBLE_MARKER}"
return struct.pack("!BBHH", marker, ACTION_READ, address, words) return struct.pack("!BBHH", marker, action, address, words)
def construct_write_request(address, marker=0xFF): def construct_write(address, data: bytes, action=ACTION_WRITE, marker=0xFF):
assert marker in POSSIBLE_MARKER, f"marker should be one of {POSSIBLE_MARKER}" assert marker in POSSIBLE_MARKER, f"marker should be one of {POSSIBLE_MARKER}"
return struct.pack("!BBH", marker, ACTION_WRITE, address) return struct.pack("!BBH", marker, action, address) + data
def parse(data: bytes, items: Collection[DataItem], offset: int = 0) -> dict: def parse(data: bytes, items: Collection[DataItem], offset: int = 0) -> dict:
@ -73,33 +66,35 @@ def parse_packet(data):
if crc != calculated_crc: if crc != calculated_crc:
e = ValueError(f"CRC missmatch: expected {crc:04X}, got {calculated_crc:04X}.") e = ValueError(f"CRC missmatch: expected {crc:04X}, got {calculated_crc:04X}.")
# e.tag = tag e.tag = tag
# e.operation = operation e.operation = operation
# e.size = size e.size = size
# e.payload = payload e.payload = payload
# e.crc = crc e.crc = crc
# e.calculated_crc = calculated_crc e.calculated_crc = calculated_crc
raise e raise e
return payload return payload
def discardUntil(fh: BaseInterface, byte: int, timeout=10) -> Optional[int]: def discardUntil(fh: RawIOBase, byte: int, timeout=10) -> Optional[int]:
assert byte >= 0 and byte < 256, f"byte: Expected 8bit unsigned int, got {byte}" assert byte >= 0 and byte < 256, f"byte: Expected 8bit unsigned int, got {byte}"
def expand(b: Optional[bytes]): def expand(b: Optional[bytes]):
if not b: if b is None:
return None return b
return b[0] return b[0]
start = time.time() start = time.time()
discarded: List[str] = [] discarded = 0
read_byte = expand(fh.read(1)) read_byte = expand(fh.read(1))
while read_byte != byte: while read_byte != byte:
if read_byte is not None: if read_byte is not None:
if not discarded: if not discarded:
discarded.append("Discarding") log("Discarding", end="")
discarded.append(f"{read_byte:02X}") discarded += 1
print(f" {read_byte:02X}", end="")
sys.stdout.flush()
if time.time() - start > timeout: if time.time() - start > timeout:
read_byte = None read_byte = None
@ -108,14 +103,15 @@ def discardUntil(fh: BaseInterface, byte: int, timeout=10) -> Optional[int]:
read_byte = expand(fh.read(1)) read_byte = expand(fh.read(1))
if discarded: if discarded:
logger.debug(" ".join(discarded)) print()
sys.stdout.flush()
return read_byte return read_byte
def readMemory(fh: BaseInterface, 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}") # log(f"Reading {words} words from 0x{address:04X}")
request = construct_read_request(address, words=words) request = construct_request(address, words=words)
# log("Request:", request) # log("Request:", request)
write(fh, request) write(fh, request)
@ -132,79 +128,51 @@ def readMemory(fh: BaseInterface, address: int, words: int = 1) -> Optional[byte
try: try:
crc = struct.unpack_from("<H", _crc)[0] crc = struct.unpack_from("<H", _crc)[0]
except struct.error: except struct.error:
logger.error( log(f"readMemory: CRC error; read {len(_crc)} bytes (2 expected)")
"readMemory: CRC error; read %s bytes (2 expected)", len(_crc)
)
return None return None
calculated_crc = modbus(bytes([tag, operation, size, *data])) calculated_crc = modbus(bytes([tag, operation, size, *data]))
if crc == calculated_crc: if crc == calculated_crc:
return data return data
else: else:
logger.error( log(f"readMemory: CRC error; {crc:04X} != {calculated_crc:04X}")
f"readMemory: CRC error; {crc:04X} != {calculated_crc:04X}" log("data or crc is falsely", header, data, _crc)
)
logger.error("data or crc is falsely %s %s %s", header, data, _crc)
return None return None
# set(255, 266, 1 or 0) def writeMemory(fh: BaseInterface, address: int, output_data: bytes) -> Optional[bytes]:
# ff 06 01 0a 00 01 # TODO: Verify behavior on multi-word writes
# CMD_ENABLE_LOAD = b"\xff\x06\x01\x0a\x00\x01" # log(f"Reading {words} words from 0x{address:04X}")
# CMD_DISABLE_LOAD = b"\xff\x06\x01\x0a\x00\x00" request = construct_write(address, data=output_data)
# REG_LOAD_ENABLE = 0x010A # log("Request:", request)
write(fh, request)
def writeMemory(fh: BaseInterface, address: int, 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)
tag = discardUntil(fh, 0xFF) tag = discardUntil(fh, 0xFF)
if tag is None: if tag is None:
return None return None
header = fh.read(3) _operation = fh.read(1)
if header and len(header) == 3: result_addr = fh.read(2)
operation, size, address = header # log("Operation:", _operation)
logger.log(5, header) if _operation is not None and result_addr is not None:
# size field is zero when writing device name for whatever reason operation = _operation[0]
# write command seems to only accept a single word, so this is fine; data = fh.read(2)
# we just hardcode the number of bytes read to two here. # log("Data:", data)
rdata = fh.read(2)
_crc = fh.read(2) _crc = fh.read(2)
if rdata and _crc: if data and _crc:
try: try:
crc = struct.unpack_from("<H", _crc)[0] crc = struct.unpack_from("<H", _crc)[0]
except struct.error: except struct.error:
logger.error( log(f"readMemory: CRC error; read {len(_crc)} bytes (2 expected)")
f"writeMemory: CRC error; read {len(_crc)} bytes (2 expected)"
)
return None return None
calculated_crc = modbus(bytes([tag, operation, size, address, *rdata])) calculated_crc = modbus(bytes([tag, operation, *result_addr, *data]))
if crc == calculated_crc: if crc == calculated_crc:
return rdata return data
else: else:
logger.error( log(f"readMemory: CRC error; {crc:04X} != {calculated_crc:04X}")
f"writeMemory: CRC error; {crc:04X} != {calculated_crc:04X}" log("data or crc is falsely", operation, result_addr, data, _crc)
)
logger.error("data or crc is falsely %s %s %s", header, rdata, _crc)
return None 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]
r = writeMemory(fh, address + i, d)
if r:
res.extend(r)
return res
def try_read_parse( def try_read_parse(
dev: BaseInterface, dev: BaseInterface,
address: int, address: int,
@ -219,156 +187,10 @@ def try_read_parse(
try: try:
if parser: if parser:
return parser(res) return parser(res)
except struct.error: except struct.error as e:
logger.exception("0x0100 Unpack error: %s %s", len(res), res) log(e)
_timeout = dev.timeout log("0x0100 Unpack error:", len(res), res)
dev.timeout = 0.5 log("Flushed from read buffer; ", dev.read()) # TODO: timeout=0.5
logger.warning("Flushed from read buffer; %s", dev.read())
dev.timeout = _timeout
else: else:
logger.warning( log(f"No data read, expected {words*2} bytes (attempts left: {attempts})")
f"No data read, expected {words*2} bytes (attempts left: {attempts})"
)
return None return None
class ChargeController:
device: BaseInterface
manufacturer: str = "SRNE Solar Co., Ltd."
manufacturer_id: str = "srne"
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
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)

View file

@ -1,8 +1,7 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
import struct import struct
from abc import ABC, abstractmethod
from enum import Enum, unique from enum import Enum, unique
from typing import Any, Callable, Dict, Optional from typing import Callable, Optional
@unique @unique
@ -22,7 +21,7 @@ class DataName(str, Enum):
BATTERY_VOLTAGE_MIN = "battery_voltage_min" BATTERY_VOLTAGE_MIN = "battery_voltage_min"
BATTERY_VOLTAGE_MAX = "battery_voltage_max" BATTERY_VOLTAGE_MAX = "battery_voltage_max"
CHARGE_MAX_CURRENT = "charge_max_current" CHARGE_MAX_CURRENT = "charge_max_current"
DISCHARGE_MAX_CURRENT = "discharge_max_current" _DISCHARGE_MAX_CURRENT = "_discharge_max_current?"
CHARGE_MAX_POWER = "charge_max_power" CHARGE_MAX_POWER = "charge_max_power"
DISCHARGE_MAX_POWER = "discharge_max_power" DISCHARGE_MAX_POWER = "discharge_max_power"
CHARGE_AMP_HOUR = "charge_amp_hour" CHARGE_AMP_HOUR = "charge_amp_hour"
@ -45,7 +44,7 @@ class DataName(str, Enum):
return repr(self.value) return repr(self.value)
def __str__(self): def __str__(self):
return self.value return str(self.value)
class DataItem: class DataItem:
@ -105,14 +104,13 @@ HISTORICAL_DATA = [
DataItem(DataName.BATTERY_VOLTAGE_MIN, "H", "V", lambda n: n / 10), DataItem(DataName.BATTERY_VOLTAGE_MIN, "H", "V", lambda n: n / 10),
DataItem(DataName.BATTERY_VOLTAGE_MAX, "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.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.CHARGE_MAX_POWER, "H", "W"),
DataItem(DataName.DISCHARGE_MAX_POWER, "H", "W"), DataItem(DataName.DISCHARGE_MAX_POWER, "H", "W"),
DataItem(DataName.CHARGE_AMP_HOUR, "H", "Ah"), DataItem(DataName.CHARGE_AMP_HOUR, "H", "Ah"),
DataItem(DataName.DISCHARGE_AMP_HOUR, "H", "Ah"), DataItem(DataName.DISCHARGE_AMP_HOUR, "H", "Ah"),
DataItem(DataName.PRODUCTION_ENERGY, "H", "Wh"), DataItem(DataName.PRODUCTION_ENERGY, "H", "Wh"),
DataItem(DataName.CONSUMPTION_ENERGY, "H", "Wh"), DataItem(DataName.CONSUMPTION_ENERGY, "H", "Wh"),
#
DataItem(DataName.RUN_DAYS, "H"), DataItem(DataName.RUN_DAYS, "H"),
DataItem(DataName.DISCHARGE_COUNT, "H"), DataItem(DataName.DISCHARGE_COUNT, "H"),
DataItem(DataName.FULL_CHARGE_COUNT, "H"), DataItem(DataName.FULL_CHARGE_COUNT, "H"),
@ -121,180 +119,3 @@ HISTORICAL_DATA = [
DataItem(DataName.TOTAL_PRODUCTION_ENERGY, "L", "Wh"), DataItem(DataName.TOTAL_PRODUCTION_ENERGY, "L", "Wh"),
DataItem(DataName.TOTAL_CONSUMPTION_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,
}

103
srnemqtt/srne.py Normal file
View file

@ -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()

View file

@ -1,18 +1,14 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
import json
import time
from logging import Filter as LoggingFilter
from logging import getLogger
from typing import Dict, Optional
__all__ = ["humanize_number", "Periodical", "LazyJSON", "LoggingDictFilter"] import datetime
import sys
import time
from typing import Optional
# Only factor of 1000 # Only factor of 1000
SI_PREFIXES_LARGE = "kMGTPEZY" SI_PREFIXES_LARGE = "kMGTPEZY"
SI_PREFIXES_SMALL = "mµnpfazy" SI_PREFIXES_SMALL = "mµnpfazy"
logger = getLogger(__name__)
def humanize_number(data, unit: str = ""): def humanize_number(data, unit: str = ""):
counter = 0 counter = 0
@ -39,30 +35,9 @@ def humanize_number(data, unit: str = ""):
return f"{data:.3g} {prefix}{unit}" return f"{data:.3g} {prefix}{unit}"
class LazyJSON: def log(*message: object, **kwargs):
def __init__(self, data): print(datetime.datetime.utcnow().isoformat(" "), *message, **kwargs)
self.data = data sys.stdout.flush()
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: class Periodical:
@ -81,9 +56,7 @@ class Periodical:
skipped, overshoot = divmod(now - self.prev, self.interval) skipped, overshoot = divmod(now - self.prev, self.interval)
skipped -= 1 skipped -= 1
if skipped: if skipped:
logger.debug( log("Skipped:", skipped, overshoot, now - self.prev, self.interval)
"Skipped:", skipped, overshoot, now - self.prev, self.interval
)
self.prev = now - overshoot self.prev = now - overshoot
return True return True

View file

@ -1,11 +0,0 @@
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"

View file

@ -1,7 +1,3 @@
[flake8] [flake8]
max-line-length = 120 max-line-length = 88
extend-ignore = E203, I201, I101 extend-ignore = E203, I201, I101
[pytest]
pythonpath = .
testpaths = tests