Compare commits

...
Sign in to create a new pull request.

24 commits

Author SHA1 Message Date
1890036b6c Fix service deployment 2024-01-03 11:28:33 +01:00
b4e8258de1 Improve logging of dictionaries 2023-12-18 14:26:56 +01:00
d38abe28ba Move to python logging 2023-12-16 23:36:53 +01:00
5524d16f68 Limit amount of historical data loaded on start 2023-12-12 11:32:45 +01:00
33acd05b8a Partial manual merge of forgotten branch output-toggle 2023-12-12 11:29:44 +01:00
67a25eeef9 Rework and restructure MQTT 2023-12-10 23:59:50 +01:00
6c0f1c3d13 Allow reading and writing device name 2023-12-10 23:59:19 +01:00
4dc42ee6f5 Aggressively cache properties
which are not expected to change at run time
2023-12-10 23:54:13 +01:00
3aa6b13615 Fix writing of multiple words to charge controller 2023-12-10 23:52:38 +01:00
71919fc406 Make consumer aware of the charge controller 2023-12-10 23:50:34 +01:00
fe9c6a82ff Fix unpack, rework main to use ChargeController 2023-12-10 16:08:28 +01:00
f0c2057428 Implement more getters in ChargeController 2023-12-10 13:51:07 +01:00
4bb77c3bb3 Add fixme comments 2023-12-09 19:17:27 +01:00
5599cb6f43 Properly implement writing memory
Start implementing abstraction class
2023-12-09 18:57:37 +01:00
6544864741 Make sure DataName returns the correct value 2023-12-09 16:46:52 +01:00
b33a466c4f Add restarting systemd service to deploy 2023-12-09 16:37:09 +01:00
9bb8e2e02e Make sure DataName returns the correct value 2023-12-09 16:36:25 +01:00
457e7cf8a3 Fix mypy issues 2023-12-09 16:35:45 +01:00
8282ec3956 Add tool for dumping memory map 2023-12-09 11:48:53 +01:00
f2d59fac94 Correct ACTION_WRITE 2023-12-08 20:51:58 +01:00
abbdd47c9d Add systemd unit 2023-12-08 16:13:47 +01:00
3c8942b485 Configure pytest 2023-12-08 13:45:38 +01:00
7977d89abf mypy: ignore missing hints in external 2023-12-08 13:45:05 +01:00
b6a62d123d Autoformat 2023-12-08 12:36:26 +01:00
27 changed files with 961 additions and 202 deletions

View file

@ -16,3 +16,6 @@ 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,6 +41,9 @@ repos:
args: args:
- "--install-types" - "--install-types"
- "--non-interactive" - "--non-interactive"
- "--check-untyped-defs"
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

14
.vscode/settings.json vendored
View file

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

View file

@ -1,2 +1,5 @@
#!/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
with open("z_solar copy.log") as fh: if __name__ == "__main__":
data = list(parse_log(fh)) with open("z_solar copy.log") as fh:
# print(data) data = list(parse_log(fh))
# print(data)
# data = list(range(256)) # data = list(range(256))
print(
print( memory_table(
memory_table( data,
data, wordsize=2,
wordsize=2, skip_nullrows=True,
skip_nullrows=True, )
) )
)
# #

29
misc/dump_memory_map.py Normal file
View file

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

153
misc/memory_dump_MT2410.txt Normal file
View file

@ -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│
├────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┤
│ │ ~│ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │
├────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┤
└────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┘

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 import rrdtool # type: ignore
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,7 +147,6 @@ 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,13 +1,12 @@
# -*- 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_request, write from srnemqtt.protocol import construct_read_request, write
with BTLEUart(MAC, timeout=1) as x: with BTLEUart(MAC, timeout=1) as x:
print(x) print(x)
write(x, construct_request(0x0E, words=3)) write(x, construct_read_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)

26
misc/test_load_switch.py Normal file
View file

@ -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}")

View file

@ -3,22 +3,23 @@ import os
import sys import sys
from time import sleep from time import sleep
from serial import Serial from serial import Serial # type: ignore
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_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) 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_request(0x0E, words=3)) write(x, construct_read_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 from serial import Serial # type: ignore
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,5 +4,6 @@ bluepy
libscrc libscrc
paho-mqtt paho-mqtt
pyserial pyserial
graypy
types-PyYAML types-PyYAML

14
solarmppt.service Normal file
View file

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

View file

@ -2,16 +2,18 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
import time import time
from decimal import Decimal from logging import getLogger
from typing import cast from logging import root as logging_root
from logging.config import dictConfig as loggingDictConfig
from bluepy.btle import BTLEDisconnectError from bluepy.btle import BTLEDisconnectError # type: ignore
from serial import SerialException from serial import SerialException # type: ignore
from .config import get_config, get_consumers, get_interface from .config import get_config, get_consumers, get_interface
from .protocol import parse_battery_state, parse_historical_entry, try_read_parse from .protocol import ChargeController
from .solar_types import DataName from .util import LazyJSON, LoggingDictFilter, Periodical
from .util import Periodical, log
logger = getLogger("SolarMPPT")
class CommunicationError(BTLEDisconnectError, SerialException, IOError): class CommunicationError(BTLEDisconnectError, SerialException, IOError):
@ -20,20 +22,34 @@ class CommunicationError(BTLEDisconnectError, SerialException, IOError):
def main(): def main():
conf = get_config() 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) 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)
# import serial
# ser = serial.Serial()
try: try:
while True: while True:
try: try:
log("Connecting...") logger.info("Connecting...")
with get_interface() as dev: 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)) # write(dev, construct_request(0, 32))
@ -41,61 +57,45 @@ def main():
# for address in range(0, 0x10000, 16): # for address in range(0, 0x10000, 16):
# log(f"Reading 0x{address:04X}...") # log(f"Reading 0x{address:04X}...")
# write(wd, construct_request(address, 16)) # write(wd, construct_request(address, 16))
days = 7 extra = cc.extra
res = try_read_parse(dev, 0x010B, 21, parse_historical_entry) days = extra.run_days
if res:
log(res)
for consumer in consumers:
consumer.write(res)
days = cast(int, res.get("run_days", 7))
for i in range(days): res = cc.today.as_dict()
res = try_read_parse( res.update(extra.as_dict())
dev, 0xF000 + i, 10, parse_historical_entry for consumer in consumers:
) consumer.write(res)
if res: del extra
log({i: res})
for consumer in consumers: # Historical data isn't actually used anywhere yet
consumer.write({str(i): res}) # 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: while True:
now = time.time() now = time.time()
if per_voltages(now): if per_voltages(now):
data = try_read_parse(dev, 0x0100, 11, parse_battery_state) data = cc.state.as_dict()
if data: logger.debug(LazyJSON(data))
data[DataName.CALCULATED_BATTERY_POWER] = float( for consumer in consumers:
Decimal(str(data.get(DataName.BATTERY_VOLTAGE, 0))) consumer.write(data)
* Decimal(
str(data.get(DataName.BATTERY_CURRENT, 0))
)
)
data[DataName.CALCULATED_PANEL_POWER] = float(
Decimal(str(data.get(DataName.PANEL_VOLTAGE, 0)))
* Decimal(str(data.get(DataName.PANEL_CURRENT, 0)))
)
data[DataName.CALCULATED_LOAD_POWER] = float(
Decimal(str(data.get(DataName.LOAD_VOLTAGE, 0)))
* Decimal(str(data.get(DataName.LOAD_CURRENT, 0)))
)
log(data)
for consumer in consumers:
consumer.write(data)
if per_current_hist(now): if per_current_hist(now):
data = try_read_parse( data = cc.today.as_dict()
dev, 0x010B, 21, parse_historical_entry data.update(cc.extra.as_dict())
) logger.debug(LazyJSON(data))
if data: for consumer in consumers:
log(data) consumer.write(data)
for consumer in consumers:
consumer.write(data)
# 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)))
# if STATUS.get('load_enabled'): # if STATUS.get('load_enabled'):
# write(wd, CMD_DISABLE_LOAD) # write(wd, CMD_DISABLE_LOAD)
@ -103,7 +103,7 @@ def main():
# write(wd, CMD_ENABLE_LOAD) # write(wd, CMD_ENABLE_LOAD)
except CommunicationError: except CommunicationError:
log("ERROR: Disconnected") logger.error("Disconnected")
time.sleep(1) time.sleep(1)
except (KeyboardInterrupt, SystemExit, Exception) as e: except (KeyboardInterrupt, SystemExit, Exception) as e:

View file

@ -6,9 +6,8 @@ from typing import Any, Dict, List, Optional, Type
import yaml import yaml
from srnemqtt.interfaces import BaseInterface
from .consumers import BaseConsumer from .consumers import BaseConsumer
from .interfaces import BaseInterface
def get_consumer(name: str) -> Optional[Type[BaseConsumer]]: 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: 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
@ -35,7 +57,9 @@ 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")
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]: def get_consumers(conf: Optional[Dict[str, Any]] = None) -> List[BaseConsumer]:

View file

@ -5,7 +5,7 @@ MAC = "DC:0D:30:9C:61:BA"
# read_service = "0000fff0-0000-1000-8000-00805f9b34fb" # read_service = "0000fff0-0000-1000-8000-00805f9b34fb"
ACTION_READ = 0x03 ACTION_READ = 0x03
ACTION_WRITE = 0x03 ACTION_WRITE = 0x06
POSSIBLE_MARKER = (0x01, 0xFD, 0xFE, 0xFF) POSSIBLE_MARKER = (0x01, 0xFD, 0xFE, 0xFF)

View file

@ -2,9 +2,12 @@
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from typing import Any, Dict from typing import Any, Dict
from ..protocol import ChargeController
class BaseConsumer(ABC): class BaseConsumer(ABC):
settings: Dict[str, Any] settings: Dict[str, Any]
controller: ChargeController | None = None
@abstractmethod @abstractmethod
def __init__(self, settings: Dict[str, Any]) -> None: def __init__(self, settings: Dict[str, Any]) -> None:

View file

@ -1,7 +1,8 @@
# -*- 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 from typing import Any, Dict, List, Optional, TypeAlias
from uuid import uuid4 from uuid import uuid4
import paho.mqtt.client as mqtt import paho.mqtt.client as mqtt
@ -9,11 +10,13 @@ import paho.mqtt.client as mqtt
from ..solar_types import DataName from ..solar_types import DataName
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: {},
@ -82,6 +85,10 @@ MAP_VALUES: Dict[DataName, Dict[str, Any]] = {
"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: {
"type": "outlet",
"platform": "switch",
},
DataName.PANEL_VOLTAGE: { DataName.PANEL_VOLTAGE: {
"unit": "V", "unit": "V",
"type": "voltage", "type": "voltage",
@ -112,29 +119,41 @@ 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]
_client: mqtt.Client | None = None
def __init__(self, settings: Dict[str, Any]) -> None: def __init__(self, settings: Dict[str, Any]) -> None:
self.initialized = [] self.initialized = []
super().__init__(settings) super().__init__(settings)
self.client = mqtt.Client(client_id=settings["client"]["id"], userdata=self)
self.client.on_connect = self.on_connect @property
self.client.on_message = self.on_message def client(self) -> mqtt.Client:
self.client.on_disconnect = self.on_disconnect if self._client is not None:
self.client.on_connect_fail = self.on_connect_fail 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!! # 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(
settings["client"]["host"], self.settings["client"]["host"],
settings["client"]["port"], self.settings["client"]["port"],
settings["client"]["keepalive"], self.settings["client"]["keepalive"],
) )
break break
except OSError as err: except OSError as err:
@ -145,9 +164,13 @@ 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
print(err) logger.warning(
"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)
@ -164,35 +187,50 @@ 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.settings['device_id']}" return f"{self.settings['prefix']}/{self.controller_id}"
def get_ha_config( def get_ha_config(
self, self,
id, id: str,
name, name: str,
unit: Optional[str] = None, unit: Optional[str] = None,
type: Optional[str] = None, type: Optional[str] = None,
expiry: int = 90, expiry: int = 90,
state_class: Optional[str] = None, state_class: Optional[str] = None,
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.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", "availability_topic": "~/available",
"state_topic": f"~/{id}", "state_topic": f"~/{id}",
"name": name, "name": name,
"device": { "device": {
"identifiers": [ "identifiers": [
self.settings["device_id"], self.controller_id,
], ],
# TODO: Get charger serial and use for identifier instead "manufacturer": self.controller.manufacturer,
# See: https://www.home-assistant.io/integrations/sensor.mqtt/#device "model": self.controller.model,
# "via_device": self.settings["device_id"], "sw_version": self.controller.version,
"via_device": self.settings["device_id"],
"suggested_area": "Solar panel", "suggested_area": "Solar panel",
"name": self.controller.name,
}, },
"force_update": True, "force_update": True,
"expire_after": expiry, "expire_after": expiry,
@ -204,13 +242,16 @@ class MqttConsumer(BaseConsumer):
res["dev_cla"] = type res["dev_cla"] = type
if state_class: if state_class:
res["state_class"] = 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 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):
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 # Subscribing in on_connect() means that if we lose the connection and
# reconnect then subscriptions will be renewed. # reconnect then subscriptions will be renewed.
@ -219,52 +260,74 @@ class MqttConsumer(BaseConsumer):
f"{userdata.topic_prefix}/available", payload="online", retain=True 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 @staticmethod
def on_connect_fail(client: mqtt.Client, userdata: "MqttConsumer"): 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. # 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):
print(msg.topic + " " + str(msg.payload)) logger.info(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):
print(userdata.__class__.__name__, "on_disconnect", rc) logger.warning("on_disconnect %s", 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:
print(self.__class__.__name__, "loop returned non-success:", res) logger.warning("loop returned non-success: %s", 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:
print(self.__class__.__name__, "Reconnect failed:", res) logger.error("Reconnect failed: %s", res)
except (OSError, mqtt.WebsocketConnectionError) as err: except (OSError, mqtt.WebsocketConnectionError) as err:
print(self.__class__.__name__, "Reconnect failed:", err) logger.error("Reconnect failed: %s", err)
return super().poll() 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)) self.client.publish(f"{self.topic_prefix}/raw", payload=json.dumps(data))
for k, v in data.items(): for dataname, data_value in data.items():
if k in MAP_VALUES: if dataname in MAP_VALUES:
if k not in self.initialized: if dataname not in self.initialized:
km = MAP_VALUES[DataName(k)] km = MAP_VALUES[DataName(dataname)]
pretty_name = k.replace("_", " ").capitalize() pretty_name = dataname.replace("_", " ").capitalize()
disc_prefix = self.settings["discovery_prefix"] disc_prefix = self.settings["discovery_prefix"]
device_id = self.settings["device_id"] platform = km.get("platform", "sensor")
self.client.publish( self.client.publish(
f"{disc_prefix}/sensor/{device_id}_{k}/config", f"{disc_prefix}/{platform}/{self.controller_id}/{dataname}/config",
payload=json.dumps(self.get_ha_config(k, pretty_name, **km)), payload=json.dumps(
self.get_ha_config(dataname, pretty_name, **km)
),
retain=True, 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): def exit(self):
self.client.publish( self.client.publish(

View file

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

View file

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

View file

@ -4,11 +4,7 @@ import queue
import time import time
from typing import TYPE_CHECKING, Optional, cast from typing import TYPE_CHECKING, Optional, cast
from bluepy import btle from bluepy import btle # type: ignore
if TYPE_CHECKING:
from _typeshed import ReadableBuffer, WriteableBuffer
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"
@ -18,7 +14,7 @@ class BTLEUart(io.RawIOBase):
mac: str mac: str
write_endpoint: str write_endpoint: str
read_endpoint: str read_endpoint: str
timeout: float timeout: float | None
device: Optional[btle.Peripheral] = None device: Optional[btle.Peripheral] = None
_write_handle: Optional[btle.Characteristic] = 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] 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, timeout: Optional[float] = None): def _read(self, num: Optional[int] = 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)
if timeout is None: timeout = self.timeout or 30
timeout = self.timeout
if num is None: if num is None:
start = time.time() start = time.time()
@ -132,7 +127,9 @@ 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: "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)) data = self._read(len(buffer))
if data is None: if data is None:
@ -144,23 +141,15 @@ class BTLEUart(io.RawIOBase):
def readall(self) -> bytes: def readall(self) -> bytes:
return self._read() return self._read()
def read( def read(self, size: Optional[int] = None) -> Optional[bytes]:
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: "ReadableBuffer") -> Optional[int]: def write(self, b: bytes | bytearray | memoryview) -> Optional[int]: # type: ignore [override]
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)
@ -174,8 +163,9 @@ class BTLEUart(io.RawIOBase):
return self return self
def __exit__(self, type, value, traceback): def __exit__(self, type, value, traceback):
self.device.disconnect() if self.device is not None:
del self.device self.device.disconnect()
self.device = None
def seekable(self) -> bool: def seekable(self) -> bool:
return False return False

View file

@ -1,16 +1,23 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
import struct import struct
import sys
import time import time
from io import RawIOBase from logging import getLogger
from typing import Callable, Collection, Optional 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 .constants import ACTION_READ, ACTION_WRITE, POSSIBLE_MARKER
from .lib.feasycom_ble import BTLEUart from .interfaces import BaseInterface
from .solar_types import DATA_BATTERY_STATE, HISTORICAL_DATA, DataItem from .solar_types import (
from .util import log DATA_BATTERY_STATE,
HISTORICAL_DATA,
ChargerState,
DataItem,
HistoricalData,
HistoricalExtraInfo,
)
logger = getLogger(__name__)
def write(fh, data): def write(fh, data):
@ -20,9 +27,14 @@ def write(fh, data):
fh.write(data + bcrc) 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}" 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: def parse(data: bytes, items: Collection[DataItem], offset: int = 0) -> dict:
@ -61,36 +73,33 @@ 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: 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}" 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 b is None: if not b:
return b return None
return b[0] return b[0]
start = time.time() start = time.time()
discarded = 0 discarded: List[str] = []
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:
log("Discarding", end="") discarded.append("Discarding")
discarded += 1 discarded.append(f"{read_byte:02X}")
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
@ -99,15 +108,14 @@ def discardUntil(fh: RawIOBase, byte: int, timeout=10) -> Optional[int]:
read_byte = expand(fh.read(1)) read_byte = expand(fh.read(1))
if discarded: if discarded:
print() logger.debug(" ".join(discarded))
sys.stdout.flush()
return read_byte 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}") # 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) # log("Request:", request)
write(fh, request) write(fh, request)
@ -124,19 +132,81 @@ def readMemory(fh: RawIOBase, address: int, words: int = 1) -> Optional[bytes]:
try: try:
crc = struct.unpack_from("<H", _crc)[0] crc = struct.unpack_from("<H", _crc)[0]
except struct.error: except struct.error:
log(f"readMemory: CRC error; read {len(_crc)} bytes (2 expected)") logger.error(
"readMemory: CRC error; read %s bytes (2 expected)", len(_crc)
)
return None 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:
log(f"readMemory: CRC error; {crc:04X} != {calculated_crc:04X}") logger.error(
log("data or crc is falsely", header, data, _crc) f"readMemory: CRC error; {crc:04X} != {calculated_crc:04X}"
)
logger.error("data or crc is falsely %s %s %s", header, data, _crc)
return None return None
# set(255, 266, 1 or 0)
# ff 06 01 0a 00 01
# CMD_ENABLE_LOAD = b"\xff\x06\x01\x0a\x00\x01"
# CMD_DISABLE_LOAD = b"\xff\x06\x01\x0a\x00\x00"
# REG_LOAD_ENABLE = 0x010A
def writeMemory(fh: BaseInterface, address: int, data: bytes):
if len(data) != 2:
raise ValueError(f"Data must consist of a two-byte word, got {len(data)} bytes")
header = construct_write_request(address)
write(fh, header + data)
tag = discardUntil(fh, 0xFF)
if tag is None:
return None
header = fh.read(3)
if header and len(header) == 3:
operation, size, address = header
logger.log(5, header)
# size field is zero when writing device name for whatever reason
# write command seems to only accept a single word, so this is fine;
# we just hardcode the number of bytes read to two here.
rdata = fh.read(2)
_crc = fh.read(2)
if rdata and _crc:
try:
crc = struct.unpack_from("<H", _crc)[0]
except struct.error:
logger.error(
f"writeMemory: CRC error; read {len(_crc)} bytes (2 expected)"
)
return None
calculated_crc = modbus(bytes([tag, operation, size, address, *rdata]))
if crc == calculated_crc:
return rdata
else:
logger.error(
f"writeMemory: CRC error; {crc:04X} != {calculated_crc:04X}"
)
logger.error("data or crc is falsely %s %s %s", header, rdata, _crc)
return None
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: BTLEUart, dev: BaseInterface,
address: int, address: int,
words: int = 1, words: int = 1,
parser: Optional[Callable] = None, parser: Optional[Callable] = None,
@ -149,10 +219,156 @@ def try_read_parse(
try: try:
if parser: if parser:
return parser(res) return parser(res)
except struct.error as e: except struct.error:
log(e) logger.exception("0x0100 Unpack error: %s %s", len(res), res)
log("0x0100 Unpack error:", len(res), res) _timeout = dev.timeout
log("Flushed from read buffer; ", dev.read(timeout=0.5)) dev.timeout = 0.5
logger.warning("Flushed from read buffer; %s", dev.read())
dev.timeout = _timeout
else: else:
log(f"No data read, expected {words*2} bytes (attempts left: {attempts})") logger.warning(
f"No data read, expected {words*2} bytes (attempts left: {attempts})"
)
return None 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,7 +1,8 @@
# -*- 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 Callable, Optional from typing import Any, Callable, Dict, Optional
@unique @unique
@ -21,7 +22,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"
@ -43,6 +44,9 @@ class DataName(str, Enum):
def __repr__(self): def __repr__(self):
return repr(self.value) return repr(self.value)
def __str__(self):
return self.value
class DataItem: class DataItem:
name: DataName name: DataName
@ -101,13 +105,14 @@ 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"),
@ -116,3 +121,180 @@ 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,
}

View file

@ -1,14 +1,18 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
import json
import datetime
import sys
import time 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 # 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
@ -35,9 +39,30 @@ def humanize_number(data, unit: str = ""):
return f"{data:.3g} {prefix}{unit}" return f"{data:.3g} {prefix}{unit}"
def log(*message: object, **kwargs): class LazyJSON:
print(datetime.datetime.utcnow().isoformat(" "), *message, **kwargs) def __init__(self, data):
sys.stdout.flush() 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: class Periodical:
@ -56,7 +81,9 @@ 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:
log("Skipped:", skipped, overshoot, now - self.prev, self.interval) logger.debug(
"Skipped:", skipped, overshoot, now - self.prev, self.interval
)
self.prev = now - overshoot self.prev = now - overshoot
return True return True

11
tests/test_protocol.py Normal file
View file

@ -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"

View file

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