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
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user