"""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_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)