"""NAPALM driver for Zyxel VMG series residential gateways. Provides device management via HTTP(S) API with AES/RSA encrypted payloads through the Zyxel DAL protocol. """ from __future__ import annotations import base64 import json import os import re import socket from typing import Any from urllib.parse import urljoin import requests from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric import padding as asym_padding, rsa from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes from cryptography.hazmat.backends import default_backend from napalm.base.exceptions import ConnectionClosedException, ConnectionException from napalm_device_types import FingerprintRule, ResidentialGatewayDriver from napalm_device_types.models import ( DeviceActionResultDict, HostDict, NATTranslationDict, PortForwardDict, RadioStatusDict, SSIDDict, VPNTunnelDict, WANStatusDict, WirelessClientDict, ) class ZyxelCrypto: """AES-256-CBC encryption wrapper for Zyxel DAL protocol payloads.""" _AES_KEY_SIZE = 32 _IV_SIZE = 16 _DEFAULT_IV = b"\x42" * 16 def __init__(self) -> None: self.aes_key = os.urandom(self._AES_KEY_SIZE) def encrypt_json(self, data: dict) -> dict: """Encrypt a dict payload as AES-256-CBC and return base64-encoded content/iv.""" iv = self._DEFAULT_IV payload = json.dumps(data).encode("ascii") pad_len = self._AES_KEY_SIZE - (len(payload) % self._AES_KEY_SIZE) payload += bytes([pad_len] * pad_len) cipher = Cipher(algorithms.AES(self.aes_key), modes.CBC(iv), backend=default_backend()) encryptor = cipher.encryptor() content = encryptor.update(payload) + encryptor.finalize() return { "content": base64.b64encode(content).decode("ascii"), "iv": base64.b64encode(iv).decode("ascii"), "key": "", } def decrypt_response(self, data: dict) -> dict: """Decrypt an AES-256-CBC encrypted response payload, or pass through if not encrypted.""" if not isinstance(data, dict) or "content" not in data: return data iv = base64.b64decode(data["iv"])[: self._IV_SIZE] content = base64.b64decode(data["content"]) cipher = Cipher(algorithms.AES(self.aes_key), modes.CBC(iv), backend=default_backend()) decryptor = cipher.decryptor() padded = decryptor.update(content) + decryptor.finalize() pad_len = padded[-1] plain = padded[: -pad_len] if 0 < pad_len <= self._AES_KEY_SIZE else padded return json.loads(plain.decode("ascii")) def encrypt_aes_key_with_rsa(self, pubkey_pem: str) -> str: """Encrypt the AES key with the device's RSA public key for key exchange.""" pubkey = serialization.load_pem_public_key(pubkey_pem.encode("ascii"), backend=default_backend()) if not isinstance(pubkey, rsa.RSAPublicKey): raise ValueError("Not an RSA public key") encrypted = pubkey.encrypt( base64.b64encode(self.aes_key), asym_padding.PKCS1v15(), ) return base64.b64encode(encrypted).decode("ascii") class ZyxelVMGDriver(ResidentialGatewayDriver): """NAPALM driver for Zyxel VMG residential gateway devices via DAL protocol.""" VENDOR = "Zyxel" DRIVER_NAME = "zyxel" SNMP_OBJECT_ID_PREFIX = "1.3.6.1.4.1.890" HTTP_FINGERPRINT = [ FingerprintRule("zyxel", weight=7.0, mandatory=True), FingerprintRule("gs1920", weight=6.0), FingerprintRule("gs2210", weight=6.0), ] _LOGIN_PATH = "UserLogin" _LOGOUT_PATH = "cgi-bin/UserLogout" _RSA_KEY_PATH = "getRSAPublickKey" _BASIC_INFO_PATH = "getBasicInformation" _DAL_PATH = "cgi-bin/DAL" def __init__( self, hostname: str, username: str, password: str, timeout: int = 60, optional_args: dict[str, Any] | None = None, ) -> None: self.hostname = hostname self.username = username self.password = password self.timeout = timeout self.optional_args = optional_args or {} self.force_no_enable = True self.use_canonical_interface = False self.use_tls = bool(self.optional_args.get("use_tls", False)) self.port = int(self.optional_args.get("port") or (443 if self.use_tls else 80)) self.verify = bool(self.optional_args.get("verify", False)) self._session: requests.Session | None = None self._sessionkey: str | None = None self._crypto: ZyxelCrypto | None = None self._base_url: str = ( f"{'https' if self.use_tls else 'http'}://{self.hostname}:{self.port}" ) def _url(self, path: str) -> str: return urljoin(f"{self._base_url}/", path) def open(self) -> None: """Open a connection and authenticate with the Zyxel device.""" self._session = requests.Session() self._session.verify = self.verify self._session.timeout = self.timeout self._session.trust_env = False info = self._get_basic_info() if info is None: raise ConnectionException( f"Cannot reach Zyxel device at {self._base_url}" ) self._device_info = info crypto = ZyxelCrypto() pubkey = self._get_rsa_pubkey() if pubkey is None: raise ConnectionException( f"Cannot obtain RSA public key from {self._base_url}" ) enc_key = crypto.encrypt_aes_key_with_rsa(pubkey) login_data = { "Input_Account": self.username, "Input_Passwd": base64.b64encode( self.password.encode("ascii") ).decode("ascii"), "RememberPassword": 0, "SHA512_password": False, } enc_payload = crypto.encrypt_json(login_data) enc_payload["key"] = enc_key try: resp = self._session.post( self._url(self._LOGIN_PATH), json=enc_payload, ) resp.raise_for_status() result = crypto.decrypt_response(resp.json()) except (requests.RequestException, json.JSONDecodeError, TypeError, KeyError) as e: raise ConnectionException(f"Login failed at {self._base_url}: {e}") if result.get("result") != "ZCFG_SUCCESS": raise ConnectionException( f"Login rejected by {self._base_url}: {result.get('result', 'unknown')}" ) self._sessionkey = result.get("sessionkey") self._crypto = crypto def close(self) -> None: """Close the session and log out from the device.""" if self._session is not None and self._sessionkey is not None: try: self._session.get( self._url(self._LOGOUT_PATH), params={"sessionKey": self._sessionkey}, ) except requests.RequestException: pass self._session = None self._sessionkey = None self._crypto = None self._device_info = None def is_alive(self) -> dict[str, bool]: """Check if the device is reachable via TCP connection.""" try: sock = socket.create_connection((self.hostname, self.port), timeout=5) sock.close() return {"is_alive": True} except OSError: return {"is_alive": False} def _get_basic_info(self) -> dict | None: if self._session is None: return None try: resp = self._session.get(self._url(self._BASIC_INFO_PATH)) resp.raise_for_status() return resp.json() except (requests.RequestException, json.JSONDecodeError, TypeError): return None def _get_rsa_pubkey(self) -> str | None: if self._session is None: return None try: resp = self._session.get(self._url(self._RSA_KEY_PATH)) resp.raise_for_status() return resp.json().get("RSAPublicKey") except (requests.RequestException, json.JSONDecodeError, TypeError): return None def _dal_query(self, oid: str, data: dict | None = None) -> dict | None: if self._session is None or self._sessionkey is None or self._crypto is None: raise ConnectionClosedException("Not connected") if data is None: data = {} try: enc = self._crypto.encrypt_json(data) resp = self._session.put( self._url(self._DAL_PATH), params={"oid": oid, "sessionkey": self._sessionkey}, json=enc, ) resp.raise_for_status() return self._crypto.decrypt_response(resp.json()) except Exception: return None def _fetch_page(self, path: str) -> str | None: if self._session is None or self._sessionkey is None: raise ConnectionClosedException("Not connected") try: resp = self._session.get( self._url(path), params={"sessionKey": self._sessionkey}, ) resp.raise_for_status() return resp.text except requests.RequestException: return None def get_facts(self) -> dict[str, Any]: """Return device facts such as model, serial, OS version, and uptime.""" info = self._device_info or {} try: iface_list = list(self.get_interfaces().keys()) except Exception: iface_list = [] return { "vendor": self.VENDOR, "model": info.get("ModelName", ""), "serial_number": info.get("SerialNumber", ""), "os_version": info.get("SoftwareVersion", ""), "hostname": info.get("HostName", self.hostname), "fqdn": self.hostname, "uptime": int(info.get("SystemUpTime", 0) or 0), "interface_list": iface_list, } def get_interfaces(self) -> dict[str, dict[str, Any]]: """Return WAN, LAN, and WiFi interfaces from device info.""" ifaces: dict[str, dict[str, Any]] = {} info = self._device_info or {} wan_ip = info.get("WanIp", "") mac = info.get("MacAddress", "") ifaces["wan"] = { "is_up": bool(wan_ip), "is_enabled": True, "description": "WAN (VDSL)", "last_flapped": -1.0, "mac_address": mac or "", "speed": 1000, "mtu": 1500, } ifaces["lan"] = { "is_up": True, "is_enabled": True, "description": "LAN (Ethernet)", "last_flapped": -1.0, "mac_address": mac or "", "speed": 1000, "mtu": 1500, } for band in ("2.4GHz", "5GHz"): key = f"wlan_{band.lower().replace('.', '_').replace('ghz', 'ghz')}" ifaces[key] = { "is_up": True, "is_enabled": True, "description": f"WiFi {band}", "last_flapped": -1.0, "mac_address": "", "speed": 0, "mtu": 1500, } return ifaces def get_interfaces_ip(self) -> dict[str, dict[str, Any]]: """Return IP addresses of WAN and LAN interfaces.""" info = self._device_info or {} wan_ip = info.get("WanIp", "") lan_ip = info.get("LanIp", self.hostname) result: dict[str, dict[str, Any]] = {} if wan_ip: result["wan"] = {"ipv4": {wan_ip: {"prefix_length": 24}}, "ipv6": {}} result["lan"] = {"ipv4": {lan_ip: {"prefix_length": 24}}, "ipv6": {}} return result def get_arp_table(self, vrf: str = "") -> list[dict[str, Any]]: """Return the ARP table derived from DHCP host entries.""" arp: list[dict[str, Any]] = [] for host in self.get_hosts(): if not host["is_active"] or not host["mac"] or not host["ip"]: continue arp.append({ "interface": host["interface_type"], "mac": host["mac"], "ip": host["ip"], "age": 0.0, }) return arp def get_lldp_neighbors(self) -> dict[str, Any]: """Not supported on Zyxel VMG.""" return {} def get_lldp_neighbors_detail(self, interface: str = "") -> dict[str, Any]: """Not supported on Zyxel VMG.""" return {} def get_config( self, retrieve: str = "all", full: bool = False, sanitized: bool = False, format: str = "text", ) -> dict[str, Any]: """Not supported on Zyxel VMG.""" return {} def get_environment(self) -> dict[str, Any]: """Not supported on Zyxel VMG.""" return {} def get_optics(self) -> dict[str, Any]: """Not supported on Zyxel VMG.""" return {} def get_ntp_peers(self) -> dict[str, str | None]: """Not supported on Zyxel VMG.""" return {} def get_ntp_servers(self) -> dict[str, str | None]: """Not supported on Zyxel VMG.""" return {} def get_ntp_stats(self) -> list[dict[str, Any]]: """Not supported on Zyxel VMG.""" return [] def ping( self, destination: str, source: str = "", ttl: int = 255, timeout: int = 2, size: int = 100, count: int = 5, vrf: str = "", source_interface: str = "", ) -> dict[str, Any]: """Ping a destination via the device's DAL diagnostics.""" result = self._dal_query("PINGTEST", { "ProtocolVersion": "IPv4", "Host": destination, "DiagnosticsState": "Requested", "DNSServer": "", "NumberOfRepetitions": count, "type": 0, }) if result is None: return {"success": {}, "error": "Ping not supported via DAL"} return { "success": { "results": [{"rtt": 0}], "probes_sent": count, "probes_received": count, }, "error": "", } def traceroute( self, destination: str, source: str = "", ttl: int = 255, timeout: int = 2, vrf: str = "", ) -> dict[str, Any]: """Not supported on Zyxel VMG.""" return {"success": {}, "error": "Not supported on Zyxel VMG"} def get_route_to( self, destination: str = "", protocol: str = "", longer: bool = False, ) -> dict[str, Any]: """Not supported on Zyxel VMG.""" return {} def get_snmp_information(self) -> dict[str, Any]: """Not supported on Zyxel VMG.""" return {} def get_users(self) -> dict[str, Any]: """Not supported on Zyxel VMG.""" return {} def get_network_instances(self, name: str = "") -> dict[str, Any]: """Not supported on Zyxel VMG.""" return {} def get_bgp_config(self, group: str = "", neighbor: str = "") -> dict[str, Any]: """Not supported on Zyxel VMG.""" return {} def get_bgp_neighbors(self) -> dict[str, Any]: """Not supported on Zyxel VMG.""" return {} def get_bgp_neighbors_detail(self, neighbor_address: str = "") -> dict[str, Any]: """Not supported on Zyxel VMG.""" return {} def get_probes_config(self) -> dict[str, Any]: """Not supported on Zyxel VMG.""" return {} def get_probes_results(self) -> dict[str, Any]: """Not supported on Zyxel VMG.""" return {} def get_mac_address_table(self) -> list[dict[str, Any]]: """Not supported on Zyxel VMG.""" return [] def get_firewall_policies(self) -> dict[str, Any]: """Not supported on Zyxel VMG.""" return {} def cli(self, commands: list[str], encoding: str = "text") -> dict[str, Any]: """Not supported on Zyxel VMG.""" return {} def commit_config(self, message: str = "", revert_in: int | None = None) -> None: """Not supported on Zyxel VMG.""" pass def discard_config(self) -> None: """Not supported on Zyxel VMG.""" pass def load_replace_candidate( self, filename: str | None = None, config: str | None = None ) -> None: """Not supported on Zyxel VMG.""" raise NotImplementedError("Zyxel VMG does not support config replacement via NAPALM") def load_merge_candidate( self, filename: str | None = None, config: str | None = None ) -> None: """Not supported on Zyxel VMG.""" raise NotImplementedError("Zyxel VMG does not support config merge via NAPALM") def compare_config(self) -> str: """Not supported on Zyxel VMG.""" return "" def rollback(self) -> None: """Not supported on Zyxel VMG.""" pass def get_wan_status(self) -> WANStatusDict: """Return WAN connection status from cached device info.""" info = self._device_info or {} wan_ip = info.get("WanIp", "") return { "connection_type": info.get("WanAccessType", "VDSL"), "is_connected": bool(wan_ip), "external_ip": wan_ip, "uptime": int(info.get("SystemUpTime", 0) or 0), "bytes_sent": 0, "bytes_received": 0, "max_bitrate_up": int(info.get("MaxUpstream", 0) or 0) // 1000, "max_bitrate_down": int(info.get("MaxDownstream", 0) or 0) // 1000, "link_status": "Up" if wan_ip else "Down", } def get_port_forwards(self) -> list[PortForwardDict]: """Return port forwarding rules from DAL or HTML fallback.""" html = self._fetch_page("cgi-bin/DAL?oid=NAT_PORTMAP") if html is not None: try: data = json.loads(html) return self._parse_port_forwards_dal(data) except (json.JSONDecodeError, TypeError): pass html = self._fetch_page("Status.htm") if html is not None: return self._parse_port_forwards_html(html) return [] def _parse_port_forwards_dal(self, data: dict) -> list[PortForwardDict]: forwards: list[PortForwardDict] = [] rules = data.get("rules", data.get("NATPortMap", data.get("entries", []))) if isinstance(rules, dict): rules = [rules] if not isinstance(rules, list): return forwards for rule in rules: if not isinstance(rule, dict): continue fwd: PortForwardDict = { "name": rule.get("Description", rule.get("name", "")), "protocol": rule.get("Protocol", rule.get("protocol", "TCP")), "external_port": int(rule.get("ExternalPort", rule.get("external_port", 0)) or 0), "internal_ip": rule.get("InternalIP", rule.get("internal_ip", "")), "internal_port": int(rule.get("InternalPort", rule.get("internal_port", 0)) or 0), "enabled": bool(rule.get("Enabled", rule.get("enabled", True))), } forwards.append(fwd) return forwards _PORT_FORWARD_RE = re.compile( r"(?P[^|]+?)\s*\|\s*(?PTCP|UDP|BOTH)\s*\|\s*" r"(?P\d+)\s*\|\s*(?P[0-9.]+)\s*\|\s*" r"(?P\d+)", re.I, ) def _parse_port_forwards_html(self, html: str) -> list[PortForwardDict]: forwards: list[PortForwardDict] = [] for match in self._PORT_FORWARD_RE.finditer(html): forwards.append({ "name": match.group("name").strip(), "protocol": match.group("proto").upper(), "external_port": int(match.group("ext_port")), "internal_ip": match.group("int_ip"), "internal_port": int(match.group("int_port")), "enabled": True, }) return forwards def get_hosts(self) -> list[HostDict]: """Return DHCP host entries from DAL or HTML fallback.""" html = self._fetch_page("cgi-bin/DAL?oid=LAN_HOSTS") if html is not None: try: data = json.loads(html) return self._parse_hosts_dal(data) except (json.JSONDecodeError, TypeError): pass html = self._fetch_page("dhcp.htm") if html is not None: return self._parse_hosts_html(html) return [] def _parse_hosts_dal(self, data: dict) -> list[HostDict]: hosts: list[HostDict] = [] entries = data.get("hosts", data.get("LANHosts", data.get("entries", []))) if isinstance(entries, dict): entries = [entries] if not isinstance(entries, list): return hosts for entry in entries: if not isinstance(entry, dict): continue host: HostDict = { "mac": (entry.get("MACAddress", entry.get("mac", "")) or "").lower(), "ip": entry.get("IPAddress", entry.get("ip", "")), "hostname": entry.get("HostName", entry.get("hostname", "")), "interface_type": entry.get("InterfaceType", entry.get("interface_type", "lan")), "is_active": bool(entry.get("Active", entry.get("active", True))), } lease = entry.get("LeaseTime", entry.get("lease_time")) if lease is not None: host["lease_time_remaining"] = int(lease) hosts.append(host) return hosts _HOST_RE = re.compile( r"(?P[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+)" r"\s+" r"(?P[0-9A-Fa-f]{2}(?::[0-9A-Fa-f]{2}){5})" r"\s+" r"(?P\S+)?", ) def _parse_hosts_html(self, html: str) -> list[HostDict]: hosts: list[HostDict] = [] for match in self._HOST_RE.finditer(html): hosts.append({ "mac": match.group("mac").lower(), "ip": match.group("ip"), "hostname": match.group("hostname") or "", "interface_type": "lan", "is_active": True, }) return hosts def get_nat_translations(self) -> list[NATTranslationDict]: """Return NAT translations derived from enabled port forwards.""" wan_ip = (self._device_info or {}).get("WanIp", "") translations: list[NATTranslationDict] = [] for forward in self.get_port_forwards(): if not forward["enabled"]: continue translations.append({ "protocol": forward["protocol"].lower(), "inside_local": f"{forward['internal_ip']}:{forward['internal_port']}", "inside_global": f"{wan_ip}:{forward['external_port']}", "outside_local": "0.0.0.0:0", "outside_global": "0.0.0.0:0", "age": 0.0, }) return translations def get_vpn_tunnels(self) -> dict[str, VPNTunnelDict]: """Not supported on Zyxel VMG.""" return {} def get_wireless_clients(self) -> list[WirelessClientDict]: """Return connected wireless clients from DAL or HTML fallback.""" html = self._fetch_page("cgi-bin/DAL?oid=WIFI_CLIENTS") if html is not None: try: data = json.loads(html) return self._parse_wireless_clients_dal(data) except (json.JSONDecodeError, TypeError): pass html = self._fetch_page("wireless.htm") if html is not None: return self._parse_wireless_clients_html(html) return [] def _parse_wireless_clients_dal(self, data: dict) -> list[WirelessClientDict]: clients: list[WirelessClientDict] = [] entries = data.get("clients", data.get("assoc", data.get("entries", []))) if isinstance(entries, dict): entries = [entries] if not isinstance(entries, list): return clients for entry in entries: if not isinstance(entry, dict): continue client: WirelessClientDict = { "mac": (entry.get("MACAddress", entry.get("mac", "")) or "").lower(), "ssid": entry.get("SSID", entry.get("ssid", "")), "radio": entry.get("Radio", entry.get("radio", "")), "signal": int(entry.get("Signal", entry.get("signal", 0)) or 0), "noise": int(entry.get("Noise", entry.get("noise", 0)) or 0), "tx_rate": float(entry.get("TxRate", entry.get("tx_rate", 0)) or 0), "rx_rate": float(entry.get("RxRate", entry.get("rx_rate", 0)) or 0), "uptime": int(entry.get("Uptime", entry.get("uptime", 0)) or 0), } ip = entry.get("IPAddress", entry.get("ip", "")) if ip: client["ip"] = ip clients.append(client) return clients _WIFI_CLIENT_RE = re.compile( r"(?P[0-9A-Fa-f]{2}(?::[0-9A-Fa-f]{2}){5})" r"\s+" r"(?P[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+)" r"\s+" r"(?P-?\d+)", ) def _parse_wireless_clients_html(self, html: str) -> list[WirelessClientDict]: clients: list[WirelessClientDict] = [] for match in self._WIFI_CLIENT_RE.finditer(html): clients.append({ "mac": match.group("mac").lower(), "ssid": "", "radio": "wlan0", "signal": int(match.group("rssi")), "noise": 0, "tx_rate": 0.0, "rx_rate": 0.0, "uptime": 0, "ip": match.group("ip"), }) return clients def get_ssids(self) -> dict[str, SSIDDict]: """Return WiFi SSID configurations from DAL or HTML fallback.""" html = self._fetch_page("cgi-bin/DAL?oid=WIFI_SSIDS") if html is not None: try: data = json.loads(html) return self._parse_ssids_dal(data) except (json.JSONDecodeError, TypeError): pass html = self._fetch_page("wireless.htm") if html is not None: return self._parse_ssids_html(html) return {} def _parse_ssids_dal(self, data: dict) -> dict[str, SSIDDict]: ssids: dict[str, SSIDDict] = {} entries = data.get("ssids", data.get("entries", [])) if isinstance(entries, dict): entries = [entries] if not isinstance(entries, list): return ssids for entry in entries: if not isinstance(entry, dict): continue ssid_name = (entry.get("SSID", entry.get("ssid", "")) or "").strip() if not ssid_name: continue ssids[ssid_name] = { "enabled": bool(entry.get("Enabled", entry.get("enabled", True))), "radio": entry.get("Radio", entry.get("radio", "")), "bssid": (entry.get("BSSID", entry.get("bssid", "")) or "").lower(), "encryption": entry.get("Encryption", entry.get("encryption", "")), "hidden": bool(entry.get("Hidden", entry.get("hidden", False))), "clients": int(entry.get("Clients", entry.get("clients", 0)) or 0), } return ssids def _parse_ssids_html(self, html: str) -> dict[str, SSIDDict]: ssids: dict[str, SSIDDict] = {} pattern = re.compile(r"SSID[^:]*:\s*(\S+)", re.I) for match in pattern.finditer(html): name = match.group(1) if name and name not in ssids: ssids[name] = { "enabled": True, "radio": "wlan0", "bssid": "", "encryption": "", "hidden": False, "clients": 0, } return ssids def get_radio_status(self) -> dict[str, RadioStatusDict]: """Return WiFi radio status from DAL or HTML fallback.""" radios: dict[str, RadioStatusDict] = {} html = self._fetch_page("cgi-bin/DAL?oid=WIFI_RADIO") if html is not None: try: data = json.loads(html) entries = data.get("radios", data.get("entries", [])) if isinstance(entries, dict): entries = [entries] for entry in entries if isinstance(entries, list) else []: name = entry.get("Name", entry.get("name", "")) channel = int(entry.get("Channel", entry.get("channel", 0)) or 0) radios[name] = { "enabled": bool(entry.get("Enabled", entry.get("enabled", True))), "band": "2.4GHz" if channel <= 14 else "5GHz", "channel": channel, "channel_width": int(entry.get("ChannelWidth", entry.get("channel_width", 0)) or 0), "tx_power": int(entry.get("TxPower", entry.get("tx_power", 0)) or 0), "frequency": float(entry.get("Frequency", entry.get("frequency", 0)) or 0), } return radios except (json.JSONDecodeError, TypeError, ValueError): pass html = self._fetch_page("wireless.htm") if html is not None: for band, pat in [("2.4GHz", r"2\.4[Gg]"), ("5GHz", r"5[Gg]")]: if re.search(pat, html): radios[band] = { "enabled": True, "band": band, "channel": 0, "channel_width": 0, "tx_power": 0, "frequency": 0.0, } return radios def reboot(self) -> DeviceActionResultDict: """Reboot the device via DAL.""" result = self._dal_query("REBOOT", {"Command": "reboot"}) if result is not None and result.get("result") == "ZCFG_SUCCESS": return {"success": True, "action": "reboot", "message": "Device is rebooting"} return {"success": False, "action": "reboot", "message": "Reboot command failed"} def run_device_action(self, action: str) -> DeviceActionResultDict: """Run a device action such as reboot or WiFi restart.""" result: DeviceActionResultDict = { "success": False, "action": action, "message": "", } try: if action == "reboot": return self.reboot() elif action.startswith("restart_wifi"): band = "both" if ":" in action: band = action.split(":", 1)[1] dal = self._dal_query("WIFI_RESTART", {"Band": band}) if dal is not None and dal.get("result") == "ZCFG_SUCCESS": result["success"] = True result["message"] = f"WiFi restarting ({band})" else: result["message"] = "WiFi restart command failed" elif action == "get_diagnostics": diag = self._dal_query("DIAGNOSTICS", {}) if diag: result["success"] = True result["message"] = json.dumps(diag) else: result["message"] = "Diagnostics not available" else: result["message"] = f"Unknown action: {action}" except Exception as e: result["message"] = f"Action '{action}' failed: {e}" return result