Skip to content

Commit 82a1421

Browse files
committed
poller: home assistant pushes
1 parent e0981dd commit 82a1421

File tree

4 files changed

+198
-27
lines changed

4 files changed

+198
-27
lines changed

pyproject.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,14 @@ classifiers = [
2727
dependencies = [
2828
"pyserial",
2929
"construct",
30+
3031
"standard-telnetlib",
3132
"Exscript",
33+
3234
"rich",
3335
"pymongo",
36+
"requests",
37+
3438
]
3539
url = "http://github.com/Frankkkkk/python-pylontech"
3640

src/pylontechpoller/poller.py

Lines changed: 57 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,37 @@
11
import argparse
2-
import datetime
32
import json
4-
import time
5-
import sys
63
import logging
7-
import itertools
4+
import sys
5+
import time
86

9-
from rich import print_json
107
from pylontech import *
11-
from pymongo import MongoClient
8+
from pylontechpoller.reporter import MongoReporter, HassReporter
129

1310
logger = logging.getLogger(__name__)
1411

1512

1613
def find_min_max_modules(modules):
1714
all_voltages = []
15+
all_disbalances = []
16+
1817
for module in modules:
19-
for voltage in module["CellVoltages"]:
20-
all_voltages.append((module["NumberOfModule"], voltage))
18+
mid = module["NumberOfModule"]
19+
cvs = module["CellVoltages"]
20+
for voltage in cvs:
21+
all_voltages.append((mid, voltage))
22+
vmax = max(cvs)
23+
vmin = min(cvs)
24+
d = vmax - vmin
25+
all_disbalances.append((mid, d))
2126

2227
if not all_voltages:
2328
return None, None
2429

2530
min_pair = min(all_voltages, key=lambda x: x[1])
2631
max_pair = max(all_voltages, key=lambda x: x[1])
32+
max_disbalance = max(all_disbalances, key=lambda x: abs(x[1]))
2733

28-
return min_pair, max_pair
34+
return min_pair, max_pair, max_disbalance
2935

3036

3137

@@ -48,22 +54,18 @@ def minimize_module(m: json) -> json:
4854
modules = b["modules"]
4955
find_min_max_modules(modules)
5056

51-
(min_pair, max_pair) = find_min_max_modules(modules)
52-
# allcv = list(itertools.chain.from_iterable(map(lambda m: m["CellVoltages"], modules)))
53-
# vmin = min(allcv)
54-
# vmax = max(allcv)
57+
(min_pair, max_pair, max_disbalance) = find_min_max_modules(modules)
5558

5659
return {
5760
"ts": b["timestamp"],
5861
"cvmin": min_pair,
5962
"cvmax": max_pair,
6063
"stack_disbalance": min_pair[1] - max_pair[1],
64+
"max_module_disbalance": max_disbalance,
6165
"modules": list(map(minimize_module, modules)),
6266
}
6367

64-
def mongo_cleanup(collection, retention_days):
65-
threshold = datetime.datetime.now() - datetime.timedelta(days=retention_days)
66-
collection.delete_many({"ts": {"$lt": threshold}})
68+
6769

6870
def run(argv: list[str]):
6971
parser = argparse.ArgumentParser(description="Pylontech RS485 poller")
@@ -75,11 +77,19 @@ def run(argv: list[str]):
7577
parser.add_argument("--interval", type=int, help="polling interval in msec", default=1000)
7678
parser.add_argument("--retention-days", type=int, help="how long to retain history data", default=90)
7779
parser.add_argument("--debug", type=bool, help="verbose output", default=False)
78-
parser.add_argument("--mongo-url", type=str, help="mongodb url", default=False)
80+
81+
parser.add_argument("--mongo-url", type=str, help="mongodb url", default=None)
7982
parser.add_argument("--mongo-db", type=str, help="target mongo database", default="pylontech")
8083
parser.add_argument("--mongo-collection-history", type=str, help="target mongo collection_hist for stack history", default="history")
8184
parser.add_argument("--mongo-collection-meta", type=str, help="target mongo collection_hist for stack data", default="meta")
8285

86+
parser.add_argument("--hass-url", type=str, help="hass url", default=None)
87+
parser.add_argument("--hass-stack-disbalance", type=str, help="state id", default="input_number.stack_disbalance")
88+
parser.add_argument("--hass-max-battery-disbalance", type=str, help="state id", default="input_number.max_bat_disbalance")
89+
parser.add_argument("--hass-max-battery-disbalance-id", type=str, help="state id", default="input_text.max_disbalance_id")
90+
parser.add_argument("--hass-token-file", type=str, help="hass token file", default="/var/run/agenix/hass-token")
91+
92+
8393
args = parser.parse_args(argv[1:])
8494

8595
level = logging.DEBUG if args.debug else logging.INFO
@@ -88,25 +98,42 @@ def run(argv: list[str]):
8898
cc = 0
8999
spinner = ['|', '/', '-', '\\']
90100

101+
reporters = []
102+
91103
while True:
92104
try:
93105
logging.debug("Preparing client...")
94106
p = Pylontech(ExscriptTelnetTransport(host=args.source_host, port=args.source_port, timeout=args.timeout))
95-
96-
mongo = MongoClient(args.mongo_url)
97-
db = mongo[args.mongo_db]
98-
99-
collection_meta = db[args.mongo_collection_meta]
100107

101-
collection_hist = db[args.mongo_collection_history]
102-
collection_hist.create_index("ts", expireAfterSeconds=3600*24*90)
108+
mongo_url = args.mongo_url
109+
110+
if mongo_url:
111+
reporters.append(MongoReporter(
112+
mongo_url,
113+
args.mongo_db,
114+
args.mongo_collection_meta,
115+
args.mongo_collection_history,
116+
args.retention_days
117+
))
118+
119+
hass_url = args.hass_url
120+
print(hass_url)
121+
if hass_url:
122+
reporters.append(HassReporter(
123+
hass_url,
124+
args.hass_stack_disbalance,
125+
args.hass_max_battery_disbalance,
126+
args.hass_max_battery_disbalance_id,
127+
args.hass_token_file
128+
))
103129

104130
logging.info("About to start polling...")
105131
bats = p.scan_for_batteries(2, 10)
106132

107133
logging.info("Have battery stack data")
108-
collection_meta.insert_one({'ts': datetime.datetime.now().isoformat(), "stack": to_json_serializable(bats)})
109134

135+
for reporter in reporters:
136+
reporter.report_meta(bats)
110137

111138
for b in p.poll_parameters(bats.range()):
112139
cc += 1
@@ -115,11 +142,14 @@ def run(argv: list[str]):
115142
sys.stdout.write('\r' + spinner[cc % len(spinner)])
116143
sys.stdout.flush()
117144

145+
mb = minimize(b)
118146
# print(print_json(json.dumps(minimize(b))))
119-
collection_hist.insert_one(minimize(b))
147+
for reporter in reporters:
148+
reporter.report_state(mb)
120149

121150
if cc % 86400 == 0:
122-
mongo_cleanup(collection_hist, args.retention_days)
151+
for reporter in reporters:
152+
reporter.cleanup()
123153

124154
time.sleep(args.interval / 1000.0)
125155
except (KeyboardInterrupt, SystemExit):

src/pylontechpoller/reporter.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import datetime
2+
import json
3+
import logging
4+
5+
import requests
6+
from pymongo import MongoClient
7+
8+
from pylontech import to_json_serializable
9+
10+
logger = logging.getLogger(__name__)
11+
12+
class Reporter:
13+
def report_meta(self, meta):
14+
pass
15+
16+
def report_state(self, state):
17+
pass
18+
19+
def cleanup(self):
20+
pass
21+
22+
class MongoReporter(Reporter):
23+
def __init__(self, mongo_url, mongo_db, mongo_collection_meta, mongo_collection_history, retention_days):
24+
mongo = MongoClient(mongo_url)
25+
db = mongo[mongo_db]
26+
self.retention_days = retention_days
27+
self.collection_meta = db[mongo_collection_meta]
28+
self.collection_hist = db[mongo_collection_history]
29+
self.collection_hist.create_index("ts", expireAfterSeconds=3600 * 24 * 90)
30+
31+
32+
33+
def report_meta(self, meta):
34+
self.collection_meta.insert_one({'ts': datetime.datetime.now().isoformat(), "stack": to_json_serializable(meta)})
35+
36+
def report_state(self, state):
37+
self.collection_hist.insert_one(state)
38+
39+
def cleanup(self):
40+
threshold = datetime.datetime.now() - datetime.timedelta(days= self.retention_days)
41+
self.collection_hist.delete_many({"ts": {"$lt": threshold}})
42+
43+
class HassReporter(Reporter):
44+
def __init__(self, hass_url, hass_stack_disbalance, hass_max_battery_disbalance, hass_max_battery_disbalance_id, hass_token_file):
45+
self.hass_url = hass_url
46+
self.hass_stack_disbalance = hass_stack_disbalance
47+
self.hass_max_battery_disbalance = hass_max_battery_disbalance
48+
self.hass_max_battery_disbalance_id = hass_max_battery_disbalance_id
49+
with open(hass_token_file, 'r') as file:
50+
self.hass_token = file.read().strip()
51+
52+
53+
def report_state(self, state):
54+
md = state["max_module_disbalance"]
55+
self.update_hass_state(self.hass_stack_disbalance, state["stack_disbalance"])
56+
self.update_hass_state(self.hass_max_battery_disbalance, md[1])
57+
self.update_hass_state(self.hass_max_battery_disbalance_id, md[0])
58+
59+
def update_hass_state(self, id, value):
60+
tpe = id.split('.')[0]
61+
update = {
62+
"entity_id": id,
63+
"value": value
64+
}
65+
66+
url = f'{self.hass_url}/api/services/{tpe}/set_value'
67+
68+
response = requests.post(url, data=json.dumps(update), headers={"Authorization": f"Bearer {self.hass_token}"})
69+
70+
if response.status_code != 200:
71+
logger.error(f"hass state update failed for {id}: {response.status_code} {response.text}")

uv.lock

Lines changed: 66 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)