Reconstruct the app's Dart-layer behaviour (blutter over libapp.so, jadx over base.apk — see docs/RE_DART_FINDINGS.md) as working Python, replacing the library stubs with a real local-connect stack. protocol/ (new package, hardware-independent): - commands: CommunicationMode / CommunicationOperate / CommunicationCmd enums with the raw values recovered from the Dart object pool - envelope: CommunicationObject with the app's exact JSON key order - crypto: SN-derived secret key and the position-dependent additive cipher (encrypt/decrypt; radix 16 default, 32 documented alternate) - crc: CRC-16/MODBUS with the app's hi/lo swap + 4-hex string form, CRC32 envelope check, little-endian int16 helpers - framing: BLE frame build/parse (0x5AA5 data / 0x4AA4 ACK magics, CRC slots, MTU chunking) and local-socket int16LE length framing plus a streaming SocketFrameDecoder wifi.py (rewritten): - derive_hotspot_credentials(): the AP_<sn> SSID/password rules (20-char SN branch, full-SN fallback with the app's warning log) - WifiBackend abstraction: NmcliWifiBackend for Linux hosts via nmcli, NoOpWifiBackend for radio-less containers - WifiManager keeps the original stub method names for API compat and adds join_device_hotspot(sn) / leave_device_hotspot() / local_ip() device_local.py (new): LocalDeviceClient for the mode==local path — the /terminal/* HTTP endpoint family (hotspot info, aGate info, overall info, MQTT relay, ...) and direct-connect socket RPC (envelope -> cipher -> length frame -> decrypt -> JSON) with convenience query_* RPCs. docs/RE_DART_FINDINGS.md (new): the full findings — transport selector, wire envelope, crypto, CRCs, MQTT (AWS IoT + Cognito), socket, BLE + ESP provisioning, hotspot credential derivation, local/cloud HTTP layers, and the Java glue — plus the open items; README now links to it. Unconfirmed candidates (local port 18000, cipher key radix, BLE field order, X-Device-* header values) are flagged in the docs and kept configurable in code.
257 lines
9.4 KiB
Python
257 lines
9.4 KiB
Python
"""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)
|