470 lines
19 KiB
Python
470 lines
19 KiB
Python
"""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
|