"""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(), operate=operate, ) 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, operate: CommunicationOperate = CommunicationOperate.QUERY, ) -> 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(), operate=operate, ) 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) def join_home_wifi(self, ssid: str, password: str = "") -> Dict[str, Any]: """``connectWifi`` — join the aGate to a home Wi-Fi network. Mirrors ``WifiOnboardingConnectProvider``: sends a ``ConnectWifiEntity`` shaped payload (``ssid``/``password`` plus the ``wifi_SSID``/``wifi_Pw`` mirrors) with operate SET over the current transport. Follow with :meth:`query_connection_status` to confirm association. .. warning:: State-changing: on success the unit leaves its SoftAP (or roams), so the current socket/HTTP session drops. Only run when you can rejoin the device network afterwards. """ return self.send_rpc( CommunicationCmd.CONNECT_WIFI, operate=CommunicationOperate.SET, data={"ssid": ssid, "password": password, "wifi_SSID": ssid, "wifi_Pw": password}, ) def scan_home_wifi(self) -> Dict[str, Any]: """``scanWifiList`` — networks visible to the aGate (socket 1109).""" return self.send_rpc(CommunicationCmd.SCAN_WIFI_LIST) def query_connection_status(self) -> Dict[str, Any]: """``connectionStatus`` — current STA association state.""" return self.send_rpc(CommunicationCmd.CONNECTION_STATUS) # --------------------------------------------------- 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