Files
franklinwh-agate-client/franklinwh_agate_client/power.py
T
kfj001 b60b32830f Optional aGate address on the API (home-LAN direct connect)
AgateClient takes address= default; attach_local accepts
address/host/gateway_ip per call with join control. An explicit
address skips the SoftAP radio join by default (join=True forces
it). derive_hotspot_credentials and join_device_hotspot accept a
gateway_ip override. battery/power host defaults now use
DEVICE_GATEWAY_IP. Verified live at 192.168.1.142:9000.
2026-10-16 08:18:33 +00:00

167 lines
5.6 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
from .wifi import DEVICE_GATEWAY_IP
log = logging.getLogger(__name__)
HOST = DEVICE_GATEWAY_IP
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,
)
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
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())