"""Local (direct-connect) device client. Talks to the aGate **on its own network** after joining the device hotspot — the ``mode == local`` path of the app (see ``docs/RE_DART_FINDINGS.md`` §8, §11). 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 (Open item #3); defaults are candidates. * **TCP socket** to ``10.100.1.1:18000`` (candidate port — Open item #4) carrying the encrypted :class:`~franklinwh_agate_client.protocol.CommunicationObject` envelope with little-endian int16 length framing. """ from __future__ import annotations import json import logging import socket from typing import Any, Dict, Optional import requests from .protocol import ( CommunicationCmd, CommunicationObject, CommunicationOperate, SocketFrameDecoder, decrypt_object, 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; 32 is the alternate candidate seen in the disassembly). :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._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: str = "", envelope: Optional[CommunicationObject] = None, ) -> Dict[str, Any]: """Send one envelope over the local socket and return the parsed reply. The request is the app's envelope: compact JSON -> ``encrypt_object`` -> int16LE length framing. The reply is deframed, decrypted (the unencrypted tail is located via ``cmdType``/``equipNo``) and parsed back into a JSON object. """ if envelope is None: envelope = direct_connect_request( cmd, equip_no or self.sn, data=data ) 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)) reply = decrypt_object( raw, self.key, cmd_type=envelope.cmd_type, equip_no=envelope.equip_no, radix=self.key_radix, ) return json.loads(reply) def _socket_roundtrip(self, framed: bytes, timeout: Optional[float] = None) -> bytes: timeout = timeout if timeout is not None else self.timeout with socket.create_connection( (self.socket_host, self.socket_port), timeout=timeout ) as sock: sock.sendall(framed) decoder = SocketFrameDecoder() while True: chunk = sock.recv(65536) if not chunk: break frames = decoder.feed(chunk) if frames: return frames[0] raise ConnectionError( f"no reply frame from {self.socket_host}:{self.socket_port}" ) # ------------------------------------------------------- convenience RPCs def query_device_info(self) -> Dict[str, Any]: return self.send_rpc(CommunicationCmd.QUERY_DEVICE_INFO) def query_bms_info(self) -> Dict[str, Any]: return self.send_rpc(CommunicationCmd.BMS) 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)