Files
franklinwh-agate-client/franklinwh_agate_client/device_local.py
T

470 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Local (direct-connect) device client.
Talks to the aGate **on its own network** after joining the device hotspot —
the direct-connect path of the app (see ``docs/RE_DART_FINDINGS.md`` §8,
§11, §14). Two channels:
* **HTTP** to ``https://IP:PORT/terminal/...`` — the ``/terminal/*`` local
endpoint family (hotspot info, aGate info, overall info, MQTT relay, ...).
Scheme/port are *not* confirmed live (Open item #3); defaults follow the
app's ``RemoteService`` shape.
* **TCP socket** to ``10.100.1.1:9000`` — verified live 2026-10-16. The
app dials the gateway IP port ``18000`` (``0x4650``), which is closed on
the tested unit; ``9000`` answers the same encrypted
:class:`~franklinwh_agate_client.protocol.CommunicationObject` envelope
sent **raw** (``dart:io _Socket.add``, no length prefix), with socket-id
``cmdType`` values (``SOCKET_CMD_TYPE``) and ``opt``-seeded payloads.
Replies use the identical framing with id ``request + 1``.
"""
from __future__ import annotations
import logging
import socket
from typing import Any, Dict, Optional
import requests
from .protocol import (
CommunicationCmd,
CommunicationObject,
CommunicationOperate,
SequenceNumber,
SocketFrameDecoder,
direct_connect_request,
frame_socket_payload,
generate_secret_key,
)
from .protocol.messages import EnvelopeCodec
from .wifi import DEVICE_GATEWAY_IP
log = logging.getLogger(__name__)
DEFAULT_HOST = DEVICE_GATEWAY_IP
#: Candidate local port constant observed in the connect path (0x4650).
DEFAULT_LOCAL_PORT = 18000
#: ``X-Device-*`` headers located in ``base.apk`` (classes5.dex). Exact
#: values are not yet confirmed (Open item #5) — override as needed.
X_DEVICE_IP = "X-Device-IP"
X_DEVICE_REFERER = "X-Device-Referer"
X_DEVICE_REQUESTED_WITH = "X-Device-Requested-With"
X_DEVICE_ACCEPT_LANGUAGE = "X-Device-Accept-Language"
class LocalDeviceError(RuntimeError):
"""Raised when the device answers with an error envelope."""
class LocalDeviceClient:
"""Direct-connect client for one aGate device.
:param sn: device serial number (drives hotspot creds + secret key).
:param host: device IP on its hotspot network (default gateway 10.100.1.1).
:param port: HTTP port of the device's ``/terminal`` API (candidate 18000).
:param scheme: ``https`` (device self-signed cert expected) or ``http``.
:param socket_port: local TCP socket port (candidate 18000).
:param verify: TLS verification for the device API (device likely serves a
self-signed cert, so ``False`` is the practical default).
:param key_radix: key parse radix for the envelope cipher (16 —
default ``int.tryParse`` radix, verified against live captures).
:param timeout: per-request timeout in seconds.
"""
def __init__(
self,
sn: str,
host: str = DEFAULT_HOST,
port: int = DEFAULT_LOCAL_PORT,
scheme: str = "https",
verify: Optional[bool] = False,
socket_port: Optional[int] = None,
key_radix: int = 16,
timeout: float = 10.0,
extra_headers: Optional[Dict[str, str]] = None,
session: Optional[requests.Session] = None,
) -> None:
self.sn = sn
self.host = host
self.port = int(port)
self.scheme = scheme
self.base_url = f"{scheme}://{host}:{port}"
self.socket_host = host
self.socket_port = int(socket_port if socket_port is not None else DEFAULT_LOCAL_PORT)
self.key_radix = int(key_radix)
self.timeout = timeout
self.key = generate_secret_key(sn)
self.codec = EnvelopeCodec(self.key, radix=self.key_radix)
self._sn_seq = SequenceNumber()
self._session = session or requests.Session()
self._session.headers.update(
{X_DEVICE_IP: host, **(extra_headers or {})}
)
self._session.verify = bool(verify)
if not verify:
import urllib3
urllib3.disable_warnings(
category=urllib3.exceptions.InsecureRequestWarning
)
# ------------------------------------------------------------------ HTTP
def request(
self,
path: str,
method: str = "GET",
params: Optional[Dict[str, Any]] = None,
json_body: Optional[Any] = None,
) -> Any:
"""Raw request against ``/terminal`` (or any absolute path)."""
url = path if path.startswith(("http://", "https://")) else self.base_url + path
resp = self._session.request(
method, url, params=params, json=json_body, timeout=self.timeout
)
resp.raise_for_status()
ctype = resp.headers.get("Content-Type", "")
if "json" in ctype or resp.text.lstrip()[:1] in "{[":
return resp.json()
return resp.text
# -- local endpoint family (docs/RE_DART_FINDINGS.md §11) --------------
def get_hotspot_info(self) -> Any:
"""``/terminal/getHotSpotInfo`` — authoritative SSID/pwd."""
return self.request("/terminal/getHotSpotInfo")
def get_hotspot_info_v2(self) -> Any:
return self.request("/terminal/getHotSpotInfo/v2")
def get_hotspot_password(self) -> Any:
"""``/terminal/getHosSpotPassword`` (sic)."""
return self.request("/terminal/getHosSpotPassword")
def obtain_agate_info(self) -> Any:
"""``/terminal/obtainAgateInfo``."""
return self.request("/terminal/obtainAgateInfo")
def get_device_info_v2(self) -> Any:
return self.request("/terminal/getDeviceInfoV2")
def get_device_composite_info(self) -> Any:
return self.request("/terminal/getDeviceCompositeInfo")
def select_device_overall_info(self) -> Any:
return self.request("/terminal/selectDeviceOverallInfo")
def query_generator_data(self) -> Any:
return self.request("/terminal/queryGeneratorData")
def get_gateway_system_setting(self) -> Any:
return self.request("/terminal/getGatewaySystemSetting")
def get_system_setting(self) -> Any:
return self.request("/terminal/system/getSystemSetting")
def send_mqtt(self, payload: Any) -> Any:
"""``/terminal/sendMqtt`` — relay an MQTT payload over local HTTP."""
return self.request("/terminal/sendMqtt", method="POST", json_body=payload)
def update_hotspot_info(self, payload: Dict[str, Any]) -> Any:
return self.request("/terminal/updateHotSpotInfo", method="POST",
json_body=payload)
# -------------------------------------------------------------- sockets
def send_rpc(
self,
cmd: CommunicationCmd,
equip_no: Optional[str] = None,
operate: CommunicationOperate = CommunicationOperate.QUERY,
data: Optional[Dict[str, Any]] = None,
envelope: Optional[CommunicationObject] = None,
) -> Dict[str, Any]:
"""Send one envelope over the local socket and return the parsed reply.
Mirrors ``sendObject``: the envelope's ``snno`` is stamped from the
``increaseSN`` counter, serialised to compact JSON,
``encrypt_object``-ed, and sent **raw** (``_Socket.add``, no length
prefix). The reply is accumulated chunk by chunk and run through
``decrypt_wire`` (2nd-comma split, equipNo-derived key) until it
yields a JSON object.
"""
if envelope is None:
envelope = direct_connect_request(
cmd, self.sn if equip_no is None else equip_no, data=data,
snno=self._sn_seq.next(),
)
return self.send_envelope(envelope)
def send_envelope(self, envelope: CommunicationObject) -> Dict[str, Any]:
raw = self._socket_roundtrip(
frame_socket_payload(self.codec.encode(envelope)))
try:
reply = self.codec.decode(raw).to_dict()
except ValueError:
raise ConnectionError(
f"undecodable reply ({len(raw)} bytes) from "
f"{self.socket_host}:{self.socket_port}"
)
return reply
def send_probe(
self,
cmd: CommunicationCmd,
data: Optional[Dict[str, Any]] = None,
timeout: Optional[float] = None,
equip_no: Optional[str] = None,
) -> Dict[str, Any]:
"""Send one envelope, return exact TX/RX bytes plus any parsed reply.
Never raises on a device-level failure: ``reply`` is the parsed
envelope dict (or ``None``), ``reply_raw`` the exact bytes received
(``b""`` when the device accepted the frame but answered with zero
bytes). ``equip_no`` overrides the serial (``""`` reproduces the
app's empty-SN discovery frame).
"""
envelope = direct_connect_request(
cmd, self.sn if equip_no is None else equip_no, data=data,
snno=self._sn_seq.next(),
)
wire = self.codec.encode(envelope)
reply_raw = self._socket_roundtrip(
frame_socket_payload(wire), timeout=timeout, collect_only=True,
)
return {
"cmd": str(cmd),
"envelope": envelope.to_dict(),
"tx": wire,
"reply_raw": reply_raw,
"reply": self.codec.try_decode(reply_raw),
}
def send_raw(self, payload: bytes,
timeout: Optional[float] = None) -> bytes:
"""Low-level escape hatch: one connection, send *payload*, drain.
Used by transport-level probes (framing/handshake variants) that
are not valid envelopes. Returns the exact bytes received (``b""``
when the peer answers with nothing). Envelope traffic must use
:meth:`send_envelope` / :meth:`send_probe` instead.
"""
return self._socket_roundtrip(bytes(payload), timeout=timeout,
collect_only=True)
def _socket_roundtrip(self, framed: bytes,
timeout: Optional[float] = None,
collect_only: bool = False) -> bytes:
timeout = timeout if timeout is not None else self.timeout
received = bytearray()
with socket.create_connection(
(self.socket_host, self.socket_port), timeout=timeout
) as sock:
sock.settimeout(timeout)
sock.sendall(framed)
decoder = SocketFrameDecoder()
while True:
try:
chunk = sock.recv(65536)
except socket.timeout:
break
if not chunk:
break
received.extend(chunk)
if collect_only:
# Probe mode: drain what the device sends, then stop on
# the short post-reply idle timeout. A rejected frame
# is met with zero bytes + close; a real reply arrives
# as buffered bytes first.
sock.settimeout(0.5)
continue
if decoder.feed(chunk):
break
if collect_only:
return bytes(received)
try:
self.codec.decode(bytes(received))
except ValueError:
raise ConnectionError(
f"no reply frame from {self.socket_host}:{self.socket_port}"
)
return bytes(received)
# ------------------------------------------------------- convenience RPCs
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"}``).
The pack index selects the battery (``BMS_SN`` ``…B1``/``…B2`` in
the device-info reply); omitting it yields the all-zero ``id: 0``
slot. ``batSoc``/``batSoh`` carry the pack charge/health.
"""
return self.send_rpc(CommunicationCmd.BMS, data={"id": int(pack_id)})
def query_mppt_info(self) -> Dict[str, Any]:
return self.send_rpc(CommunicationCmd.MPPT)
def query_pe_info(self) -> Dict[str, Any]:
return self.send_rpc(CommunicationCmd.PE)
def query_generator(self) -> Dict[str, Any]:
return self.send_rpc(CommunicationCmd.GENERATOR_INFO)
def query_ems_info(self) -> Dict[str, Any]:
return self.send_rpc(CommunicationCmd.EMS)
def query_a_power(self) -> Dict[str, Any]:
"""``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)
def query_current_net_type(self) -> Dict[str, Any]:
return self.send_rpc(CommunicationCmd.NETWORK_INFO)
def query_sn_array(self) -> Dict[str, Any]:
return self.send_rpc(CommunicationCmd.RESOURCE_ADDRESS)
def query_modes(self) -> Dict[str, Any]:
"""``_queryModesByDirectConnect`` family (``mode`` cmd)."""
return self.send_rpc(CommunicationCmd.MODE)
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