Skip to content

Commit 8d444ba

Browse files
authored
Implement Powermeter TQ EM (#150)
Fixes #133
1 parent 153cb19 commit 8d444ba

7 files changed

Lines changed: 210 additions & 0 deletions

File tree

README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -381,6 +381,15 @@ PASSWORD = pass (Optional)
381381
HEADERS = Authorization: Bearer token
382382
```
383383

384+
### TQ Energy Manager
385+
386+
```ini
387+
[TQ_EM]
388+
IP = 192.168.1.100
389+
#PASSWORD = pass
390+
#TIMEOUT = 5.0 (Optional)
391+
```
392+
384393
### Modbus
385394

386395
```ini

config.ini.example

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,11 @@ THROTTLE_INTERVAL = 0
134134
#USERNAME = user
135135
#PASSWORD = pass
136136
#HEADERS = Authorization: Bearer token
137+
138+
#[TQ_EM]
139+
#IP = 192.168.1.100
140+
#PASSWORD = secret (Optional)
141+
#TIMEOUT = 5.0 (Optional)
137142
#THROTTLE_INTERVAL = 1
138143

139144
#[SCRIPT]

config/config_loader.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
Script,
2323
ESPHome,
2424
JsonHttpPowermeter,
25+
TQEnergyManager,
2526
ThrottledPowermeter,
2627
)
2728

@@ -37,6 +38,7 @@
3738
AMIS_READER_SECTION = "AMIS_READER"
3839
MODBUS_SECTION = "MODBUS"
3940
JSON_HTTP_SECTION = "JSON_HTTP"
41+
TQ_EM_SECTION = "TQ_EM"
4042

4143

4244
class ClientFilter:
@@ -119,6 +121,8 @@ def create_powermeter(
119121
return create_amisreader_powermeter(section, config)
120122
elif section.startswith(MODBUS_SECTION):
121123
return create_modbus_powermeter(section, config)
124+
elif section.startswith(TQ_EM_SECTION):
125+
return create_tq_em_powermeter(section, config)
122126
elif section.startswith(JSON_HTTP_SECTION):
123127
return create_json_http_powermeter(section, config)
124128
elif section.startswith("MQTT"):
@@ -319,3 +323,13 @@ def create_tasmota_powermeter(
319323
config.get(section, "JSON_POWER_OUTPUT_MQTT_LABEL", fallback=""),
320324
config.getboolean(section, "JSON_POWER_CALCULATE", fallback=False),
321325
)
326+
327+
328+
def create_tq_em_powermeter(
329+
section: str, config: configparser.ConfigParser
330+
) -> Powermeter:
331+
return TQEnergyManager(
332+
config.get(section, "IP", fallback=""),
333+
config.get(section, "PASSWORD", fallback=""),
334+
timeout=config.getfloat(section, "TIMEOUT", fallback=5.0),
335+
)

config/config_loader_test.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
create_modbus_powermeter,
2020
create_mqtt_powermeter,
2121
create_json_http_powermeter,
22+
create_tq_em_powermeter,
2223
)
2324
import unittest
2425
from unittest.mock import patch, Mock
@@ -232,6 +233,18 @@ def test_create_json_http_powermeter():
232233
raise
233234

234235

236+
def test_create_tq_em_powermeter():
237+
"""Test TQ Energy Manager powermeter creation."""
238+
config = configparser.ConfigParser()
239+
config["TQ_EM"] = {"IP": "127.0.0.1"}
240+
241+
try:
242+
create_tq_em_powermeter("TQ_EM", config)
243+
except Exception as e:
244+
if "Connection" not in str(e):
245+
raise
246+
247+
235248
def test_create_powermeter():
236249
"""Test the main create_powermeter function."""
237250
config = configparser.ConfigParser()
@@ -250,6 +263,7 @@ def test_create_powermeter():
250263
config["MODBUS_TEST"] = {"HOST": "127.0.0.1"}
251264
config["MQTT_TEST"] = {"BROKER": "127.0.0.1"}
252265
config["JSON_HTTP_TEST"] = {"URL": "http://localhost", "JSON_PATHS": "$.power"}
266+
config["TQ_EM_TEST"] = {"IP": "127.0.0.1"}
253267
config["UNKNOWN_TEST"] = {"SOME_KEY": "some_value"}
254268

255269
# Test each powermeter type

powermeter/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,4 @@
1313
from .json_http import JsonHttpPowermeter
1414
from .script import Script
1515
from .throttling import ThrottledPowermeter
16+
from .tq_em import TQEnergyManager

powermeter/tq_em.py

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
from typing import List
2+
import time
3+
import requests
4+
5+
from .base import Powermeter
6+
7+
8+
class TQEnergyManager(Powermeter):
9+
"""Powermeter using the TQ Energy Manager JSON API."""
10+
11+
# OBIS codes
12+
_TOTAL_KEY = "1-0:1.4.0*255" # Σ active power
13+
_PHASE_KEYS = (
14+
"1-0:21.4.0*255", # L1
15+
"1-0:41.4.0*255", # L2
16+
"1-0:61.4.0*255", # L3
17+
)
18+
19+
_MAX_IDLE = 60 * 30 # 30 min
20+
21+
def __init__(self, host: str, password: str = "", *, timeout: float = 5.0) -> None:
22+
self._host, self._pw, self._timeout = host.rstrip("/"), password, timeout
23+
self._sess = requests.Session()
24+
self._serial: str | None = None
25+
self._last_use = 0.0
26+
27+
# ------------------------------------------------------------------ #
28+
# PUBLIC #
29+
# ------------------------------------------------------------------ #
30+
def get_powermeter_watts(self) -> List[float]:
31+
self._ensure_session()
32+
33+
try:
34+
data = self._read_live_json()
35+
except _SessionExpired:
36+
self._login()
37+
data = self._read_live_json()
38+
39+
if all(k in data for k in self._PHASE_KEYS):
40+
return [float(data[k]) for k in self._PHASE_KEYS]
41+
if self._TOTAL_KEY in data:
42+
return [float(data[self._TOTAL_KEY])]
43+
44+
raise RuntimeError("Required OBIS values missing in payload")
45+
46+
# ------------------------------------------------------------------ #
47+
# INTERNALS #
48+
# ------------------------------------------------------------------ #
49+
def _ensure_session(self) -> None:
50+
now = time.time()
51+
if self._serial is None or (now - self._last_use) > self._MAX_IDLE:
52+
self._login()
53+
self._last_use = now
54+
55+
def _login(self) -> None:
56+
"""Authenticate lazily with the device."""
57+
r1 = self._sess.get(f"http://{self._host}/start.php", timeout=self._timeout)
58+
r1.raise_for_status()
59+
j1 = r1.json()
60+
61+
self._serial = j1.get("serial") or j1.get("ieq_serial")
62+
if not self._serial:
63+
raise RuntimeError("Serial number missing in /start.php response")
64+
65+
if j1.get("authentication") is True:
66+
return
67+
68+
payload = {"login": self._serial, "save_login": 1}
69+
if self._pw:
70+
payload["password"] = self._pw
71+
72+
r2 = self._sess.post(
73+
f"http://{self._host}/start.php", data=payload, timeout=self._timeout
74+
)
75+
r2.raise_for_status()
76+
if r2.json().get("authentication") is not True:
77+
raise RuntimeError("Authentication failed")
78+
79+
def _read_live_json(self) -> dict:
80+
r = self._sess.get(
81+
f"http://{self._host}/mum-webservice/data.php", timeout=self._timeout
82+
)
83+
if r.status_code in (401, 403):
84+
raise _SessionExpired
85+
86+
r.raise_for_status()
87+
data = r.json()
88+
if data.get("status", 0) >= 900:
89+
raise _SessionExpired
90+
return data
91+
92+
93+
class _SessionExpired(RuntimeError):
94+
"""Internal marker – triggers transparent re-login."""
95+
96+
pass

powermeter/tq_em_test.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import unittest
2+
from unittest.mock import patch, MagicMock
3+
4+
from powermeter.tq_em import TQEnergyManager
5+
6+
7+
class TestTQEnergyManager(unittest.TestCase):
8+
@patch("requests.Session.post")
9+
@patch("requests.Session.get")
10+
def test_three_phase(self, mock_get, mock_post):
11+
# login GET
12+
mock_get.side_effect = [
13+
MagicMock(
14+
status_code=200, json=lambda: {"serial": "123", "authentication": False}
15+
),
16+
MagicMock(
17+
status_code=200,
18+
json=lambda: {
19+
"1-0:21.4.0*255": 1,
20+
"1-0:41.4.0*255": 2,
21+
"1-0:61.4.0*255": 3,
22+
},
23+
),
24+
]
25+
mock_post.return_value = MagicMock(
26+
status_code=200, json=lambda: {"authentication": True}
27+
)
28+
29+
meter = TQEnergyManager("192.168.0.10")
30+
self.assertEqual(meter.get_powermeter_watts(), [1.0, 2.0, 3.0])
31+
32+
@patch("requests.Session.post")
33+
@patch("requests.Session.get")
34+
def test_total_only(self, mock_get, mock_post):
35+
mock_get.side_effect = [
36+
MagicMock(
37+
status_code=200, json=lambda: {"serial": "321", "authentication": False}
38+
),
39+
MagicMock(status_code=200, json=lambda: {"1-0:1.4.0*255": 9}),
40+
]
41+
mock_post.return_value = MagicMock(
42+
status_code=200, json=lambda: {"authentication": True}
43+
)
44+
45+
meter = TQEnergyManager("192.168.0.12")
46+
self.assertEqual(meter.get_powermeter_watts(), [9.0])
47+
48+
@patch("requests.Session.post")
49+
@patch("requests.Session.get")
50+
def test_relogin_on_expired_session(self, mock_get, mock_post):
51+
mock_get.side_effect = [
52+
MagicMock(
53+
status_code=200, json=lambda: {"serial": "123", "authentication": False}
54+
),
55+
MagicMock(status_code=200, json=lambda: {"status": 901}),
56+
MagicMock(
57+
status_code=200, json=lambda: {"serial": "123", "authentication": False}
58+
),
59+
MagicMock(status_code=200, json=lambda: {"1-0:1.4.0*255": 5}),
60+
]
61+
mock_post.side_effect = [
62+
MagicMock(status_code=200, json=lambda: {"authentication": True}),
63+
MagicMock(status_code=200, json=lambda: {"authentication": True}),
64+
]
65+
66+
meter = TQEnergyManager("192.168.0.11")
67+
self.assertEqual(meter.get_powermeter_watts(), [5.0])
68+
69+
70+
if __name__ == "__main__":
71+
unittest.main()

0 commit comments

Comments
 (0)