Skip to content

Commit 7b147d1

Browse files
committed
mqtt reporter
1 parent be341df commit 7b147d1

8 files changed

Lines changed: 459 additions & 116 deletions

File tree

pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,9 @@ dependencies = [
3535
"pymongo",
3636
"requests",
3737

38+
"paho-mqtt",
39+
"ha-mqtt-discoverable",
40+
3841
]
3942
url = "http://github.com/Frankkkkk/python-pylontech"
4043

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import json
2+
import os
3+
4+
import requests
5+
6+
from pylontechpoller.reporter import Reporter, logger
7+
8+
9+
class HassReporter(Reporter):
10+
def __init__(self, hass_url, hass_stack_disbalance, hass_max_battery_disbalance, hass_max_battery_disbalance_id, hass_token):
11+
self.hass_url = hass_url
12+
self.hass_stack_disbalance = hass_stack_disbalance
13+
self.hass_max_battery_disbalance = hass_max_battery_disbalance
14+
self.hass_max_battery_disbalance_id = hass_max_battery_disbalance_id
15+
if os.path.exists(hass_token):
16+
with open(hass_token, 'r') as file:
17+
hass_token = file.read().strip()
18+
self.hass_token = hass_token
19+
20+
21+
def report_state(self, state):
22+
md = state["max_module_disbalance"]
23+
self.update_hass_state(self.hass_stack_disbalance, int(state["stack_disbalance"] * 10000) / 10000.0)
24+
self.update_hass_state(self.hass_max_battery_disbalance, int(md[1] * 10000) / 10000.0)
25+
self.update_hass_state(self.hass_max_battery_disbalance_id, md[0])
26+
27+
def update_hass_state(self, id, value):
28+
tpe = id.split('.')[0]
29+
update = {
30+
"entity_id": id,
31+
"value": value
32+
}
33+
34+
url = f'{self.hass_url}/api/services/{tpe}/set_value'
35+
36+
response = requests.post(url, data=json.dumps(update), headers={"Authorization": f"Bearer {self.hass_token}"})
37+
38+
if response.status_code != 200:
39+
logger.error(f"hass state update failed for {id}: {response.status_code} {response.text}")
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import datetime
2+
3+
from pymongo import MongoClient
4+
5+
from pylontech import to_json_serializable, Pylontech
6+
from pylontech.pylontech import PylontechStackData
7+
from pylontechpoller.reporter import Reporter
8+
9+
10+
class MongoReporter(Reporter):
11+
def __init__(self, mongo_url, mongo_db, mongo_collection_meta, mongo_collection_history, retention_days):
12+
mongo = MongoClient(mongo_url)
13+
db = mongo[mongo_db]
14+
self.retention_days = retention_days
15+
self.collection_meta = db[mongo_collection_meta]
16+
self.collection_hist = db[mongo_collection_history]
17+
self.collection_hist.create_index("ts", expireAfterSeconds=3600 * 24 * 90)
18+
19+
def report_meta(self, meta: PylontechStackData, p: Pylontech):
20+
self.collection_meta.insert_one({'ts': datetime.datetime.now().isoformat(), "stack": to_json_serializable(meta)})
21+
22+
def report_state(self, state):
23+
self.collection_hist.insert_one(state)
24+
25+
def cleanup(self):
26+
threshold = datetime.datetime.now() - datetime.timedelta(days= self.retention_days)
27+
self.collection_hist.delete_many({"ts": {"$lt": threshold}})
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
import os.path
2+
3+
from ha_mqtt_discoverable import Settings, DeviceInfo
4+
from ha_mqtt_discoverable.sensors import SensorInfo, Sensor
5+
6+
from pylontech.pylontech import PylontechModule, Pylontech, PylontechStackData
7+
from pylontechpoller.tools import minimize
8+
from pylontechpoller.reporter import Reporter
9+
10+
import paho.mqtt.client as mqtt
11+
12+
13+
class MqttReporter(Reporter):
14+
def __init__(self, mqtt_host, mqtt_port, mqtt_login, mqtt_password):
15+
if os.path.exists(mqtt_password):
16+
with open(mqtt_password, 'r') as file:
17+
mqtt_password = file.read().strip()
18+
19+
client = mqtt.Client(client_id="pylontech-poller")
20+
client.username_pw_set(mqtt_login, mqtt_password)
21+
client.connect(mqtt_host, mqtt_port)
22+
client.loop_start()
23+
self.mqtt_settings = Settings.MQTT(client=client)
24+
# client.enable_logger(logger)
25+
26+
# self.mqtt_settings = Settings.MQTT(host=mqtt_host, port=mqtt_port, username=mqtt_login, password=mqtt_password,
27+
# client_name="pylontech-poller")
28+
29+
self.device_info = DeviceInfo(name="Pylontech Battery Stack", identifiers="pylontech_battery_stack")
30+
31+
self.hass_stack_disbalance_info = SensorInfo(
32+
name="Stack Disbalance",
33+
device_class="voltage",
34+
unique_id="stack_disbalance",
35+
unit_of_measurement="V",
36+
suggested_display_precision=3,
37+
device=self.device_info,
38+
icon="mdi:scale-unbalanced",
39+
40+
)
41+
self.hass_stack_disbalance_settings = Settings(mqtt=self.mqtt_settings, entity=self.hass_stack_disbalance_info)
42+
self.hass_stack_disbalance = Sensor(self.hass_stack_disbalance_settings)
43+
44+
self.hass_max_battery_disbalance_info = SensorInfo(
45+
name="Max Battery Disbalance",
46+
device_class="voltage",
47+
unique_id="max_battery_disbalance",
48+
unit_of_measurement="V",
49+
suggested_display_precision=3,
50+
device=self.device_info,
51+
icon="mdi:scale-unbalanced",
52+
)
53+
self.hass_max_battery_disbalance_settings = Settings(mqtt=self.mqtt_settings,
54+
entity=self.hass_max_battery_disbalance_info)
55+
self.hass_max_battery_disbalance = Sensor(self.hass_max_battery_disbalance_settings)
56+
57+
self.hass_max_disbalance_id = Sensor(Settings(mqtt=self.mqtt_settings, entity=SensorInfo(
58+
name="Max disbalance ID",
59+
unique_id=f"max_battery_disbalance_id",
60+
device=self.device_info,
61+
icon="mdi:battery-alert",
62+
63+
)))
64+
self.bats = {}
65+
66+
def report_meta(self, meta: PylontechStackData, p: Pylontech):
67+
moduledata = { m["n"] : m for m in minimize( next(p.poll_parameters(meta.range())) )["modules"]}
68+
cells = {}
69+
70+
for id in meta.ids:
71+
m = meta.modules[id]
72+
device_info = DeviceInfo(
73+
name=f"Pylontech Battery {id}",
74+
identifiers=[f"pylontech_battery_{m.serial}", f"pylontech_battery_{id}", ],
75+
manufacturer=m.manufacturer_info,
76+
sw_version=".".join([str(x) for x in m.fw_version]),
77+
model=m.device_name
78+
)
79+
mdata = moduledata[id]
80+
for cn, c in enumerate(mdata["cv"]):
81+
cells[f"cell_{cn}_voltage"] = Sensor(Settings(mqtt=self.mqtt_settings, entity=SensorInfo(
82+
name=f"Cell {cn} Voltage",
83+
device_class="voltage",
84+
unique_id=f"cell_voltage_{id}_{cn}",
85+
unit_of_measurement="V",
86+
suggested_display_precision=3,
87+
device=device_info,
88+
entity_category="diagnostic",
89+
icon="mdi:gauge",
90+
)))
91+
92+
self.bats[id] = {
93+
"bat_soc": Sensor(Settings(mqtt=self.mqtt_settings, entity=SensorInfo(
94+
name="SoC",
95+
device_class="battery",
96+
unique_id=f"battery_soc_{id}",
97+
unit_of_measurement="%",
98+
suggested_display_precision=1,
99+
device=device_info
100+
))),
101+
"bat_disbalance": Sensor(Settings(mqtt=self.mqtt_settings, entity=SensorInfo(
102+
name="Cell Disbalance",
103+
device_class="voltage",
104+
unique_id=f"battery_disbalance_{id}",
105+
unit_of_measurement="V",
106+
suggested_display_precision=3,
107+
device=device_info,
108+
icon="mdi:scale-unbalanced",
109+
))),
110+
"bat_voltage": Sensor(Settings(mqtt=self.mqtt_settings, entity=SensorInfo(
111+
name="Voltage",
112+
device_class="voltage",
113+
unique_id=f"battery_voltage_{id}",
114+
unit_of_measurement="V",
115+
suggested_display_precision=3,
116+
device=device_info,
117+
icon="mdi:gauge",
118+
))),
119+
"bat_current": Sensor(Settings(mqtt=self.mqtt_settings, entity=SensorInfo(
120+
name="Current",
121+
device_class="current",
122+
unique_id=f"battery_current_{id}",
123+
unit_of_measurement="A",
124+
suggested_display_precision=3,
125+
device=device_info,
126+
icon="mdi:current-dc",
127+
))),
128+
"bat_power": Sensor(Settings(mqtt=self.mqtt_settings, entity=SensorInfo(
129+
name="Power",
130+
device_class="power",
131+
unique_id=f"battery_power_{id}",
132+
unit_of_measurement="W",
133+
suggested_display_precision=2,
134+
device=device_info,
135+
icon="mdi:battery-charging",
136+
))),
137+
"bat_cycle": Sensor(Settings(mqtt=self.mqtt_settings, entity=SensorInfo(
138+
name="Cycle",
139+
unique_id=f"battery_cycle_{id}",
140+
device=device_info,
141+
icon="mdi:battery-sync",
142+
))),
143+
"bat_temp": Sensor(Settings(mqtt=self.mqtt_settings, entity=SensorInfo(
144+
name="Temperature",
145+
device_class="temperature",
146+
unique_id=f"battery_temperature_{id}",
147+
unit_of_measurement="C",
148+
suggested_display_precision=1,
149+
device=device_info,
150+
))),
151+
} | cells
152+
153+
def report_state(self, state):
154+
md = state["max_module_disbalance"]
155+
self.hass_stack_disbalance.set_state(state["stack_disbalance"])
156+
self.hass_max_battery_disbalance.set_state(md[1])
157+
self.hass_max_disbalance_id.set_state(md[0])
158+
159+
for b in state["modules"]:
160+
s = self.bats[b["n"]]
161+
s["bat_disbalance"].set_state(b["disbalance"])
162+
s["bat_voltage"].set_state(b["v"])
163+
s["bat_current"].set_state(b["current"])
164+
s["bat_soc"].set_state(int(b["soc"] * 1000) / 10.0)
165+
s["bat_power"].set_state(b["pw"])
166+
s["bat_cycle"].set_state(b["cycle"])
167+
s["bat_temp"].set_state(b["tempavg"])
168+
for cn, c in enumerate(b["cv"]):
169+
s[f"cell_{cn}_voltage"].set_state(c)

src/pylontechpoller/poller.py

Lines changed: 23 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -1,70 +1,17 @@
11
import argparse
2-
import json
32
import logging
43
import sys
54
import time
65

76
from pylontech import *
8-
from pylontechpoller.reporter import MongoReporter, HassReporter
7+
from pylontechpoller.mqtt_reporter import MqttReporter
8+
from pylontechpoller.hass_basic_reporter import HassReporter
9+
from pylontechpoller.mongo_reporter import MongoReporter
10+
from pylontechpoller.tools import minimize
911

1012
logger = logging.getLogger(__name__)
1113

1214

13-
def find_min_max_modules(modules):
14-
all_voltages = []
15-
all_disbalances = []
16-
17-
for module in modules:
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))
26-
27-
if not all_voltages:
28-
return None, None
29-
30-
min_pair = min(all_voltages, key=lambda x: x[1])
31-
max_pair = max(all_voltages, key=lambda x: x[1])
32-
max_disbalance = max(all_disbalances, key=lambda x: abs(x[1]))
33-
34-
return min_pair, max_pair, max_disbalance
35-
36-
37-
38-
def minimize(b: json) -> json:
39-
def minimize_module(m: json) -> json:
40-
return {
41-
"n": m["NumberOfModule"],
42-
"v": m["Voltage"],
43-
"cv": m["CellVoltages"],
44-
"current": m["Current"],
45-
"pw": m["Power"],
46-
"cycle": m["CycleNumber"],
47-
"soc": m["StateOfCharge"],
48-
"tempavg": m["AverageBMSTemperature"],
49-
"temps": m["GroupedCellsTemperatures"],
50-
"remaining": m["RemainingCapacity"],
51-
"disbalance": max(m["CellVoltages"]) - min(m["CellVoltages"])
52-
}
53-
54-
modules = b["modules"]
55-
find_min_max_modules(modules)
56-
57-
(min_pair, max_pair, max_disbalance) = find_min_max_modules(modules)
58-
59-
return {
60-
"ts": b["timestamp"],
61-
"cvmin": min_pair,
62-
"cvmax": max_pair,
63-
"stack_disbalance": max_pair[1] - min_pair[1],
64-
"max_module_disbalance": max_disbalance,
65-
"modules": list(map(minimize_module, modules)),
66-
}
67-
6815

6916

7017
def run(argv: list[str]):
@@ -87,7 +34,14 @@ def run(argv: list[str]):
8734
parser.add_argument("--hass-stack-disbalance", type=str, help="state id", default="input_number.stack_disbalance")
8835
parser.add_argument("--hass-max-battery-disbalance", type=str, help="state id", default="input_number.max_bat_disbalance")
8936
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")
37+
parser.add_argument("--hass-token", type=str, help="hass token or token file", default="/var/run/agenix/hass-token")
38+
39+
40+
parser.add_argument("--mqtt-host", type=str, help="mqtt host", default=None)
41+
parser.add_argument("--mqtt-port", type=int, help="mqtt url", default=1883)
42+
parser.add_argument("--mqtt-user", type=str, help="mqtt login", default="mqtt")
43+
parser.add_argument("--mqtt-password", type=str, help="mqtt password or password file", default="/var/run/agenix/mqtt-user")
44+
9145

9246

9347
args = parser.parse_args(argv[1:])
@@ -128,13 +82,23 @@ def run(argv: list[str]):
12882
args.hass_token_file
12983
))
13084

85+
mqtt_host = args.mqtt_host
86+
87+
if mqtt_host:
88+
reporters.append(MqttReporter(
89+
mqtt_host,
90+
args.mqtt_port,
91+
args.mqtt_user,
92+
args.mqtt_password,
93+
))
94+
13195
logging.info("About to start polling...")
13296
bats = p.scan_for_batteries(2, 10)
13397

13498
logging.info("Have battery stack data")
13599

136100
for reporter in reporters:
137-
reporter.report_meta(bats)
101+
reporter.report_meta(bats, p)
138102

139103
for b in p.poll_parameters(bats.range()):
140104
cc += 1

0 commit comments

Comments
 (0)