diff --git a/docs/BATTERY_POC.md b/docs/BATTERY_POC.md index 6be3809..f40c220 100644 --- a/docs/BATTERY_POC.md +++ b/docs/BATTERY_POC.md @@ -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: diff --git a/franklinwh_agate_client/battery.py b/franklinwh_agate_client/battery.py index 3febd62..f899de9 100644 --- a/franklinwh_agate_client/battery.py +++ b/franklinwh_agate_client/battery.py @@ -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 diff --git a/franklinwh_agate_client/device_local.py b/franklinwh_agate_client/device_local.py index f3d2657..d9ae7e9 100644 --- a/franklinwh_agate_client/device_local.py +++ b/franklinwh_agate_client/device_local.py @@ -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 diff --git a/franklinwh_agate_client/power.py b/franklinwh_agate_client/power.py index 1e5b573..378845e 100644 --- a/franklinwh_agate_client/power.py +++ b/franklinwh_agate_client/power.py @@ -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 diff --git a/scripts/host_probe7.py b/scripts/host_probe7.py index 4cd97e2..c022e5e 100644 --- a/scripts/host_probe7.py +++ b/scripts/host_probe7.py @@ -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()) diff --git a/scripts/host_probe8.py b/scripts/host_probe8.py index 5b76c57..d5381f4 100644 --- a/scripts/host_probe8.py +++ b/scripts/host_probe8.py @@ -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()) diff --git a/tests/test_operations.py b/tests/test_operations.py new file mode 100644 index 0000000..e8bd25c --- /dev/null +++ b/tests/test_operations.py @@ -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()