Business operations API: get_battery_soc/get_household_wattage/get_device_inventory

This commit is contained in:
kfj001 committed 2026-10-16 04:58:33 +00:00
1 parent 363c7cc6a2
commit b79adc5d54
7 files changed
+307 -112

No files matched your search

+15
View File
@@ -59,6 +59,21 @@ out = get_battery("...", host="10.100.1.1")
print(out["value"], out["source"]) # e.g. 78.0 currentBatterySoc
```
Business operations (one call per question, on a bound client):
```python
from franklinwh_agate_client.device_local import LocalDeviceClient
client = LocalDeviceClient("A03X2516013210060005", host="10.100.1.1",
socket_port=9000)
client.get_battery_soc() # charge % (apower, then BMS packs 1-2)
client.get_household_wattage() # watts (detail, then load, then EMS math)
client.get_device_inventory() # IBG/FHP/BMS serials + versions
```
`get_*` returns `{"value", "source", "detail", "reply", ...}` with
`value: None` when nothing answered; transport errors propagate so
multi-port callers (`get_battery`, `get_power`) can move on.
## Host probe recipe (run on the machine joined to the device AP)
The agent container cannot reach `10.100.1.x`; run this on the host:
+10 -28
View File
@@ -168,34 +168,16 @@ def get_battery(
key_radix=key_radix, timeout=timeout,
)
try:
client.send_rpc(CommunicationCmd.QUERY_DEVICE_INFO,
equip_no="",
data={"minProtocolVer": "V1.00.00"})
except Exception as exc: # discovery is best-effort; keep going
log.debug("port %d discovery failed: %s", port, exc)
attempts: Tuple[Tuple[CommunicationCmd, Any], ...] = (
(CommunicationCmd.DEVICE_APOWER_INFO, {"update": 2}),
(CommunicationCmd.BMS, {"id": 1}),
(CommunicationCmd.BMS, {"id": 2}),
(CommunicationCmd.EMS, None),
)
for cmd, data in attempts:
try:
reply = client.send_rpc(cmd, data=data)
except (ConnectionError, socket.error, TimeoutError) as exc:
last_err = exc
log.debug("port %d cmd %s failed: %s", port, cmd, exc)
break # port unreachable — try next port
except Exception as exc: # envelope-level failure; try next cmd
last_err = exc
log.debug("port %d cmd %s error: %s", port, cmd, exc)
continue
result = extract_soc(reply)
result.update({"cmd": str(cmd), "port": port, "reply": reply})
if result["value"] is not None and not _is_empty_pack(
reply, result):
return result
last_err = ValueError(f"reply on port {port} cmd {cmd} has no SoC field")
result = client.get_battery_soc()
except (ConnectionError, socket.error, TimeoutError) as exc:
last_err = exc
log.debug("port %d failed: %s", port, exc)
continue # port unreachable — try next port
if result["value"] is not None:
result.update({"port": port})
return result
last_err = ValueError(
f"no SoC field in replies via {host}:{port}")
raise ConnectionError(f"no battery reply via {host}:{list(ports)}") from last_err
+121 -1
View File
@@ -330,7 +330,13 @@ class LocalDeviceClient:
return self.send_rpc(CommunicationCmd.EMS)
def query_a_power(self) -> Dict[str, Any]:
return self.send_rpc(CommunicationCmd.DEVICE_APOWER_INFO)
"""``queryAPowerData`` — apower query (``arguments={"update": 2}``).
Note: the tested unit answers ``{"result": 1, "reason": -1}`` here;
prefer :meth:`query_bms_info` / :meth:`get_battery_soc` for charge.
"""
return self.send_rpc(CommunicationCmd.DEVICE_APOWER_INFO,
data={"update": 2})
def query_warning_list(self) -> Dict[str, Any]:
return self.send_rpc(CommunicationCmd.DEVICE_ALARMS)
@@ -347,3 +353,117 @@ class LocalDeviceClient:
def query_modes_v2(self) -> Dict[str, Any]:
return self.send_rpc(CommunicationCmd.MODE_V2)
# --------------------------------------------------- business operations
# Intention-revealing reads: each runs the app's query sequence for one
# question and returns ``{"value", "source", "detail", "reply", ...}``.
# Transport errors propagate (caller picks the next port/device);
# answered-but-empty replies yield ``value: None``.
def get_device_inventory(self) -> Dict[str, Any]:
"""Discovery + device info: identities, versions, FHP/BMS serials.
Mirrors the direct-connect open: a best-effort discovery query
(empty serial), then ``queryDeviceInfo``. Returns the parsed
device-info reply plus an ``identity`` summary (``ibg_sn``,
``fhp_sn``/``bms_sn`` lists, ``protocol_ver``).
"""
try:
self.send_rpc(CommunicationCmd.QUERY_DEVICE_INFO,
equip_no="",
data={"minProtocolVer": "V1.00.00"})
except Exception:
log.debug("discovery probe failed (best-effort)", exc_info=True)
reply = self.send_rpc(
CommunicationCmd.QUERY_DEVICE_INFO,
data={"minProtocolVer": "V1.00.00"})
data = reply.get("dataArea", {})
if not isinstance(data, dict):
data = {}
return {
"reply": reply,
"ibg_sn": reply.get("equipNo"),
"fhp_sn": list(data.get("FHP_SN", []) or []),
"bms_sn": list(data.get("BMS_SN", []) or []),
"protocol_ver": data.get("protocolVer"),
"result": data.get("result"),
}
def get_battery_soc(self) -> Dict[str, Any]:
"""Aggregate battery charge percentage for this device.
Tries apower, then per-pack BMS reads (``id`` 1..2 — the id-less
slot answers all zeros), skipping empty placeholder slots.
Returns ``extract_soc`` fields plus ``reply``/``cmd`` context;
``value`` is ``None`` when nothing answered with charge data.
"""
from .battery import _is_empty_pack, extract_soc
try: # best-effort open, mirrors the direct-connect flow
self.send_rpc(CommunicationCmd.QUERY_DEVICE_INFO,
equip_no="",
data={"minProtocolVer": "V1.00.00"})
except Exception:
log.debug("discovery probe failed (best-effort)", exc_info=True)
last: Dict[str, Any] = {"value": None, "source": None,
"detail": {}, "reply": None, "cmd": None}
for cmd, data in (
(CommunicationCmd.DEVICE_APOWER_INFO, {"update": 2}),
(CommunicationCmd.BMS, {"id": 1}),
(CommunicationCmd.BMS, {"id": 2}),
):
try:
reply = self.send_rpc(cmd, data=data)
except (ConnectionError, socket.error, TimeoutError):
raise
except Exception as exc: # envelope-level failure; next attempt
log.debug("get_battery_soc %s error: %s", cmd, exc)
continue
result = extract_soc(reply)
result.update({"reply": reply, "cmd": str(cmd)})
if (result["value"] is not None
and not _is_empty_pack(reply, result)):
return result
last = result
if last["value"] is not None and _is_empty_pack(
last.get("reply") or {}, last):
# every attempt hit placeholder slots: unknown, not 0 %.
last = dict(last, value=None, source=None)
return last
def get_household_wattage(self) -> Dict[str, Any]:
"""Live household wattage draw for this device.
Tries realtime detail (``totalPower``), then per-switch load
status, then EMS current×voltage math. Returns ``extract_power``
fields plus ``reply``/``cmd`` context; ``value`` is ``None`` when
nothing answered with power data.
"""
from .power import _power_detail, ems_load_watts, extract_power
last: Dict[str, Any] = {"value": None, "source": None,
"detail": {}, "reply": None, "cmd": None}
for name, call in (
("detail", self.query_detail_info),
("load", self.query_load_status),
("ems", self.query_ems_info),
):
try:
reply = call()
except (ConnectionError, socket.error, TimeoutError):
raise
except Exception as exc: # envelope-level failure; next attempt
log.debug("get_household_wattage %s error: %s", name, exc)
continue
result = extract_power(reply)
if result["value"] is None and name == "ems":
watts = ems_load_watts(reply)
if watts is not None:
result = {"value": watts, "source": "ems(loadCurr*V)",
"detail": _power_detail(
reply.get("dataArea", reply))}
result.update({"reply": reply, "cmd": name})
if result["value"] is not None:
return result
last = result
return last
+11 -27
View File
@@ -119,33 +119,17 @@ def get_power(
sn=sn, host=host, socket_port=port,
key_radix=key_radix, timeout=timeout,
)
for name, call in (
("detail", client.query_detail_info),
("load", client.query_load_status),
("ems", client.query_ems_info),
):
try:
reply = call()
except (ConnectionError, socket.error, TimeoutError) as exc:
last_err = exc
log.debug("port %d %s failed: %s", port, name, exc)
break
except Exception as exc:
last_err = exc
log.debug("port %d %s error: %s", port, name, exc)
continue
result = extract_power(reply)
if result["value"] is None and name == "ems":
watts = ems_load_watts(reply)
if watts is not None:
result = {"value": watts, "source": "ems(loadCurr*V)",
"detail": _power_detail(
reply.get("dataArea", reply))}
result.update({"cmd": name, "port": port, "reply": reply})
if result["value"] is not None:
return result
last_err = ValueError(
f"reply on port {port} {name} has no power field")
try:
result = client.get_household_wattage()
except (ConnectionError, socket.error, TimeoutError) as exc:
last_err = exc
log.debug("port %d failed: %s", port, exc)
continue # port unreachable — try next port
if result["value"] is not None:
result.update({"port": port})
return result
last_err = ValueError(
f"no power field in replies via {host}:{port}")
raise ConnectionError(f"no power reply via {host}:{list(ports)}") from last_err
+21 -26
View File
@@ -1,9 +1,8 @@
#!/usr/bin/env python3
"""Host probe v7 — per-pack BMS reads via the library business API.
"""Host probe v7 — battery charge via the business operations API.
The app's ``queryBmsInfoBySocket`` passes ``arguments={"id": pack}``; the
id-less query answers the all-zero ``id: 0`` slot. Pack ids 1..2 select
the two batteries (``BMS_SN …B1/…B2``); ``batSoc`` is the pack charge.
One call per port: ``LocalDeviceClient.get_battery_soc`` (discovery,
apower, per-pack BMS reads). Prints the percentage.
Run from the repo root on the SoftAP-joined host:
python3 scripts/host_probe7.py --sn A03X2516013210060005 --ports 9000
@@ -18,48 +17,44 @@ import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(
os.path.abspath(__file__))))
from franklinwh_agate_client.battery import extract_soc # noqa: E402
from franklinwh_agate_client.device_local import ( # noqa: E402
LocalDeviceClient,
)
from franklinwh_agate_client.protocol import ( # noqa: E402
CommunicationCmd,
generate_secret_key,
)
def run(label, client, call):
try:
reply = call()
except (ConnectionError, socket.error, TimeoutError) as exc:
print("--- %s: error: %s: %s" % (label, type(exc).__name__, exc))
return None
print("--- %s (%d B reply)" % (label, len(json.dumps(reply))))
print(" reply: " + json.dumps(reply, default=str)[:1500])
result = extract_soc(reply)
print(" ==> soc: %s (source=%s)" % (result["value"], result["source"]))
return reply
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--sn", required=True)
ap.add_argument("--host", default="10.100.1.1")
ap.add_argument("--ports", default="9000")
ap.add_argument("--timeout", type=float, default=5.0)
ap.add_argument("--raw", action="store_true",
help="print the full reply envelope")
args = ap.parse_args()
print("secret key for %s: %s (radix 16)"
% (args.sn, generate_secret_key(args.sn)))
for port in [int(p) for p in args.ports.split(",") if p.strip()]:
print("=== port %d" % port)
client = LocalDeviceClient(args.sn, host=args.host,
socket_port=port, timeout=args.timeout)
run("bms/id1", client, lambda: client.query_bms_info(1))
run("bms/id2", client, lambda: client.query_bms_info(2))
run("apower/update2", client, lambda: client.send_rpc(
CommunicationCmd.DEVICE_APOWER_INFO, data={"update": 2}))
run("ems", client, client.query_ems_info)
try:
result = client.get_battery_soc()
except (ConnectionError, socket.error, TimeoutError) as exc:
print("=== port %d: error: %s: %s"
% (port, type(exc).__name__, exc))
continue
print("=== port %d" % port)
if args.raw:
print(json.dumps(result.get("reply"), indent=2, default=str))
return 0
value = result["value"]
print("==> soc: %s%% (source=%s, cmd=%s)"
% (value, result["source"], result["cmd"])
if value is not None else "==> soc: unknown")
return 0
if __name__ == "__main__":
main()
raise SystemExit(main())
+23 -30
View File
@@ -1,8 +1,8 @@
#!/usr/bin/env python3
"""Host probe v8 — live household wattage via the library business API.
"""Host probe v8 — household wattage via the business operations API.
Three socket APIs: realtime detail (home-page poll) -> ``totalPower``,
per-switch load status -> ``SWxExpPower``, EMS telemetry -> watts math.
One call per port: ``LocalDeviceClient.get_household_wattage`` (realtime
detail → per-switch load → EMS math). Prints the watts.
Run from the repo root on the SoftAP-joined host:
python3 scripts/host_probe8.py --sn A03X2516013210060005 --ports 9000
@@ -20,50 +20,43 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(
from franklinwh_agate_client.device_local import ( # noqa: E402
LocalDeviceClient,
)
from franklinwh_agate_client.power import ( # noqa: E402
ems_load_watts,
extract_power,
)
from franklinwh_agate_client.protocol import ( # noqa: E402
generate_secret_key,
)
def run(label, client, call):
try:
reply = call()
except (ConnectionError, socket.error, TimeoutError) as exc:
print("--- %s: error: %s: %s" % (label, type(exc).__name__, exc))
return None
print("--- %s" % label)
print(" reply: " + json.dumps(reply, default=str)[:2000])
result = extract_power(reply)
value, source = result["value"], result["source"]
if value is None:
watts = ems_load_watts(reply)
if watts is not None:
value, source = watts, "ems(loadCurr*V)"
print(" ==> watts: %s (source=%s)" % (value, source))
return reply
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--sn", required=True)
ap.add_argument("--host", default="10.100.1.1")
ap.add_argument("--ports", default="9000")
ap.add_argument("--timeout", type=float, default=5.0)
ap.add_argument("--raw", action="store_true",
help="print the full reply envelope")
args = ap.parse_args()
print("secret key for %s: %s (radix 16)"
% (args.sn, generate_secret_key(args.sn)))
for port in [int(p) for p in args.ports.split(",") if p.strip()]:
print("=== port %d" % port)
client = LocalDeviceClient(args.sn, host=args.host,
socket_port=port, timeout=args.timeout)
run("detail/1301", client, client.query_detail_info)
run("load/1411", client, client.query_load_status)
run("ems/1707", client, client.query_ems_info)
try:
result = client.get_household_wattage()
except (ConnectionError, socket.error, TimeoutError) as exc:
print("=== port %d: error: %s: %s"
% (port, type(exc).__name__, exc))
continue
print("=== port %d" % port)
if args.raw:
print(json.dumps(result.get("reply"), indent=2, default=str))
return 0
value = result["value"]
if value is not None:
print("==> watts: %gW (source=%s, cmd=%s)"
% (value, result["source"], result["cmd"]))
else:
print("==> watts: unknown")
return 0
if __name__ == "__main__":
main()
raise SystemExit(main())
+106
View File
@@ -0,0 +1,106 @@
"""Offline tests for the business operations (transport stubbed)."""
import unittest
from franklinwh_agate_client.device_local import LocalDeviceClient
from franklinwh_agate_client.protocol import CommunicationCmd
def _client(handler):
client = LocalDeviceClient("SN", host="127.0.0.1", timeout=0.1)
calls = []
def fake_send_rpc(cmd, equip_no=None, operate=None, data=None,
envelope=None):
calls.append((cmd, equip_no, data))
return handler(cmd, equip_no, data)
client.send_rpc = fake_send_rpc
return client, calls
class TestBatterySocOp(unittest.TestCase):
def test_apower_win(self):
client, calls = _client(
lambda cmd, eq, data: {"dataArea": {"currentBatterySoc": 78}})
out = client.get_battery_soc()
self.assertEqual(out["value"], 78)
self.assertEqual(out["source"], "currentBatterySoc")
def test_bms_pack_fallback(self):
def handler(cmd, eq, data):
if cmd == CommunicationCmd.DEVICE_APOWER_INFO:
return {"dataArea": {"result": 1, "reason": -1}}
if (data or {}).get("id") == 1:
return {"dataArea": {"opt": 0, "result": 0, "id": 1,
"batSoc": 64, "batSoh": 99,
"batTotalVolt": 512}}
return {"dataArea": {"opt": 0, "result": 0, "id": 0,
"batSoc": 0, "batSoh": 0,
"batTotalVolt": 0}}
client, calls = _client(handler)
out = client.get_battery_soc()
self.assertEqual(out["value"], 64)
self.assertEqual(out["source"], "batSoc")
# discovery + apower + bms id=1 (empty id=0 never needed)
ids = [d.get("id") if isinstance(d, dict) else None
for _, _, d in calls]
self.assertIn(1, ids)
def test_empty_slots_skipped(self):
client, _ = _client(
lambda cmd, eq, data: {"dataArea": {"opt": 0, "result": 0,
"id": 0, "batSoc": 0,
"batSoh": 0,
"batTotalVolt": 0}})
out = client.get_battery_soc()
self.assertIsNone(out["value"])
def test_transport_error_propagates(self):
client, _ = _client(None)
def boom(cmd, equip_no=None, operate=None, data=None,
envelope=None):
raise ConnectionError("down")
client.send_rpc = boom
with self.assertRaises(ConnectionError):
client.get_battery_soc()
class TestWattageOp(unittest.TestCase):
def test_detail_win(self):
client, _ = _client(
lambda cmd, eq, data: {"dataArea": {"apowerCount": 2,
"totalPower": 1872.5}})
out = client.get_household_wattage()
self.assertEqual(out["value"], 1872.5)
self.assertEqual(out["source"], "totalPower")
def test_ems_math_fallback(self):
def handler(cmd, eq, data):
if cmd == CommunicationCmd.EMS:
return {"dataArea": {"gridVol1": 120.0, "gridVol2": 120.0,
"loadCurr1": 5.0, "loadCurr2": 5.0}}
return {"dataArea": {"result": 1, "reason": -1}}
client, _ = _client(handler)
out = client.get_household_wattage()
self.assertAlmostEqual(out["value"], 1200.0)
self.assertEqual(out["source"], "ems(loadCurr*V)")
def test_inventory(self):
client, _ = _client(
lambda cmd, eq, data: {
"cmdType": 1102, "equipNo": "IBG",
"dataArea": {"opt": 0, "result": 0, "FHP_SN": ["A", "B"],
"BMS_SN": ["C"], "protocolVer": "V1"}})
out = client.get_device_inventory()
self.assertEqual(out["ibg_sn"], "IBG")
self.assertEqual(out["fhp_sn"], ["A", "B"])
self.assertEqual(out["protocol_ver"], "V1")
if __name__ == "__main__":
unittest.main()