182 lines
6.3 KiB
Python
182 lines
6.3 KiB
Python
"""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())
|