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}]
indent_size = 2
[.vscode/*.json]
insert_final_newline = false

View file

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

16
.vscode/settings.json vendored
View file

@ -1,6 +1,14 @@
{
"python.linting.mypyEnabled": true,
"python.formatting.provider": "black",
"editor.formatOnSave": true,
"python.linting.flake8Enabled": true
}
"pylint.args": [
"--disable=missing-function-docstring,missing-class-docstring,missing-module-docstring"
],
"python.testing.unittestEnabled": false,
"python.testing.pytestEnabled": true,
"mypy-type-checker.importStrategy": "fromEnvironment",
"mypy-type-checker.reportingScope": "workspace",
"mypy-type-checker.preferDaemon": true,
"mypy-type-checker.args": [
"--check-untyped-defs"
]
}

View file

@ -1,2 +1,5 @@
#!/bin/bash
rsync --exclude=.git --exclude='packet format.java' --exclude-from=.gitignore -ravC . pi@solarpi:ble
ssh pi@solarpi './ble-venv/bin/pip install -r ble/requirements.txt'
ssh pi@solarpi 'ln -fs $(pwd)/ble/solarmppt.service ~/.config/systemd/user/solarmppt.service'
ssh pi@solarpi 'loginctl enable-linger; systemctl --user daemon-reload; systemctl --user restart solarmppt'

View file

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

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

View file

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

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

View file

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

View file

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

View file

@ -6,9 +6,8 @@ from typing import Any, Dict, List, Optional, Type
import yaml
from srnemqtt.interfaces import BaseInterface
from .consumers import BaseConsumer
from .interfaces import BaseInterface
def get_consumer(name: str) -> Optional[Type[BaseConsumer]]:
@ -28,6 +27,29 @@ def get_config() -> Dict[str, Any]:
with open("config.yaml", "r") as fh:
conf: dict = yaml.safe_load(fh)
conf.setdefault("consumers", {})
logging = conf.setdefault("logging", {})
logging.setdefault("version", 1)
logging.setdefault("disable_existing_loggers", False)
logging.setdefault(
"handlers",
{
"console": {
"class": "logging.StreamHandler",
"formatter": "default",
"level": "INFO",
"stream": "ext://sys.stdout",
}
},
)
logging.setdefault(
"formatters",
{
"format": "%(asctime)s %(levelname)-8s %(name)-15s %(message)s",
"datefmt": "%Y-%m-%d %H:%M:%S",
},
)
loggers = logging.setdefault("loggers", {})
loggers.setdefault("root", {"handlers": ["console"], "level": "DEBUG"})
return conf
@ -35,7 +57,9 @@ def get_config() -> Dict[str, Any]:
def write_config(conf: Dict[str, Any]):
with open(".config.yaml~writing", "w") as fh:
yaml.safe_dump(conf, fh, indent=2, encoding="utf-8")
os.rename(".config.yaml~writing", "config.yaml")
fh.flush()
os.fsync(fh.fileno())
os.replace(".config.yaml~writing", "config.yaml")
def get_consumers(conf: Optional[Dict[str, Any]] = None) -> List[BaseConsumer]:

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,16 +1,23 @@
# -*- coding: utf-8 -*-
import struct
import sys
import time
from io import RawIOBase
from typing import Callable, Collection, Optional
from logging import getLogger
from typing import Callable, Collection, List, Optional
from libscrc import modbus
from libscrc import modbus # type: ignore
from .constants import ACTION_READ, POSSIBLE_MARKER
from .lib.feasycom_ble import BTLEUart
from .solar_types import DATA_BATTERY_STATE, HISTORICAL_DATA, DataItem
from .util import log
from .constants import ACTION_READ, ACTION_WRITE, POSSIBLE_MARKER
from .interfaces import BaseInterface
from .solar_types import (
DATA_BATTERY_STATE,
HISTORICAL_DATA,
ChargerState,
DataItem,
HistoricalData,
HistoricalExtraInfo,
)
logger = getLogger(__name__)
def write(fh, data):
@ -20,9 +27,14 @@ def write(fh, data):
fh.write(data + bcrc)
def construct_request(address, words=1, action=ACTION_READ, marker=0xFF):
def construct_read_request(address, words=1, marker=0xFF):
assert marker in POSSIBLE_MARKER, f"marker should be one of {POSSIBLE_MARKER}"
return struct.pack("!BBHH", marker, action, address, words)
return struct.pack("!BBHH", marker, ACTION_READ, address, words)
def construct_write_request(address, marker=0xFF):
assert marker in POSSIBLE_MARKER, f"marker should be one of {POSSIBLE_MARKER}"
return struct.pack("!BBH", marker, ACTION_WRITE, address)
def parse(data: bytes, items: Collection[DataItem], offset: int = 0) -> dict:
@ -61,36 +73,33 @@ def parse_packet(data):
if crc != calculated_crc:
e = ValueError(f"CRC missmatch: expected {crc:04X}, got {calculated_crc:04X}.")
e.tag = tag
e.operation = operation
e.size = size
e.payload = payload
e.crc = crc
e.calculated_crc = calculated_crc
# e.tag = tag
# e.operation = operation
# e.size = size
# e.payload = payload
# e.crc = crc
# e.calculated_crc = calculated_crc
raise e
return payload
def discardUntil(fh: RawIOBase, byte: int, timeout=10) -> Optional[int]:
def discardUntil(fh: BaseInterface, byte: int, timeout=10) -> Optional[int]:
assert byte >= 0 and byte < 256, f"byte: Expected 8bit unsigned int, got {byte}"
def expand(b: Optional[bytes]):
if b is None:
return b
if not b:
return None
return b[0]
start = time.time()
discarded = 0
discarded: List[str] = []
read_byte = expand(fh.read(1))
while read_byte != byte:
if read_byte is not None:
if not discarded:
log("Discarding", end="")
discarded += 1
print(f" {read_byte:02X}", end="")
sys.stdout.flush()
discarded.append("Discarding")
discarded.append(f"{read_byte:02X}")
if time.time() - start > timeout:
read_byte = None
@ -99,15 +108,14 @@ def discardUntil(fh: RawIOBase, byte: int, timeout=10) -> Optional[int]:
read_byte = expand(fh.read(1))
if discarded:
print()
sys.stdout.flush()
logger.debug(" ".join(discarded))
return read_byte
def readMemory(fh: RawIOBase, address: int, words: int = 1) -> Optional[bytes]:
def readMemory(fh: BaseInterface, address: int, words: int = 1) -> Optional[bytes]:
# log(f"Reading {words} words from 0x{address:04X}")
request = construct_request(address, words=words)
request = construct_read_request(address, words=words)
# log("Request:", request)
write(fh, request)
@ -124,19 +132,81 @@ def readMemory(fh: RawIOBase, address: int, words: int = 1) -> Optional[bytes]:
try:
crc = struct.unpack_from("<H", _crc)[0]
except struct.error:
log(f"readMemory: CRC error; read {len(_crc)} bytes (2 expected)")
logger.error(
"readMemory: CRC error; read %s bytes (2 expected)", len(_crc)
)
return None
calculated_crc = modbus(bytes([tag, operation, size, *data]))
if crc == calculated_crc:
return data
else:
log(f"readMemory: CRC error; {crc:04X} != {calculated_crc:04X}")
log("data or crc is falsely", header, data, _crc)
logger.error(
f"readMemory: CRC error; {crc:04X} != {calculated_crc:04X}"
)
logger.error("data or crc is falsely %s %s %s", header, data, _crc)
return None
# 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(
dev: BTLEUart,
dev: BaseInterface,
address: int,
words: int = 1,
parser: Optional[Callable] = None,
@ -149,10 +219,156 @@ def try_read_parse(
try:
if parser:
return parser(res)
except struct.error as e:
log(e)
log("0x0100 Unpack error:", len(res), res)
log("Flushed from read buffer; ", dev.read(timeout=0.5))
except struct.error:
logger.exception("0x0100 Unpack error: %s %s", len(res), res)
_timeout = dev.timeout
dev.timeout = 0.5
logger.warning("Flushed from read buffer; %s", dev.read())
dev.timeout = _timeout
else:
log(f"No data read, expected {words*2} bytes (attempts left: {attempts})")
logger.warning(
f"No data read, expected {words*2} bytes (attempts left: {attempts})"
)
return None
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 -*-
import struct
from abc import ABC, abstractmethod
from enum import Enum, unique
from typing import Callable, Optional
from typing import Any, Callable, Dict, Optional
@unique
@ -21,7 +22,7 @@ class DataName(str, Enum):
BATTERY_VOLTAGE_MIN = "battery_voltage_min"
BATTERY_VOLTAGE_MAX = "battery_voltage_max"
CHARGE_MAX_CURRENT = "charge_max_current"
_DISCHARGE_MAX_CURRENT = "_discharge_max_current?"
DISCHARGE_MAX_CURRENT = "discharge_max_current"
CHARGE_MAX_POWER = "charge_max_power"
DISCHARGE_MAX_POWER = "discharge_max_power"
CHARGE_AMP_HOUR = "charge_amp_hour"
@ -43,6 +44,9 @@ class DataName(str, Enum):
def __repr__(self):
return repr(self.value)
def __str__(self):
return self.value
class DataItem:
name: DataName
@ -101,13 +105,14 @@ HISTORICAL_DATA = [
DataItem(DataName.BATTERY_VOLTAGE_MIN, "H", "V", lambda n: n / 10),
DataItem(DataName.BATTERY_VOLTAGE_MAX, "H", "V", lambda n: n / 10),
DataItem(DataName.CHARGE_MAX_CURRENT, "H", "A", lambda n: n / 100),
DataItem(DataName._DISCHARGE_MAX_CURRENT, "H", "A", lambda n: n / 100),
DataItem(DataName.DISCHARGE_MAX_CURRENT, "H", "A", lambda n: n / 100),
DataItem(DataName.CHARGE_MAX_POWER, "H", "W"),
DataItem(DataName.DISCHARGE_MAX_POWER, "H", "W"),
DataItem(DataName.CHARGE_AMP_HOUR, "H", "Ah"),
DataItem(DataName.DISCHARGE_AMP_HOUR, "H", "Ah"),
DataItem(DataName.PRODUCTION_ENERGY, "H", "Wh"),
DataItem(DataName.CONSUMPTION_ENERGY, "H", "Wh"),
#
DataItem(DataName.RUN_DAYS, "H"),
DataItem(DataName.DISCHARGE_COUNT, "H"),
DataItem(DataName.FULL_CHARGE_COUNT, "H"),
@ -116,3 +121,180 @@ HISTORICAL_DATA = [
DataItem(DataName.TOTAL_PRODUCTION_ENERGY, "L", "Wh"),
DataItem(DataName.TOTAL_CONSUMPTION_ENERGY, "L", "Wh"),
]
class DecodedData(ABC):
@abstractmethod
def __init__(self, data: bytes | bytearray | memoryview) -> None:
...
@abstractmethod
def as_dict(self) -> Dict[DataName, Any]:
...
class ChargerState(DecodedData):
battery_charge: int
battery_voltage: float
battery_current: float
internal_temperature: int
battery_temperature: int
load_voltage: float
load_current: float
load_power: float
panel_voltage: float
panel_current: float
panel_power: float
load_enabled: bool
def __init__(self, data: bytes | bytearray | memoryview) -> None:
(
_battery_charge,
_battery_voltage,
_battery_current,
_internal_temperature,
_battery_temperature,
_load_voltage,
_load_current,
_load_power,
_panel_voltage,
_panel_current,
_panel_power,
_load_enabled,
) = struct.unpack("!HHHBBHHHHHHx?", data)
self.battery_charge = _battery_charge
self.battery_voltage = _battery_voltage / 10
self.battery_current = _battery_current / 100
self.internal_temperature = parse_temperature(_internal_temperature)
self.battery_temperature = parse_temperature(_battery_temperature)
self.load_voltage = _load_voltage / 10
self.load_current = _load_current / 100
self.load_power = _load_power
self.panel_voltage = _panel_voltage / 10
self.panel_current = _panel_current / 100
self.panel_power = _panel_power
self.load_enabled = bool(_load_enabled)
@property
def calculated_battery_power(self) -> float:
return self.battery_voltage * self.battery_current
@property
def calculated_panel_power(self) -> float:
return self.panel_voltage * self.panel_current
@property
def calculated_load_power(self) -> float:
return self.load_voltage * self.load_current
def as_dict(self):
return {
DataName.BATTERY_CHARGE: self.battery_charge,
DataName.BATTERY_VOLTAGE: self.battery_voltage,
DataName.BATTERY_CURRENT: self.battery_current,
DataName.INTERNAL_TEMPERATURE: self.internal_temperature,
DataName.BATTERY_TEMPERATURE: self.battery_temperature,
DataName.LOAD_VOLTAGE: self.load_voltage,
DataName.LOAD_CURRENT: self.load_current,
DataName.LOAD_POWER: self.load_power,
DataName.PANEL_VOLTAGE: self.panel_voltage,
DataName.PANEL_CURRENT: self.panel_current,
DataName.PANEL_POWER: self.panel_power,
DataName.LOAD_ENABLED: self.load_enabled,
DataName.CALCULATED_BATTERY_POWER: self.calculated_battery_power,
DataName.CALCULATED_PANEL_POWER: self.calculated_panel_power,
DataName.CALCULATED_LOAD_POWER: self.calculated_load_power,
}
class HistoricalData(DecodedData):
battery_voltage_min: float
battery_voltage_max: float
charge_max_current: float
discharge_max_current: float
charge_max_power: int
discharge_max_power: int
charge_amp_hour: int
discharge_amp_hour: int
production_energy: int
consumption_energy: int
def __init__(self, data: bytes | bytearray | memoryview) -> None:
(
_battery_voltage_min,
_battery_voltage_max,
_charge_max_current,
_discharge_max_current,
_charge_max_power,
_discharge_max_power,
_charge_amp_hour,
_discharge_amp_hour,
_production_energy,
_consumption_energy,
) = struct.unpack("!HHHHHHHHHH", data)
self.battery_voltage_min = _battery_voltage_min / 10
self.battery_voltage_max = _battery_voltage_max / 10
self.charge_max_current = _charge_max_current / 100
self.discharge_max_current = _discharge_max_current / 100
self.charge_max_power = _charge_max_power
self.discharge_max_power = _discharge_max_power
self.charge_amp_hour = _charge_amp_hour
self.discharge_amp_hour = _discharge_amp_hour
self.production_energy = _production_energy
self.consumption_energy = _consumption_energy
def as_dict(self):
return {
DataName.BATTERY_VOLTAGE_MIN: self.battery_voltage_min,
DataName.BATTERY_VOLTAGE_MAX: self.battery_voltage_max,
DataName.CHARGE_MAX_CURRENT: self.charge_max_current,
DataName.DISCHARGE_MAX_CURRENT: self.discharge_max_current,
DataName.CHARGE_MAX_POWER: self.charge_max_power,
DataName.DISCHARGE_MAX_POWER: self.discharge_max_power,
DataName.CHARGE_AMP_HOUR: self.charge_amp_hour,
DataName.DISCHARGE_AMP_HOUR: self.discharge_amp_hour,
DataName.PRODUCTION_ENERGY: self.production_energy,
DataName.CONSUMPTION_ENERGY: self.consumption_energy,
}
class HistoricalExtraInfo(DecodedData):
run_days: int
discharge_count: int
full_charge_count: int
total_charge_amp_hours: int
total_discharge_amp_hours: int
total_production_energy: int
total_consumption_energy: int
def __init__(self, data: bytes | bytearray | memoryview) -> None:
(
_run_days,
_discharge_count,
_full_charge_count,
_total_charge_amp_hours,
_total_discharge_amp_hours,
_total_production_energy,
_total_consumption_energy,
) = struct.unpack("!HHHLLLL", data)
self.run_days = _run_days
self.discharge_count = _discharge_count
self.full_charge_count = _full_charge_count
self.total_charge_amp_hours = _total_charge_amp_hours
self.total_discharge_amp_hours = _total_discharge_amp_hours
self.total_production_energy = _total_production_energy
self.total_consumption_energy = _total_consumption_energy
def as_dict(self):
return {
DataName.RUN_DAYS: self.run_days,
DataName.DISCHARGE_COUNT: self.discharge_count,
DataName.FULL_CHARGE_COUNT: self.full_charge_count,
DataName.TOTAL_CHARGE_AMP_HOURS: self.total_charge_amp_hours,
DataName.TOTAL_DISCHARGE_AMP_HOURS: self.total_discharge_amp_hours,
DataName.TOTAL_PRODUCTION_ENERGY: self.total_production_energy,
DataName.TOTAL_CONSUMPTION_ENERGY: self.total_consumption_energy,
}

View file

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

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]
max-line-length = 88
max-line-length = 120
extend-ignore = E203, I201, I101
[pytest]
pythonpath = .
testpaths = tests