Live power APIs: detail/load/EMS queries + power module + host_probe8
This commit is contained in:
1 parent
c11188dd0b
commit
f15ae42db7
5 files changed
+393
No files matched your search
@@ -289,6 +289,23 @@ class LocalDeviceClient:
|
||||
def query_device_info(self) -> Dict[str, Any]:
|
||||
return self.send_rpc(CommunicationCmd.QUERY_DEVICE_INFO)
|
||||
|
||||
def query_detail_info(self) -> Dict[str, Any]:
|
||||
"""``queryDeviceRealTimeData`` — realtime headline (socket id 1301).
|
||||
|
||||
No arguments. The reply ``dataArea`` carries ``totalPower``
|
||||
(the household draw the home page polls) and ``apowerCount``.
|
||||
"""
|
||||
return self.send_rpc(CommunicationCmd.DEVICE_DETAIL_INFO)
|
||||
|
||||
def query_load_status(self) -> Dict[str, Any]:
|
||||
"""``queryLoadStatusInfo`` — per smart-switch watts (socket id 1411).
|
||||
|
||||
No arguments. The reply ``dataArea`` carries per-channel
|
||||
``Sw1Volt``/``SW1Curr``/``SW1ExpPower`` (and ``SW2…``) plus
|
||||
``index``.
|
||||
"""
|
||||
return self.send_rpc(CommunicationCmd.LOAD_STATUS)
|
||||
|
||||
def query_bms_info(self, pack_id: int = 1) -> Dict[str, Any]:
|
||||
"""``queryBmsInfoBySocket`` — BMS pack query (``arguments={"id"}``).
|
||||
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
"""Live household power over the local socket.
|
||||
|
||||
The app's home page polls ``queryDeviceRealTimeData`` (socket
|
||||
``DEVICE_DETAIL_INFO``) for its energy-flow screen; per-switch watts come
|
||||
from ``queryLoadStatusInfo`` (socket ``LOAD_STATUS``); grid/load
|
||||
current+voltage telemetry comes from ``queryEmsInfoBySocket``. See
|
||||
``docs/RE_DART_FINDINGS.md`` §4 (ids) and §16.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
import socket
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from .battery import _walk
|
||||
from .device_local import LocalDeviceClient
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
HOST = "10.100.1.1"
|
||||
PORTS: Tuple[int, ...] = (9000, 18000)
|
||||
|
||||
#: Reply keys holding watt readings, in priority order.
|
||||
POWER_KEYS: Tuple[str, ...] = (
|
||||
"totalPower",
|
||||
"homeloadPower",
|
||||
"gridPower",
|
||||
"solarPower",
|
||||
"SW1ExpPower",
|
||||
"SW2ExpPower",
|
||||
)
|
||||
|
||||
|
||||
def _to_float(value: Any) -> Optional[float]:
|
||||
try:
|
||||
if isinstance(value, bool):
|
||||
return None
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def extract_power(reply: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Pull watt readings out of a parsed reply envelope.
|
||||
|
||||
Returns ``{"value": float|None, "source": str|None, "detail": {...}}``.
|
||||
``value`` is the first ``POWER_KEYS`` hit (headline: ``totalPower``);
|
||||
``detail`` carries the supporting fields (per-switch watts/currents,
|
||||
grid volts/amps) when present.
|
||||
"""
|
||||
data = reply.get("dataArea", reply)
|
||||
if isinstance(data, str):
|
||||
try:
|
||||
data = json.loads(data)
|
||||
except (ValueError, TypeError):
|
||||
data = {}
|
||||
for key in POWER_KEYS:
|
||||
for hit in _walk(reply, key):
|
||||
num = _to_float(hit)
|
||||
if num is not None:
|
||||
return {"value": num, "source": key,
|
||||
"detail": _power_detail(data)}
|
||||
return {"value": None, "source": None, "detail": _power_detail(data)}
|
||||
|
||||
|
||||
def _power_detail(data: Any) -> Dict[str, Any]:
|
||||
detail: Dict[str, Any] = {}
|
||||
if not isinstance(data, dict):
|
||||
return detail
|
||||
for key in (
|
||||
"totalPower", "apowerCount", "homeloadPower", "solarPower",
|
||||
"gridPower", "SW1ExpPower", "SW2ExpPower", "Sw1Volt", "Sw2Volt",
|
||||
"SW1Curr", "gridVol1", "gridVol2", "gridCurr1", "gridCurr2",
|
||||
"loadCurr1", "loadCurr2", "gridLineVol", "gridFreq",
|
||||
):
|
||||
hits = _walk(data, key)
|
||||
if hits:
|
||||
detail[key] = hits[0] if len(hits) == 1 else hits
|
||||
return detail
|
||||
|
||||
|
||||
def ems_load_watts(reply: Dict[str, Any]) -> Optional[float]:
|
||||
"""Estimate household draw from an EMS reply, when present.
|
||||
|
||||
``(loadCurr1 + loadCurr2) * (gridVol1 + gridVol2) / 2`` — per-leg
|
||||
current times mean leg voltage. Returns ``None`` if the fields are
|
||||
absent (the EMS reply on the tested unit carries live grid Vol/Curr).
|
||||
"""
|
||||
data = reply.get("dataArea", reply)
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
try:
|
||||
amps = float(data["loadCurr1"]) + float(data["loadCurr2"])
|
||||
volts = (float(data["gridVol1"]) + float(data["gridVol2"])) / 2
|
||||
except (KeyError, TypeError, ValueError):
|
||||
return None
|
||||
return amps * volts
|
||||
|
||||
|
||||
def get_power(
|
||||
sn: str,
|
||||
host: str = HOST,
|
||||
ports: Tuple[int, ...] = PORTS,
|
||||
key_radix: int = 16,
|
||||
timeout: float = 5.0,
|
||||
) -> Dict[str, Any]:
|
||||
"""Read live household wattage over the local TCP socket.
|
||||
|
||||
Tries realtime detail (``DEVICE_DETAIL_INFO`` → ``totalPower``),
|
||||
then per-switch load status, then EMS telemetry, probing each port.
|
||||
Returns the ``extract_power`` dict plus ``cmd``/``port``/``reply``.
|
||||
"""
|
||||
last_err: Optional[Exception] = None
|
||||
for port in ports:
|
||||
client = LocalDeviceClient(
|
||||
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")
|
||||
raise ConnectionError(f"no power reply via {host}:{list(ports)}") from last_err
|
||||
|
||||
|
||||
def main(argv: Optional[List[str]] = None) -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Read live household wattage over the device SoftAP socket.")
|
||||
parser.add_argument("--sn", required=True, help="20-char device serial")
|
||||
parser.add_argument("--host", default=HOST, help="aGate gateway IP")
|
||||
parser.add_argument("--ports", default=",".join(map(str, PORTS)),
|
||||
help="comma-separated socket ports to probe")
|
||||
parser.add_argument("--timeout", type=float, default=5.0)
|
||||
parser.add_argument("--raw", action="store_true",
|
||||
help="print full reply JSON")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
ports = tuple(int(p) for p in args.ports.split(",") if p.strip())
|
||||
try:
|
||||
result = get_power(args.sn, host=args.host, ports=ports,
|
||||
timeout=args.timeout)
|
||||
except Exception as exc:
|
||||
print(f"ERROR: {exc}")
|
||||
return 1
|
||||
if args.raw:
|
||||
print(json.dumps(result["reply"], indent=2, default=str))
|
||||
else:
|
||||
value = result["value"]
|
||||
print(f"{value:g}W ({result['source']})"
|
||||
if value is not None else "unknown")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in new issue
Block a user