From e04112ac0b7db9e01ea6bb9256a24bf90d0822b2 Mon Sep 17 00:00:00 2001 From: Rudy Date: Mon, 27 Jul 2026 00:26:07 +0800 Subject: [PATCH 1/3] Fix Telnet file reading --- custom_components/aqara_gateway/core/shell.py | 160 +++++++++++++----- 1 file changed, 118 insertions(+), 42 deletions(-) diff --git a/custom_components/aqara_gateway/core/shell.py b/custom_components/aqara_gateway/core/shell.py index cbb71965..c63951cc 100755 --- a/custom_components/aqara_gateway/core/shell.py +++ b/custom_components/aqara_gateway/core/shell.py @@ -1,4 +1,5 @@ -""" Telnet Shell """ +"""Telnet Shell""" + # pylint: disable=line-too-long import time import base64 @@ -10,32 +11,37 @@ SIGMASTAR_MODELS, MD5_MOSQUITTO_NEW_ARMV7L, MD5_MOSQUITTO_G2HPRO_ARMV7L, - MD5_MOSQUITTO_MIPSEL + MD5_MOSQUITTO_MIPSEL, ) -WGET = "(wget http://master.dl.sourceforge.net/project/aqarahub/{0}?viasf=1 " \ - "-O /data/bin/{1} && chmod +x /data/bin/{1})" +WGET = ( + "(wget http://master.dl.sourceforge.net/project/aqarahub/{0}?viasf=1 " + "-O /data/bin/{1} && chmod +x /data/bin/{1})" +) CHECK_SOCAT = "(md5sum /data/socat | grep 92b77e1a93c4f4377b4b751a5390d979)" -DOWNLOAD_SOCAT = "(wget -O /data/socat http://pkg.simple-ha.ru/mipsel/socat && chmod +x /data/socat)" +DOWNLOAD_SOCAT = ( + "(wget -O /data/socat http://pkg.simple-ha.ru/mipsel/socat && chmod +x /data/socat)" +) RUN_SOCAT_BT_IRDA = "/data/socat tcp-l:8888,reuseaddr,fork /dev/ttyS2" RUN_SOCAT_ZIGBEE = "/data/socat tcp-l:8888,reuseaddr,fork /dev/ttyS1" class TelnetShell(Telnet): - """ Telnet Shell """ + """Telnet Shell""" + _aqara_property = False _suffix = "# " # pylint: disable=unsubscriptable-object def __init__(self, host: str, password=""): - """ init function """ + """init function""" super().__init__(host, timeout=5) self._host = host self._password = password def login(self): - """ login function """ + """login function""" self.write(b"\n") self.read_until(b"login: ", timeout=10) @@ -47,7 +53,7 @@ def login(self): self.write(b"\n") self.read_until(b" # ", timeout=2) -# self.run_command("export PS1='# '") + # self.run_command("export PS1='# '") def run_command(self, command: str, as_bytes=False) -> Union[str, bytes]: """Run command and return it result.""" @@ -57,7 +63,7 @@ def run_command(self, command: str, as_bytes=False) -> Union[str, bytes]: suffix = "\r\n{}".format(self._suffix) raw = self.read_until(suffix.encode(), timeout=15) except Exception: - raw = b'' + raw = b"" return raw if as_bytes else raw.decode() def check_bin(self, filename: str, md5: str, url=None) -> bool: @@ -79,44 +85,116 @@ def run_basis_cli(self, command: str, as_bytes=False) -> Union[str, bytes]: self.read_until(self._suffix.encode()) def file_exist(self, filename: str) -> bool: - """ check file exit """ + """check file exit""" raw = self.run_command("ls -al {}".format(filename)) if "No such" not in str(raw): return True return False def run_public_mosquitto(self, model): - """ run mosquitto as public """ + """run mosquitto as public""" if not self.file_exist("/data/bin/mosquitto"): command = "mkdir -p /data/bin" self.write(command.encode() + b"\n") - if model in ('lumi.camera.agl001'): - self.check_bin('mosquitto', MD5_MOSQUITTO_G2HPRO_ARMV7L , 'bin/armv7l/mosquitto_g2hpro') + if model in ("lumi.camera.agl001"): + self.check_bin( + "mosquitto", + MD5_MOSQUITTO_G2HPRO_ARMV7L, + "bin/armv7l/mosquitto_g2hpro", + ) elif model in SIGMASTAR_MODELS: - self.check_bin('mosquitto', MD5_MOSQUITTO_NEW_ARMV7L , 'bin/armv7l/mosquitto_new') + self.check_bin( + "mosquitto", MD5_MOSQUITTO_NEW_ARMV7L, "bin/armv7l/mosquitto_new" + ) else: - self.check_bin('mosquitto', MD5_MOSQUITTO_MIPSEL, 'bin/mipsel/mosquitto') + self.check_bin( + "mosquitto", MD5_MOSQUITTO_MIPSEL, "bin/mipsel/mosquitto" + ) self.run_command("killall mosquitto") self.run_command("sleep .1") self.run_command("/data/bin/mosquitto -d") def check_public_mosquitto(self) -> bool: - """ get processes list """ + """get processes list""" raw = self.run_command("mosquitto") if 'Binding listener to interface ""' in raw: return True - if 'Binding listener to interface ' not in raw: + if "Binding listener to interface " not in raw: return True return False def get_running_ps(self, ps=None) -> str: - """ get processes list """ + """get processes list""" if isinstance(ps, str): return self.run_command(f"ps | grep {ps}") return self.run_command("ps") - def read_file(self, filename: str, as_base64=False, with_newline=True): - """ read file content """ + def read_file( + self, + filename: str, + as_base64: bool = False, + with_newline: bool = True, + ): + """Read complete file content from the gateway.""" + + try: + if as_base64: + command = f"cat {filename} | base64" + else: + command = f"cat {filename}" + + # run_command already performs exactly one read until the shell prompt. + raw = self.run_command(command, as_bytes=False) + + if not raw: + raise RuntimeError(f"Gateway returned no data for {filename}") + + # Normalize Telnet line endings. + raw = raw.replace("\r\n", "\n").replace("\r", "\n") + + # Remove the trailing shell prompt. + suffix = self._suffix.strip() + + lines = raw.splitlines() + + while lines and lines[-1].strip() == suffix: + lines.pop() + + # Remove a possible echoed command. + if lines and lines[0].strip() == command: + lines.pop(0) + + raw = "\n".join(lines).strip() + + if not raw: + raise RuntimeError( + f"Gateway returned empty file content for {filename}" + ) + + if as_base64: + encoded = "".join(raw.splitlines()) + + try: + return base64.b64decode(encoded) + except Exception as exc: + raise RuntimeError( + f"Invalid base64 content from {filename}: {exc}" + ) from exc + + if with_newline: + return raw + + # JSON supports whitespace, but the existing caller requests a + # single-line value. Join lines rather than reading Telnet twice. + return "".join(raw.splitlines()) + + except Exception as exc: + raise RuntimeError( + f"Failed reading gateway file {filename}: {exc}" + ) from exc + + def read_file_backup(self, filename: str, as_base64=False, with_newline=True): + """read file content""" # pylint: disable=broad-except try: if as_base64: @@ -135,10 +213,10 @@ def read_file(self, filename: str, as_base64=False, with_newline=True): ret = "".join(ret.rsplit(self._suffix, 1)) return ret.strip("\n") except Exception: - return '' + return "" def get_prop(self, property_value: str): - """ get property """ + """get property""" # pylint: disable=broad-except try: if self._aqara_property: @@ -152,10 +230,10 @@ def get_prop(self, property_value: str): ret = ret.replace(self._suffix, "", 2) return ret.replace("\r", "").replace("\n", "") except Exception: - return '' + return "" def set_prop(self, property_value: str, value: str): - """ set property """ + """set property""" if self._aqara_property: command = "asetprop {} {}\n".format(property_value, value) else: @@ -165,19 +243,19 @@ def set_prop(self, property_value: str, value: str): self.read_until(self._suffix.encode()) def get_version(self): - """ get gateway version """ + """get gateway version""" return self.get_prop("ro.sys.fw_ver") def set_audio_volume(self, value): - """ set gateway audio volume """ + """set gateway audio volume""" if value > 100: value = 100 command = "-sys -v {}".format(value) raw = self.run_basis_cli(command) - return raw[raw.find(">>>") + 4:] + return raw[raw.find(">>>") + 4 :] def get_token(self): - """ get gateway token """ + """get gateway token""" filename = "/data/miio/device.token" if self.file_exist(filename): return self.read_file(filename).rstrip().encode().hex() @@ -189,7 +267,7 @@ def get_model(self): suffix = ":" raw = self.read_until(suffix.encode(), timeout=15) except Exception: - raw = b'' + raw = b"" model = raw.decode() models = { "G2HPro": "g2h pro", @@ -200,7 +278,7 @@ def get_model(self): "M1S": "m1s gen2", "V1": "v1", "M200": "m200", - "M100": "m100" + "M100": "m100", } if len(model) >= 1: for key, value in models.items(): @@ -208,25 +286,25 @@ def get_model(self): return value return "m2 2022" -class TelnetShellG2H(TelnetShell): +class TelnetShellG2H(TelnetShell): def login(self): - """ login function """ + """login function""" self._aqara_property = True self.write(b"\n") self.read_until(b"login: ", timeout=10) password = self._password - if ((self._password is None) or - (isinstance(self._password, str) and len(self._password) <= 1) + if (self._password is None) or ( + isinstance(self._password, str) and len(self._password) <= 1 ): - password = '\n' + password = "\n" self.write(b"root\n\r") if password: self.read_until(b"Password: ", timeout=3) - #self.write(password.encode() + b"\n") + # self.write(password.encode() + b"\n") self.run_command(password) self.run_command("stty -echo") @@ -235,9 +313,8 @@ def login(self): class TelnetShellE1(TelnetShell): - def login(self): - """ login function """ + """login function""" self._aqara_property = True self.write(b"\n") @@ -258,7 +335,7 @@ class TelnetShellG3(TelnetShell): _suffix = "~ # " def login(self): - """ login function """ + """login function""" self._aqara_property = True self.write(b"\n") @@ -280,7 +357,7 @@ class TelnetShellM2POE(TelnetShell): _suffix = "/ # " def login(self): - """ login function """ + """login function""" self._aqara_property = True self.write(b"\n") @@ -294,4 +371,3 @@ def login(self): self.read_until(b"/ # ", timeout=10) self.run_command("stty -echo") self.read_until(self._suffix.encode(), timeout=10) - From e062ba1dddd936d9773457c5f79f197790aa0e41 Mon Sep 17 00:00:00 2001 From: rudy Date: Mon, 27 Jul 2026 08:55:46 +0800 Subject: [PATCH 2/3] Fix M1S Telnet device discovery --- .../aqara_gateway/core/gateway.py | 55 +++++++++++++++++-- custom_components/aqara_gateway/core/shell.py | 50 +++++------------ 2 files changed, 63 insertions(+), 42 deletions(-) diff --git a/custom_components/aqara_gateway/core/gateway.py b/custom_components/aqara_gateway/core/gateway.py index 60125a0d..35d4de84 100755 --- a/custom_components/aqara_gateway/core/gateway.py +++ b/custom_components/aqara_gateway/core/gateway.py @@ -43,6 +43,42 @@ _LOGGER = logging.getLogger(__name__) +def _parse_gateway_json(raw, filename: str): + """Extract and parse JSON returned through the gateway Telnet shell.""" + + if isinstance(raw, bytes): + raw = raw.decode("utf-8", errors="replace") + + raw = str(raw).replace("\r", "").strip() + + if not raw: + raise ValueError("Empty response while reading {}".format(filename)) + + # Telnet may include an echoed command, prompt, or other shell output. + json_start = raw.find("{") + json_end = raw.rfind("}") + + if json_start == -1 or json_end == -1 or json_end < json_start: + raise ValueError( + "No complete JSON object found in {}; raw={!r}".format( + filename, + raw[:300] + ) + ) + + json_text = raw[json_start:json_end + 1] + + try: + return json.loads(json_text) + except json.JSONDecodeError as exc: + raise ValueError( + "Invalid JSON from {}: {}; json_start={!r}".format( + filename, + exc, + json_text[:300] + ) + ) from exc + class Gateway: # pylint: disable=too-many-instance-attributes, unused-argument """ Aqara Gateway """ @@ -228,6 +264,9 @@ def _get_shell(self, device_name: str) -> TelnetShell: elif "e1" in device_name: shell = TelnetShellE1(self.host, self.options.get(CONF_PASSWORD, '')) + elif "m1s" in device_name: + shell = TelnetShell(self.host, + self.options.get(CONF_PASSWORD, '')) elif (("m2 2022" in device_name) or ("m1s 2022" in device_name) or ("m3" in device_name) or ("m1s gen2" in device_name) or ("v1" in device_name)): @@ -303,7 +342,8 @@ def _get_devices(self, shell): did = device_conf.get('did', '') model = device_conf.get('model', '') if len(zb_coordinator) >= 1: - raw = shell.read_file(zb_coordinator, with_newline=False) + coordinator_path = zb_coordinator + raw = shell.read_file(coordinator_path, with_newline=False) data = re.search(r"\[persist\.sys\.did\]: \[([a-zA-Z0-9.-]+)\]", prop_raw) did = data.group(1) if data else shell.get_prop("persist.sys.did") data = re.search(r"\[persist\.sys\.model\]: \[([a-zA-Z0-9.-]+)\]", prop_raw) @@ -313,8 +353,9 @@ def _get_devices(self, shell): 'lumi.camera.gwpagl01', 'lumi.camera.agl001', 'lumi.camera.acn003', 'lumi.camera.acn008', 'lumi.camera.acn009', 'lumi.camera.acn010', 'lumi.camera.acn011', 'lumi.gateway.agl011']): + coordinator_path = '/data/zigbee/coordinator.info' raw = shell.read_file( - '/data/zigbee/coordinator.info', with_newline=False) + coordinator_path, with_newline=False) data = re.search(r"\[persist\.sys\.did\]: \[([a-zA-Z0-9.-]+)\]", prop_raw) did = data.group(1) if data else shell.get_prop("persist.sys.did") data = re.search(r"\[persist\.sys\.model\]: \[([a-zA-Z0-9.-]+)\]", prop_raw) @@ -332,8 +373,9 @@ def _get_devices(self, shell): version_g2h = data.group(1) if data else '' data = re.search(r"ro.sys.build_num=([0-9]+).+", raw) build_num_g2h = data.group(1) if data else '' - raw = shell.read_file( - '/mnt/config/zigbee/coordinator.info', with_newline=False) + coordinator_path = '/mnt/config/zigbee/coordinator.info' + raw = shell.read_file( + coordinator_path, with_newline=False) else: raw = shell.read_file( '/data/zigbee/coordinator.info', with_newline=False) @@ -341,7 +383,7 @@ def _get_devices(self, shell): did = data.group(1) if data else shell.get_prop("persist.sys.did") data = re.search(r"\[ro\.sys\.model\]: \[([a-zA-Z0-9.-]+)\]", prop_raw) model = data.group(1) if data else shell.get_prop("ro.sys.model") - value = json.loads(raw) + value = _parse_gateway_json(raw, coordinator_path) devices = [{ 'coordinator': 'lumi.0', 'did': did, @@ -391,6 +433,7 @@ def _get_devices(self, shell): data = re.search(r"\[sys\.zb_device\]: \[([a-zA-Z0-9.-]+)\]", prop_raw) zb_device = data.group(1) if data else shell.get_prop("sys.zb_device") if len(zb_device) >= 1: + device_info_path = zb_device raw = shell.read_file(zb_device, with_newline=False) else: device_info_path = '{}/zigbee/device.info'.format(Utils.get_info_store_path(self._model)) @@ -398,7 +441,7 @@ def _get_devices(self, shell): device_info_path = '/mnt/config/zigbee/device.info' raw = shell.read_file(device_info_path) - value = json.loads(raw) + value = _parse_gateway_json(raw, device_info_path) dev_info = value.get("devInfo", 'null') or [] if not Utils.gateway_is_aiot_only(model): self.cloud = shell.get_prop("persist.sys.cloud") diff --git a/custom_components/aqara_gateway/core/shell.py b/custom_components/aqara_gateway/core/shell.py index c63951cc..b0c683f2 100755 --- a/custom_components/aqara_gateway/core/shell.py +++ b/custom_components/aqara_gateway/core/shell.py @@ -129,38 +129,26 @@ def get_running_ps(self, ps=None) -> str: return self.run_command(f"ps | grep {ps}") return self.run_command("ps") - def read_file( - self, - filename: str, - as_base64: bool = False, - with_newline: bool = True, - ): + def read_file(self, filename: str, as_base64: bool = False, with_newline: bool = True): """Read complete file content from the gateway.""" try: - if as_base64: - command = f"cat {filename} | base64" - else: - command = f"cat {filename}" - - # run_command already performs exactly one read until the shell prompt. - raw = self.run_command(command, as_bytes=False) + command = "cat {} | base64".format(filename) if as_base64 \ + else "cat {}".format(filename) + raw = self.run_command(command) if not raw: - raise RuntimeError(f"Gateway returned no data for {filename}") + raise RuntimeError( + "Gateway returned no data for {}".format(filename) + ) - # Normalize Telnet line endings. raw = raw.replace("\r\n", "\n").replace("\r", "\n") - - # Remove the trailing shell prompt. - suffix = self._suffix.strip() - lines = raw.splitlines() + suffix = self._suffix.strip() while lines and lines[-1].strip() == suffix: lines.pop() - # Remove a possible echoed command. if lines and lines[0].strip() == command: lines.pop(0) @@ -168,29 +156,19 @@ def read_file( if not raw: raise RuntimeError( - f"Gateway returned empty file content for {filename}" + "Gateway returned empty file content for {}".format( + filename + ) ) if as_base64: - encoded = "".join(raw.splitlines()) - - try: - return base64.b64decode(encoded) - except Exception as exc: - raise RuntimeError( - f"Invalid base64 content from {filename}: {exc}" - ) from exc - - if with_newline: - return raw + return base64.b64decode("".join(raw.splitlines())) - # JSON supports whitespace, but the existing caller requests a - # single-line value. Join lines rather than reading Telnet twice. - return "".join(raw.splitlines()) + return raw if with_newline else "".join(raw.splitlines()) except Exception as exc: raise RuntimeError( - f"Failed reading gateway file {filename}: {exc}" + "Failed reading gateway file {}: {}".format(filename, exc) ) from exc def read_file_backup(self, filename: str, as_base64=False, with_newline=True): From 2aeea2c622e7747c104d18433ee92c0594fbc987 Mon Sep 17 00:00:00 2001 From: rudy Date: Mon, 27 Jul 2026 08:59:45 +0800 Subject: [PATCH 3/3] fix indent --- custom_components/aqara_gateway/core/gateway.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/custom_components/aqara_gateway/core/gateway.py b/custom_components/aqara_gateway/core/gateway.py index 35d4de84..b9dbafd5 100755 --- a/custom_components/aqara_gateway/core/gateway.py +++ b/custom_components/aqara_gateway/core/gateway.py @@ -373,9 +373,9 @@ def _get_devices(self, shell): version_g2h = data.group(1) if data else '' data = re.search(r"ro.sys.build_num=([0-9]+).+", raw) build_num_g2h = data.group(1) if data else '' - coordinator_path = '/mnt/config/zigbee/coordinator.info' - raw = shell.read_file( - coordinator_path, with_newline=False) + coordinator_path = '/mnt/config/zigbee/coordinator.info' + raw = shell.read_file( + coordinator_path, with_newline=False) else: raw = shell.read_file( '/data/zigbee/coordinator.info', with_newline=False)