Business operations API: get_battery_soc/get_household_wattage/get_device_inventory
This commit is contained in:
1 parent
363c7cc6a2
commit
b79adc5d54
7 files changed
+307
-112
No files matched your search
@@ -59,6 +59,21 @@ out = get_battery("...", host="10.100.1.1")
|
|||||||
print(out["value"], out["source"]) # e.g. 78.0 currentBatterySoc
|
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)
|
## 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:
|
The agent container cannot reach `10.100.1.x`; run this on the host:
|
||||||
|
|||||||
@@ -168,34 +168,16 @@ def get_battery(
|
|||||||
key_radix=key_radix, timeout=timeout,
|
key_radix=key_radix, timeout=timeout,
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
client.send_rpc(CommunicationCmd.QUERY_DEVICE_INFO,
|
result = client.get_battery_soc()
|
||||||
equip_no="",
|
except (ConnectionError, socket.error, TimeoutError) as exc:
|
||||||
data={"minProtocolVer": "V1.00.00"})
|
last_err = exc
|
||||||
except Exception as exc: # discovery is best-effort; keep going
|
log.debug("port %d failed: %s", port, exc)
|
||||||
log.debug("port %d discovery failed: %s", port, exc)
|
continue # port unreachable — try next port
|
||||||
attempts: Tuple[Tuple[CommunicationCmd, Any], ...] = (
|
if result["value"] is not None:
|
||||||
(CommunicationCmd.DEVICE_APOWER_INFO, {"update": 2}),
|
result.update({"port": port})
|
||||||
(CommunicationCmd.BMS, {"id": 1}),
|
return result
|
||||||
(CommunicationCmd.BMS, {"id": 2}),
|
last_err = ValueError(
|
||||||
(CommunicationCmd.EMS, None),
|
f"no SoC field in replies via {host}:{port}")
|
||||||
)
|
|
||||||
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")
|
|
||||||
raise ConnectionError(f"no battery reply via {host}:{list(ports)}") from last_err
|
raise ConnectionError(f"no battery reply via {host}:{list(ports)}") from last_err
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -330,7 +330,13 @@ class LocalDeviceClient:
|
|||||||
return self.send_rpc(CommunicationCmd.EMS)
|
return self.send_rpc(CommunicationCmd.EMS)
|
||||||
|
|
||||||
def query_a_power(self) -> Dict[str, Any]:
|
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]:
|
def query_warning_list(self) -> Dict[str, Any]:
|
||||||
return self.send_rpc(CommunicationCmd.DEVICE_ALARMS)
|
return self.send_rpc(CommunicationCmd.DEVICE_ALARMS)
|
||||||
@@ -347,3 +353,117 @@ class LocalDeviceClient:
|
|||||||
|
|
||||||
def query_modes_v2(self) -> Dict[str, Any]:
|
def query_modes_v2(self) -> Dict[str, Any]:
|
||||||
return self.send_rpc(CommunicationCmd.MODE_V2)
|
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
|
||||||
@@ -119,33 +119,17 @@ def get_power(
|
|||||||
sn=sn, host=host, socket_port=port,
|
sn=sn, host=host, socket_port=port,
|
||||||
key_radix=key_radix, timeout=timeout,
|
key_radix=key_radix, timeout=timeout,
|
||||||
)
|
)
|
||||||
for name, call in (
|
try:
|
||||||
("detail", client.query_detail_info),
|
result = client.get_household_wattage()
|
||||||
("load", client.query_load_status),
|
except (ConnectionError, socket.error, TimeoutError) as exc:
|
||||||
("ems", client.query_ems_info),
|
last_err = exc
|
||||||
):
|
log.debug("port %d failed: %s", port, exc)
|
||||||
try:
|
continue # port unreachable — try next port
|
||||||
reply = call()
|
if result["value"] is not None:
|
||||||
except (ConnectionError, socket.error, TimeoutError) as exc:
|
result.update({"port": port})
|
||||||
last_err = exc
|
return result
|
||||||
log.debug("port %d %s failed: %s", port, name, exc)
|
last_err = ValueError(
|
||||||
break
|
f"no power field in replies via {host}:{port}")
|
||||||
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")
|
|
||||||
raise ConnectionError(f"no power reply via {host}:{list(ports)}") from last_err
|
raise ConnectionError(f"no power reply via {host}:{list(ports)}") from last_err
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+21
-26
@@ -1,9 +1,8 @@
|
|||||||
#!/usr/bin/env python3
|
#!/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
|
One call per port: ``LocalDeviceClient.get_battery_soc`` (discovery,
|
||||||
id-less query answers the all-zero ``id: 0`` slot. Pack ids 1..2 select
|
apower, per-pack BMS reads). Prints the percentage.
|
||||||
the two batteries (``BMS_SN …B1/…B2``); ``batSoc`` is the pack charge.
|
|
||||||
|
|
||||||
Run from the repo root on the SoftAP-joined host:
|
Run from the repo root on the SoftAP-joined host:
|
||||||
python3 scripts/host_probe7.py --sn A03X2516013210060005 --ports 9000
|
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(
|
sys.path.insert(0, os.path.dirname(os.path.dirname(
|
||||||
os.path.abspath(__file__))))
|
os.path.abspath(__file__))))
|
||||||
|
|
||||||
from franklinwh_agate_client.battery import extract_soc # noqa: E402
|
|
||||||
from franklinwh_agate_client.device_local import ( # noqa: E402
|
from franklinwh_agate_client.device_local import ( # noqa: E402
|
||||||
LocalDeviceClient,
|
LocalDeviceClient,
|
||||||
)
|
)
|
||||||
from franklinwh_agate_client.protocol import ( # noqa: E402
|
from franklinwh_agate_client.protocol import ( # noqa: E402
|
||||||
CommunicationCmd,
|
|
||||||
generate_secret_key,
|
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():
|
def main():
|
||||||
ap = argparse.ArgumentParser()
|
ap = argparse.ArgumentParser()
|
||||||
ap.add_argument("--sn", required=True)
|
ap.add_argument("--sn", required=True)
|
||||||
ap.add_argument("--host", default="10.100.1.1")
|
ap.add_argument("--host", default="10.100.1.1")
|
||||||
ap.add_argument("--ports", default="9000")
|
ap.add_argument("--ports", default="9000")
|
||||||
ap.add_argument("--timeout", type=float, default=5.0)
|
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()
|
args = ap.parse_args()
|
||||||
print("secret key for %s: %s (radix 16)"
|
print("secret key for %s: %s (radix 16)"
|
||||||
% (args.sn, generate_secret_key(args.sn)))
|
% (args.sn, generate_secret_key(args.sn)))
|
||||||
for port in [int(p) for p in args.ports.split(",") if p.strip()]:
|
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,
|
client = LocalDeviceClient(args.sn, host=args.host,
|
||||||
socket_port=port, timeout=args.timeout)
|
socket_port=port, timeout=args.timeout)
|
||||||
run("bms/id1", client, lambda: client.query_bms_info(1))
|
try:
|
||||||
run("bms/id2", client, lambda: client.query_bms_info(2))
|
result = client.get_battery_soc()
|
||||||
run("apower/update2", client, lambda: client.send_rpc(
|
except (ConnectionError, socket.error, TimeoutError) as exc:
|
||||||
CommunicationCmd.DEVICE_APOWER_INFO, data={"update": 2}))
|
print("=== port %d: error: %s: %s"
|
||||||
run("ems", client, client.query_ems_info)
|
% (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__":
|
if __name__ == "__main__":
|
||||||
main()
|
raise SystemExit(main())
|
||||||
+23
-30
@@ -1,8 +1,8 @@
|
|||||||
#!/usr/bin/env python3
|
#!/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``,
|
One call per port: ``LocalDeviceClient.get_household_wattage`` (realtime
|
||||||
per-switch load status -> ``SWxExpPower``, EMS telemetry -> watts math.
|
detail → per-switch load → EMS math). Prints the watts.
|
||||||
|
|
||||||
Run from the repo root on the SoftAP-joined host:
|
Run from the repo root on the SoftAP-joined host:
|
||||||
python3 scripts/host_probe8.py --sn A03X2516013210060005 --ports 9000
|
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
|
from franklinwh_agate_client.device_local import ( # noqa: E402
|
||||||
LocalDeviceClient,
|
LocalDeviceClient,
|
||||||
)
|
)
|
||||||
from franklinwh_agate_client.power import ( # noqa: E402
|
|
||||||
ems_load_watts,
|
|
||||||
extract_power,
|
|
||||||
)
|
|
||||||
from franklinwh_agate_client.protocol import ( # noqa: E402
|
from franklinwh_agate_client.protocol import ( # noqa: E402
|
||||||
generate_secret_key,
|
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():
|
def main():
|
||||||
ap = argparse.ArgumentParser()
|
ap = argparse.ArgumentParser()
|
||||||
ap.add_argument("--sn", required=True)
|
ap.add_argument("--sn", required=True)
|
||||||
ap.add_argument("--host", default="10.100.1.1")
|
ap.add_argument("--host", default="10.100.1.1")
|
||||||
ap.add_argument("--ports", default="9000")
|
ap.add_argument("--ports", default="9000")
|
||||||
ap.add_argument("--timeout", type=float, default=5.0)
|
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()
|
args = ap.parse_args()
|
||||||
print("secret key for %s: %s (radix 16)"
|
print("secret key for %s: %s (radix 16)"
|
||||||
% (args.sn, generate_secret_key(args.sn)))
|
% (args.sn, generate_secret_key(args.sn)))
|
||||||
for port in [int(p) for p in args.ports.split(",") if p.strip()]:
|
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,
|
client = LocalDeviceClient(args.sn, host=args.host,
|
||||||
socket_port=port, timeout=args.timeout)
|
socket_port=port, timeout=args.timeout)
|
||||||
run("detail/1301", client, client.query_detail_info)
|
try:
|
||||||
run("load/1411", client, client.query_load_status)
|
result = client.get_household_wattage()
|
||||||
run("ems/1707", client, client.query_ems_info)
|
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__":
|
if __name__ == "__main__":
|
||||||
main()
|
raise SystemExit(main())
|
||||||
@@ -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()
|
||||||
Reference in new issue
Block a user