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

348 lines
13 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 json
import logging
import socket
from typing import Any, Dict, Optional
import requests
from .protocol import (
CommunicationCmd,
CommunicationObject,
CommunicationOperate,
SequenceNumber,
SocketFrameDecoder,
decrypt_wire,
direct_connect_request,
encrypt_object,
frame_socket_payload,
generate_secret_key,
)
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._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]:
wire = encrypt_object(envelope.to_json_string(), self.key,
radix=self.key_radix)
raw = self._socket_roundtrip(frame_socket_payload(wire))
try:
reply = decrypt_wire(raw, radix=self.key_radix)
except ValueError:
raise ConnectionError(
f"undecodable reply ({len(raw)} bytes) from "
f"{self.socket_host}:{self.socket_port}"
)
return json.loads(reply)
def send_probe(
self,
cmd: CommunicationCmd,
data: Optional[Dict[str, Any]] = None,
timeout: Optional[float] = 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 JSON
(or ``None``), ``reply_raw`` the exact bytes received (``b""`` when
the device accepted the frame but answered with zero bytes).
"""
envelope = direct_connect_request(
cmd, self.sn, data=data, snno=self._sn_seq.next(),
)
wire = encrypt_object(envelope.to_json_string(), self.key,
radix=self.key_radix)
reply_raw = self._socket_roundtrip(
frame_socket_payload(wire), timeout=timeout, collect_only=True,
)
reply: Optional[Dict[str, Any]] = None
if reply_raw:
try:
decoded = decrypt_wire(reply_raw, radix=self.key_radix)
except ValueError:
decoded = ""
if decoded:
try:
reply = json.loads(decoded)
except ValueError:
reply = None
return {
"cmd": str(cmd),
"envelope": envelope.to_dict(),
"tx": wire,
"reply_raw": reply_raw,
"reply": reply,
}
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:
decrypt_wire(bytes(received), radix=self.key_radix)
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]:
return self.send_rpc(CommunicationCmd.DEVICE_APOWER_INFO)
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)