pax_global_header00006660000000000000000000000064145376366570014540gustar00rootroot0000000000000052 comment=84b62f665f7f9a38717384c06203c8f6c786cd7a ollo69-pyasuswrt-84b62f6/000077500000000000000000000000001453763665700153065ustar00rootroot00000000000000ollo69-pyasuswrt-84b62f6/.github/000077500000000000000000000000001453763665700166465ustar00rootroot00000000000000ollo69-pyasuswrt-84b62f6/.github/workflows/000077500000000000000000000000001453763665700207035ustar00rootroot00000000000000ollo69-pyasuswrt-84b62f6/.github/workflows/linting.yaml000066400000000000000000000010421453763665700232300ustar00rootroot00000000000000name: Linting on: push: branches: - master pull_request: branches: '*' jobs: lint: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - name: Setup Python uses: actions/setup-python@v1 with: python-version: 3.9 - name: Install dependencies run: | pip install -r requirements.txt - name: flake8 run: flake8 . - name: isort run: isort --diff --check . - name: Black run: black --line-length 88 --check . ollo69-pyasuswrt-84b62f6/.gitignore000066400000000000000000000056671453763665700173140ustar00rootroot00000000000000# Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .Python build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ wheels/ share/python-wheels/ *.egg-info/ .installed.cfg *.egg MANIFEST # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .nox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *.cover *.py,cover .hypothesis/ .pytest_cache/ cover/ # Translations *.mo *.pot # Django stuff: *.log local_settings.py db.sqlite3 db.sqlite3-journal # Flask stuff: instance/ .webassets-cache # Scrapy stuff: .scrapy # Sphinx documentation docs/_build/ # PyBuilder .pybuilder/ target/ # Jupyter Notebook .ipynb_checkpoints # IPython profile_default/ ipython_config.py # pyenv # For a library or package, you might want to ignore these files since the code is # intended to run in multiple environments; otherwise, check them in: # .python-version # pipenv # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. # However, in case of collaboration, if having platform-specific dependencies or dependencies # having no cross-platform support, pipenv may install dependencies that don't work, or not # install all needed dependencies. #Pipfile.lock # poetry # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. # This is especially recommended for binary packages to ensure reproducibility, and is more # commonly ignored for libraries. # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control #poetry.lock # PEP 582; used by e.g. github.com/David-OConnor/pyflow __pypackages__/ # Celery stuff celerybeat-schedule celerybeat.pid # SageMath parsed files *.sage.py # Environments .env .venv env/ venv/ ENV/ env.bak/ venv.bak/ # Spyder project settings .spyderproject .spyproject # Rope project settings .ropeproject # mkdocs documentation /site # mypy .mypy_cache/ .dmypy.json dmypy.json # Pyre type checker .pyre/ # pytype static type analyzer .pytype/ # Cython debug symbols cython_debug/ # PyCharm # JetBrains specific template is maintainted in a separate JetBrains.gitignore that can # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ .idea/PyAsusWrt.iml .idea/inspectionProfiles/profiles_settings.xml .idea/inspectionProfiles/Project_Default.xml .idea/misc.xml .idea/modules.xml .idea/vcs.xml .idea/workspace.xml .idea/.name # Tests /pyp_instruction.txt /test_local.py ollo69-pyasuswrt-84b62f6/LICENSE000066400000000000000000000020471453763665700163160ustar00rootroot00000000000000MIT License Copyright (c) 2022 ollo69 Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ollo69-pyasuswrt-84b62f6/README.md000066400000000000000000000043231453763665700165670ustar00rootroot00000000000000## PyAsusWrt **PyAsusWrt** is a small API wrapper written in python for communication with ASUSWRT-powered routers using the HTTP or HTTPS protocols. It is based on Asynchronous HTTP Client [AioHTTP](https://docs.aiohttp.org/en/stable/). It was mainly developed to be used with HomeAssistant AsusWRT integration as an alternative to the excellent library currently in use [AIOAsusWrt](https://github.com/kennedyshead/aioasuswrt). The purpose of this library is not to replace AIOAsusWrt (which uses the `SSH` and `Telnet` protocols) but to work alongside it to allow also the use the HTTP(s) protocols, so you can choose the best solution according to your model of router. Of course, you can use this library for any other purpose, respecting the open source license to which this library is licensed. ### Note Pull Request to HA integration is under development and will be available **when and if** it will be approved by HA teams. If you cannot wait for the completion of the PR, it is possible to replace the native HA integration with [this custom integration](https://github.com/ollo69/ha_asuswrt_custom) that already contains support for this new library. This custom integration is based on the native one and is to be considered for test purpose only. ## Installation Installation of the latest release is available from PyPI: ``` pip install pyasuswrt ``` ## How open issue and run tests There are many versions of `asuswrt` firmware, sometimes they just don't work in current implementation. If you have a problem with your specific router open an issue on this repository, but please add as much info as you can and at least: * Model and version of router * Version of Asuswrt If possible before open issue run a test on your environment, using the code inside the module `test.py` (you must set right login credential inside the module before running it) and then provide the error log printed by the test. To run the test: ``` python test.py ``` ## Be nice! If you like the library, why don't you support me by buying me a coffee? It would certainly motivate me to further improve this work. [![Buy me a coffee!](https://www.buymeacoffee.com/assets/img/custom_images/black_img.png)](https://www.buymeacoffee.com/ollo69) ollo69-pyasuswrt-84b62f6/pyasuswrt/000077500000000000000000000000001453763665700173675ustar00rootroot00000000000000ollo69-pyasuswrt-84b62f6/pyasuswrt/__init__.py000066400000000000000000000007771453763665700215130ustar00rootroot00000000000000"""PyAsusWRT Library implementation""" # flake8: noqa from .asuswrt import AsusWrtHttp as AsusWrtHttp from .exceptions import ( AsusWrtClientResponseError as AsusWrtClientResponseError, AsusWrtConnectionError as AsusWrtConnectionError, AsusWrtConnectionTimeoutError as AsusWrtConnectionTimeoutError, AsusWrtError as AsusWrtError, AsusWrtLoginError as AsusWrtLoginError, AsusWrtResponseError as AsusWrtResponseError, AsusWrtValueError as AsusWrtValueError, ) __version__ = "0.1.21" ollo69-pyasuswrt-84b62f6/pyasuswrt/asuswrt.py000066400000000000000000000767621453763665700214730ustar00rootroot00000000000000"""PyAsusWRT Library implementation""" from __future__ import annotations import asyncio import base64 from collections import namedtuple from dataclasses import dataclass from datetime import datetime, timedelta, timezone import logging import math from typing import Any import aiohttp from .exceptions import ( AsusWrtClientResponseError, AsusWrtConnectionError, AsusWrtConnectionTimeoutError, AsusWrtError, AsusWrtLoginError, AsusWrtNotAvailableInfoError, AsusWrtValueError, ) from .helpers import ( _calculate_cpu_usage, _get_json_result, _parse_fw_info, _parse_sysinfo, _parse_temperatures, ) _ASUSWRT_USR_AGENT = "asusrouter-Android-DUTUtil-1.0.0.245" _ASUSWRT_ERROR_KEY = "error_status" _ASUSWRT_ACTION_KEY = "action_mode" _ASUSWRT_HOOK_KEY = "hook" _ASUSWRT_TOKEN_KEY = "asus_token" _ASUSWRT_LOGIN_PATH = "login.cgi" _ASUSWRT_GET_PATH = "appGet.cgi" _ASUSWRT_CMD_PATH = "applyapp.cgi" _ASUSWRT_APPLY_PATH = "apply.cgi" _ASUSWRT_FW_PATH = "detect_firmware.asp" _ASUSWRT_TEMP_PATH = "ajax_coretmp.asp" _ASUSWRT_SYSINFO_PATH = "ajax_sysinfo.asp" _ASUSWRT_SVC_REQ = "rc_service" _ASUSWRT_SVC_REPLY = "run_service" _ASUSWRT_SVC_MODIFY = "modify" _CMD_CLIENT_LIST = "get_clientlist" _CMD_CPU_USAGE = "cpu_usage" _CMD_DHCP_LEASE = "dhcpLeaseMacList" _CMD_MEMORY_USAGE = "memory_usage" _CMD_NET_TRAFFIC = "netdev" _CMD_NVRAM = "nvram_get" _CMD_UPTIME = "uptime" _CMD_WAN_INFO = "wanlink" _CMD_REBOOT = "reboot" _CMD_LED_STATUS = "start_ctrl_led" _CMD_FW_CHECK = "firmware_check" _PARAM_APPOBJ = "appobj" _PROP_MAC_ADDR = "label_mac" _PROP_MODEL = "productid" _PROP_LED_STATUS = "led_val" _NVRAM_INFO = [ "acs_dfs", "model", _PROP_MODEL, _PROP_MAC_ADDR, "buildinfo", "firmver", "firmver_org", "buildno", "buildno_org", "extendno", "extendno_org", "innerver", "apps_sq", "lan_hwaddr", "lan_ipaddr", "lan_proto", "x_Setting", "lan_netmask", "lan_gateway", "http_enable", "https_lanport", "cfg_device_list", "wl0_country_code", "wl1_country_code", "time_zone", "time_zone_dst", "time_zone_x", "time_zone_dstoff", "ntp_server0", ] _CACHE_SPECIFIC_URI = "specific_uri" DEFAULT_TIMEOUT = 5 DEFAULT_HTTP_PORT = 80 DEFAULT_HTTPS_PORT = 8443 FW_CHECK_INTERVAL = 7200 # seconds, means 2 hour UTC = timezone.utc Device = namedtuple("Device", ["mac", "ip", "name", "node", "is_wl"]) _LOGGER = logging.getLogger(__name__) def _nvram_cmd(info_type): """Return the cmd to get nvram data.""" return f"{_CMD_NVRAM}({info_type})" @dataclass() class AsusWrtFirmware: """Represent an AsusWrt firmware.""" version: str | None build: str extend: str | None def to_str(self) -> str | None: """Convert firmware information to a readable string.""" retval = None if self.version: retval = self.version if retval: retval += f".{self.build}" else: retval = self.build if self.extend: retval += f"_{self.extend}" return retval def check_new(self, new_ver: str) -> str | None: """Check if available fw differs from existing.""" chk_ver = None if self.version: chk_ver = self.version.replace(".", "") chk_build = self.build.replace(".", "_") if self.extend: chk_build += f"_{self.extend}" if new_ver == chk_build: return None if chk_ver: if new_ver == f"{chk_ver}_{chk_build}": return None return new_ver class AsusWrtCache: """Class to manage command cache.""" def __init__(self, validity: int): """Initialize the class.""" self._cache: dict = {} self._validity = validity def set_key(self, cache_type: str, key: str, value: Any) -> None: """Add a value in the cache for specific key.""" self._cache.setdefault(cache_type, {})[key] = { "timestamp": datetime.utcnow(), "value": value, } def get_key(self, cache_type: str, key: str) -> Any: """Add a value in the cache for specific key.""" if cache_type not in self._cache: return None if (cache_value := self._cache[cache_type].get(key)) is None: return None diff = datetime.utcnow() - cache_value["timestamp"] if diff.total_seconds() > self._validity: self._cache[cache_type].pop(key) return None return cache_value["value"] class AsusWrtHttp: """Class for AsusWrt router HTTP/HTTPS connection.""" def __init__( self, hostname: str, username: str, password: str, *, use_https: bool = False, port: int | None = None, timeout: int = DEFAULT_TIMEOUT, session: aiohttp.ClientSession | None = None, ): """ Create the router object Parameters: hostname: HostName or IP Address of the router username: Router username password: Password required to log in use_https: if True use https instead of http (default False) port: the tcp port to use (leave None or 0 for protocol default) timeout: the tcp timeout (default = 5 sec.) session: the AioHttp session to use (if None a new session is created) """ self._hostname = hostname self._username = username self._password = password self._protocol = "https" if use_https else "http" if port and port > 0: self._port = port else: self._port = DEFAULT_HTTPS_PORT if use_https else DEFAULT_HTTP_PORT total_timeout = timeout if timeout > 0 else DEFAULT_TIMEOUT self._timeout = aiohttp.ClientTimeout(total=total_timeout) self._auth_headers = None if session: self._session = session self._managed_session = False else: self._session = None self._managed_session = True # initialize 5 seconds cache to avoid # multiple requests in specific interval self._cache = AsusWrtCache(5) self._mac: str | None = None self._model: str | None = None self._last_boot: datetime | None = None self._last_boot_str: str | None = None self._firmware: AsusWrtFirmware | None = None self._last_fw_check = datetime.utcnow() self._mesh_nodes: dict[str, str] | None = None self._mesh_last_refresh = datetime.utcnow() # Transfer byte variable self._latest_byte_data = None self._byte_mode_internet = False # Transfer rate variable self._latest_transfer_data = None self._latest_transfer_rate = {"rx_rate": 0.0, "tx_rate": 0.0} self._latest_transfer_check = None # CPU usage variable self._available_cpu = None self._latest_cpu_data = None # SYS Info page supported self._sysinfo_support = True def __url(self, path): """Return the url to a specific path.""" return f"{self._protocol}://{self._hostname}:{self._port}/{path}" async def __http_post(self, url, headers, payload, *, get_json=False): """Perform aiohttp POST request.""" try: async with self._session.post( url=url, headers=headers, data=payload, timeout=self._timeout, raise_for_status=True, ssl=False, ) as resp: if get_json: result = await resp.json() else: result = await resp.text() except (asyncio.TimeoutError, aiohttp.ServerTimeoutError) as err: raise AsusWrtConnectionTimeoutError(str(err)) from err except aiohttp.ClientConnectorError as err: raise AsusWrtConnectionError(str(err)) from err except aiohttp.ClientConnectionError as err: self._auth_headers = None raise AsusWrtConnectionError(str(err)) from err except aiohttp.ClientResponseError as err: raise AsusWrtClientResponseError( request_info=err.request_info, history=err.history, status=err.status, message=err.message, headers=err.headers, ) from err except aiohttp.ClientError as err: self._auth_headers = None raise AsusWrtError(str(err)) from err return result async def __post( self, *, path=_ASUSWRT_GET_PATH, command: str | None = None, retry=True ): """ Private POST method to execute a command on the router and return the result :param path: Path to send to the command :param command: Command to send :returns: string result from the router """ payload = command or "" try: await self.async_connect() result = await self.__http_post( self.__url(path), self._auth_headers, payload ) except (AsusWrtConnectionError, AsusWrtClientResponseError): if retry: return await self.__post(path=path, command=command, retry=False) raise if result.find(_ASUSWRT_ERROR_KEY, 0, len(_ASUSWRT_ERROR_KEY) + 5) >= 0: self._auth_headers = None if retry: return await self.__post(path=path, command=command, retry=False) raise AsusWrtConnectionError("Not connected to the router") return result async def __send_cmd( self, *, path=_ASUSWRT_CMD_PATH, commands: dict[str, str] | None = None, action_mode: str = "apply", ): """Command device to run a service or set parameter.""" add_req = commands or {} request: dict = { _ASUSWRT_ACTION_KEY: action_mode, **add_req, } return await self.__post(path=path, command=str(request)) async def __send_req(self, command: str): """Send a hook request to the device. :param command: Command to send :returns: string result from the router """ if value := self._cache.get_key(_ASUSWRT_HOOK_KEY, command): return value request = f"{_ASUSWRT_HOOK_KEY}={command}" result = await self.__post(command=request) self._cache.set_key(_ASUSWRT_HOOK_KEY, command, result) return result async def _run_service( self, service: str, *, arguments: dict[str, str] | None = None ) -> bool: """Command device to run a service. :param service: Service to run :param arguments: Arguments for the service to run (optional) :returns: True or False """ commands = {_ASUSWRT_SVC_REQ: service} if arguments: commands.update(arguments) s = await self.__send_cmd(commands=commands) result = _get_json_result(s) if not all(v in result for v in [_ASUSWRT_SVC_REPLY, _ASUSWRT_SVC_MODIFY]): return False if result[_ASUSWRT_SVC_REPLY] != service: return False return True @property def hostname(self) -> str: """Return the device hostname.""" return self._hostname @property def mac(self) -> str | None: """Return the device mac address.""" return self._mac @property def model(self) -> str | None: """Return the device mac address.""" return self._model @property def last_boot(self) -> datetime | None: """Return the last boot date and time.""" return self._last_boot @property def firmware(self) -> str | None: """Return the device firmware.""" if self._firmware: return self._firmware.to_str() return None @property def is_connected(self) -> bool: """Return if connection is active.""" return self._auth_headers is not None async def async_disconnect(self): """Close the managed session on exit.""" if self._managed_session and self._session is not None: await self._session.close() self._session = None self._auth_headers = None async def async_set_host(self, new_host: str): """Change the connection hostname.""" await self.async_disconnect() self._hostname = new_host async def async_connect(self): """Authenticate with the router.""" if self.is_connected: return if self._managed_session and self._session is None: self._session = aiohttp.ClientSession() auth = f"{self._username}:{self._password}".encode("ascii") login_token = base64.b64encode(auth).decode("ascii") payload = f"login_authorization={login_token}" headers = {"user-agent": _ASUSWRT_USR_AGENT} result = await self.__http_post( self.__url(_ASUSWRT_LOGIN_PATH), headers, payload, get_json=True ) if _ASUSWRT_TOKEN_KEY not in result: raise AsusWrtLoginError("Login Failed") token = result[_ASUSWRT_TOKEN_KEY] self._auth_headers = { "user-agent": _ASUSWRT_USR_AGENT, "cookie": f"{_ASUSWRT_TOKEN_KEY}={token}", } # try to get the main properties after connect await self._load_props() async def _load_props(self) -> None: """Load device properties from NVRam.""" # mac address if self._mac is None: try: result = await self.async_get_settings(_PROP_MAC_ADDR) except AsusWrtError: _LOGGER.debug("Failed to retrieve device mac address") else: self._mac = result.get(_PROP_MAC_ADDR) # model if self._model is None: try: result = await self.async_get_settings(_PROP_MODEL) except AsusWrtError: _LOGGER.debug("Failed to retrieve device model") else: self._model = result.get(_PROP_MODEL) # last boot try: await self.async_get_uptime() except AsusWrtError: _LOGGER.debug("Failed to retrieve last boot info") # firmware try: await self.async_get_cur_fw() except AsusWrtError: _LOGGER.debug("Failed to retrieve installed firmware") async def async_get_cur_fw(self) -> str | None: """Get current device firmware information.""" version = build = extend = None if firmver := await self.async_get_settings("firmver"): version = firmver.get("firmver") buildno = await self.async_get_settings("buildno") if buildno and "buildno" in buildno: build = buildno["buildno"] if extendno := await self.async_get_settings("extendno"): extend = extendno.get("extendno") if build: self._firmware = AsusWrtFirmware(version, build, extend) return self.firmware async def async_get_new_fw(self) -> str | None: """Get new device firmware available.""" try: await self.async_check_fw_update() if not await self.async_get_cur_fw(): return None res = await self.__post(path=_ASUSWRT_FW_PATH) except AsusWrtError as ex: _LOGGER.debug("Failed checking for new fw version: %s", ex) return None if not (new_ver := _parse_fw_info(res)): return None return self._firmware.check_new(new_ver) async def async_check_fw_update(self): """Check for firmware update.""" call_time = datetime.utcnow() if (call_time - self._last_fw_check).total_seconds() < FW_CHECK_INTERVAL: return self._last_fw_check = call_time try: await self.__send_cmd(path=_ASUSWRT_APPLY_PATH, action_mode=_CMD_FW_CHECK) except AsusWrtError: _LOGGER.debug("Failed to check for new firmware") async def async_reboot(self) -> bool: """Reboot the router.""" return await self._run_service(_CMD_REBOOT) async def async_get_led_status(self) -> bool: """Get device led status.""" result = await self.async_get_settings(_PROP_LED_STATUS) try: led = int(result.get(_PROP_LED_STATUS, 0)) except (TypeError, ValueError): return False return led != 0 async def async_set_led_status(self, status: bool) -> bool: """Set device led status.""" arguments = { _PROP_LED_STATUS: 1 if status else 0, } return await self._run_service(_CMD_LED_STATUS, arguments=arguments) async def async_get_uptime(self): """ Return uptime of the router Format: {'last_boot': '2023-10-14T17:24:00+00:00', 'uptime': '375001'} :returns: JSON with last boot time as utc datetime in iso format and uptime in seconds """ r = await self.__send_req(f"{_CMD_UPTIME}()") time = r.partition(":")[2].partition("(")[0] up = r.partition("(")[2].partition(" ")[0] try: up_val = int(up) except ValueError as exc: raise AsusWrtValueError(message=f"Invalid UpTime value: {r}") from exc if self._last_boot_str is None or time != self._last_boot_str: self._last_boot = (datetime.now(UTC) - timedelta(seconds=up_val)).replace( second=0, microsecond=0 ) self._last_boot_str = time return {"last_boot": self._last_boot.isoformat(), "uptime": up_val} async def async_get_memory_usage(self): """ Return memory usage of the router Format: {'mem_total': 262144, 'mem_free': 107320, 'mem_used': 154824} :returns: JSON with memory variables """ s = await self.__send_req(f"{_CMD_MEMORY_USAGE}({_PARAM_APPOBJ})") result = _get_json_result(s, _CMD_MEMORY_USAGE) result_val = {k: int(v) for k, v in result.items()} # calculate memory usage percentage try: mem_usage = round( (result_val["mem_used"] / result_val["mem_total"]) * 100, 2 ) except (KeyError, TypeError, ValueError, ZeroDivisionError): mem_usage = None return {"mem_usage_perc": mem_usage, **result_val} async def async_get_cpu_usage(self): """ Return CPUs usage of the router Note that at least 2 calls is required to have valid data Format: {'cpu1': 0.22, 'cpu2': 0.01, ... 'cpu_total': 0.21} :returns: JSON with CPUs load percentage """ if self._available_cpu is not None: if not self._available_cpu: return {} s = await self.__send_req(f"{_CMD_CPU_USAGE}({_PARAM_APPOBJ})") result = _get_json_result(s, _CMD_CPU_USAGE) cpu_data = {} for key, val in result.items(): if not key.startswith("cpu"): continue cpu_info = key.split("_") if len(cpu_info) != 2: continue cpu_data.setdefault(f"{cpu_info[0]}_usage", {})[cpu_info[1]] = int(val) if self._available_cpu is None: self._available_cpu = [k for k in cpu_data] if not self._available_cpu: return {} # calculate the CPU usage prev_cpu_data = self._latest_cpu_data or {} cpu_usage = {} for key in self._available_cpu: if not (key in cpu_data and key in prev_cpu_data): cpu_usage[key] = 0.0 continue cpu_usage[key] = _calculate_cpu_usage(cpu_data[key], prev_cpu_data[key]) # calculate the total CPU average usage cpu_avg = [v for v in cpu_usage.values()] cpu_usage["cpu_total_usage"] = round(sum(cpu_avg) / len(cpu_avg), 2) # save last fetched data self._latest_cpu_data = cpu_data.copy() return cpu_usage async def _async_get_sysinfo(self): """ Return SysInfo from the router :returns: JSON with Sysinfo statistics """ if not self._sysinfo_support: return None if result := self._cache.get_key(_CACHE_SPECIFIC_URI, _ASUSWRT_SYSINFO_PATH): return result try: s = await self.__post(path=_ASUSWRT_SYSINFO_PATH) except AsusWrtClientResponseError as exc: if exc.status == 404: self._sysinfo_support = False return None raise result = _parse_sysinfo(s) self._cache.set_key(_CACHE_SPECIFIC_URI, _ASUSWRT_SYSINFO_PATH, result) return result async def async_get_loadavg(self): """ Return Load Average info from the router Format: {'load_avg_1': 1.0, 'load_avg_5': 1.03, 'load_avg_15': 1.0} :returns: JSON with LoadAvg statistics """ if (sys_info := await self._async_get_sysinfo()) is None: raise AsusWrtNotAvailableInfoError( message="Loadavg info not available for this device/firmware" ) if "cpu_stats_arr" not in sys_info: raise AsusWrtValueError(message="Loadavg info not available") load_avg = sys_info["cpu_stats_arr"] result = {} for key, val in load_avg.items(): num_val = None if val is not None: try: num_val = round(float(val), 2) except ValueError: num_val = None result[key] = num_val return result async def async_get_temperatures(self): """ Return Temperatures from the router Format: {'2.4GHz': 42.0, '5.0GHz': 48.0, 'CPU': 64.0, ...} :returns: JSON with Temperatures statistics """ if result := self._cache.get_key(_CACHE_SPECIFIC_URI, _ASUSWRT_TEMP_PATH): return result s = await self.__post(path=_ASUSWRT_TEMP_PATH) result = _parse_temperatures(s) self._cache.set_key(_CACHE_SPECIFIC_URI, _ASUSWRT_TEMP_PATH, result) return result async def async_get_wan_info(self): """ Get the status of the WAN connection Format: {"status": "1", "statusstr": "'Connected'", "type": "'dhcp'", "ipaddr": "'192.168.1.2'", "netmask": "'255.255.255.0'", "gateway": "'192.168.1.1'", "dns": "1.1.1.1'", "lease": "86400", "expires": "81967", "xtype": "''", "xipaddr": "'0.0.0.0'", "xnetmask": "'0.0.0.0'", "xgateway": "'0.0.0.0'", "xdns": "''", "xlease": "0", "xexpires": "0"} :returns: JSON with status information on the WAN connection """ r = await self.__send_req(f"{_CMD_WAN_INFO}()") status = {} for f in r.split("\n"): if "return" in f: if f"{_CMD_WAN_INFO}_" in f: key = f.partition("(")[0].partition("_")[2] value = (f.rpartition(" ")[-1][:-2]).replace("'", "") status[key] = value return status async def async_is_wan_online(self): """ Returns if the WAN connection in online :returns: True if WAN is connected """ r = await self.async_get_wan_info() return r["status"] == "1" async def async_get_dhcp_leases(self): """ Obtain a list of DHCP leases Format: [["00:00:00:00:00:00", "name"], ...] :returns: JSON with a list of DHCP leases """ s = await self.__send_req(f"{_CMD_DHCP_LEASE}()") return _get_json_result(s, _CMD_DHCP_LEASE) async def async_get_traffic_bytes(self): """ Get total amount of traffic since last restart (bytes format) Format: {'rx': 15901, 'tx': 10926} :returns: JSON with sent and received bytes since last boot """ s = await self.__send_req(f"{_CMD_NET_TRAFFIC}({_PARAM_APPOBJ})") meas = _get_json_result(s, _CMD_NET_TRAFFIC) if "INTERNET_rx" in meas: traffics = ["INTERNET"] self._byte_mode_internet = True else: if self._byte_mode_internet: return self._latest_byte_data traffics = ["WIRED", "WIRELESS0", "WIRELESS1"] # elif "BRIDGE_rx" in meas: # traffics = ["BRIDGE"] rx = tx = 0 for traffic in traffics: if f"{traffic}_rx" in meas: rx += int(meas[f"{traffic}_rx"], base=16) tx += int(meas[f"{traffic}_tx"], base=16) self._latest_byte_data = {"rx": rx, "tx": tx} return self._latest_byte_data async def async_get_traffic_rates(self): """ Get total and current amount of traffic since last restart (bytes format) Note that at least 2 calls with an interval of min 10 seconds is required to have valid data Format: {"rx_rate": 0.13004302978515625, "tx_rate": 4.189826965332031} :returns: JSON with current up and down stream in byte/s """ now = datetime.utcnow() meas_1 = None if self._latest_transfer_data: meas_1 = self._latest_transfer_data.copy() meas_2 = await self.async_get_traffic_bytes() prev_check = self._latest_transfer_check self._latest_transfer_data = meas_2.copy() self._latest_transfer_check = now if meas_1 is None: return self._latest_transfer_rate meas_delta = (now - prev_check).total_seconds() if meas_delta < 10: return self._latest_transfer_rate rates = {} for key in ["rx", "tx"]: if meas_2[key] < meas_1[key]: rates[key] = meas_2[key] else: rates[key] = meas_2[key] - meas_1[key] self._latest_transfer_rate = { "rx_rate": math.ceil(rates["rx"] / meas_delta), "tx_rate": math.ceil(rates["tx"] / meas_delta), } return self._latest_transfer_rate async def async_get_settings(self, setting: str = None): """ Get settings from the router NVRam Format:{'time_zone': 'MEZ-1DST', 'time_zone_dst': '1', 'time_zone_x': 'MEZ-1DST,M3.2.0/2,M10.2.0/2', 'time_zone_dstoff': 'M3.2.0/2,M10.2.0/2', 'ntp_server0': 'pool.ntp.org', 'acs_dfs': '1', 'productid': 'RT-AC68U', 'apps_sq': '', 'lan_hwaddr': '04:D4:C4:C4:AD:D0', 'lan_ipaddr': '192.168.2.1', 'lan_proto': 'static', 'x_Setting': '1', 'label_mac': '04:D4:C4:C4:AD:D0', 'lan_netmask': '255.255.255.0', 'lan_gateway': '0.0.0.0', 'http_enable': '2', 'https_lanport': '8443', 'wl0_country_code': 'EU', 'wl1_country_code': 'EU'} :param setting: the setting name to query (leave empty to get all main settings) :returns: JSON with main Router settings or specific one """ setting_list = [setting] if setting else _NVRAM_INFO result = {} for s in setting_list: resp = await self.__send_req(_nvram_cmd(s)) if resp: result[s] = _get_json_result(resp, s) return result async def async_get_clients_fullinfo(self) -> list[dict[str, any]]: """ Obtain a list of all clients Format: [ "AC:84:C6:6C:A7:C0":{"type": "2", "defaultType": "0", "name": "Archer_C1200", "nickName": "Router Forlindon", "ip": "192.168.2.175", "mac": "AC:84:C6:6C:A7:C0", "from": "networkmapd", "macRepeat": "1", "isGateway": "0", "isWebServer": "0", "isPrinter": "0", "isITunes": "0", "dpiType": "", "dpiDevice": "", "vendor": "TP-LINK", "isWL": "0", "isOnline": "1", "ssid": "", "isLogin": "0", "opMode": "0", "rssi": "0", "curTx": "", "curRx": "", "totalTx": "", "totalRx": "", "wlConnectTime": "", "ipMethod": "Manual", "ROG": "0", "group": "", "callback": "", "keeparp": "", "qosLevel": "", "wtfast": "0", "internetMode": "allow", "internetState": "1", "amesh_isReClient": "1", "amesh_papMac": "04:D4:C4:C4:AD:D0"}, "maclist": ["AC:84:C6:6C:A7:C0"], "ClientAPILevel": "2" } ] :returns: JSON with list of mac address and all client related info """ s = await self.__send_req(f"{_CMD_CLIENT_LIST}()") result = _get_json_result(s) return [result.get(_CMD_CLIENT_LIST, {})] async def async_get_connected_mac(self): """ Obtain a list of MAC-addresses from online clients Format: ["00:00:00:00:00:00", ...] :returns: JSON list with MAC adresses """ clnts = await self.async_get_clients_fullinfo() lst = [ mac for mac, info in clnts[0].items() if len(mac) == 17 and info.get("isOnline", "0") == "1" ] return lst async def async_get_connected_devices(self): """ Obtain info on all clients Format: {"AC:84:C6:6C:A7:C0": {mac: "AC:84:C6:6C:A7:C0", ip: "x.x.x.x" name: "Archer_C1200"}, ...} :return: JSON dict with mac as key and a namedtuple with mac, ip address and name as value """ clnts = await self.async_get_clients_fullinfo() dev_list = [] mesh_nodes = {self._mac: self._hostname} if self._mac else {} for mac, info in clnts[0].items(): if len(mac) == 17: if info.get("amesh_isRe", "0") == "1": mesh_nodes[mac] = info.get("ip") continue if info.get("isOnline", "0") != "1": continue if not (name := info.get("nickName")): name = info.get("name") is_wl = info.get("isWL", "0") != "0" dev_list.append( Device( mac, info.get("ip"), name, info.get("amesh_papMac"), is_wl, ) ) self._mesh_nodes = mesh_nodes self._mesh_last_refresh = datetime.utcnow() result = {} for dev in dev_list: node_ip = mesh_nodes.get(dev.node) if dev.node else None result[dev.mac] = Device( dev.mac, dev.ip, dev.name, node_ip or self._hostname, dev.is_wl, ) return result async def async_get_mesh_nodes(self): """ Return a list of available mesh nodes Format: {"AC:84:C6:6C:A7:C0": "x.x.x.x"}, ...} :return: JSON dict with mac as key and ip address as value """ last_refresh = (datetime.utcnow() - self._mesh_last_refresh).total_seconds() if self._mesh_nodes is None or last_refresh > 60: await self.async_get_connected_devices() return self._mesh_nodes async def async_get_client_info(self, client_mac): """ Get info on a single client :param client_mac: MAC address of the client requested :return: JSON with clientinfo (see async_get_clients_fullinfo() for description) """ clnts = await self.async_get_clients_fullinfo() return clnts[0].get(client_mac) ollo69-pyasuswrt-84b62f6/pyasuswrt/exceptions.py000066400000000000000000000034441453763665700221270ustar00rootroot00000000000000"""PyAsusWRT Exceptions implementation""" import asyncio from typing import Any, Optional import aiohttp class AsusWrtError(Exception): """Base class for all errors raised by this library.""" def __init__( self, *args: Any, message: Optional[str] = None, **_kwargs: Any ) -> None: """Initialize base AsusWrtError.""" super().__init__(*args, message) class AsusWrtCommunicationError(AsusWrtError, aiohttp.ClientError): """Error occurred while communicating with the AsusWrt device .""" class AsusWrtResponseError(AsusWrtCommunicationError): """HTTP error code returned by the AsusWrt device.""" def __init__( self, *args: Any, status: int, headers: Optional[aiohttp.typedefs.LooseHeaders] = None, message: Optional[str] = None, **kwargs: Any, ) -> None: """Initialize.""" if not message: message = f"Did not receive HTTP 200 but {status}" super().__init__(*args, message=message, **kwargs) self.status = status self.headers = headers class AsusWrtClientResponseError(aiohttp.ClientResponseError, AsusWrtResponseError): """HTTP response error with more details from aiohttp.""" class AsusWrtConnectionError(AsusWrtCommunicationError, aiohttp.ClientConnectionError): """Error connecting with the router.""" class AsusWrtConnectionTimeoutError( AsusWrtCommunicationError, aiohttp.ServerTimeoutError, asyncio.TimeoutError ): """Timeout while communicating with the device.""" class AsusWrtLoginError(AsusWrtError): """Login error / invalid credential.""" class AsusWrtValueError(AsusWrtError, ValueError): """Error invalid value received.""" class AsusWrtNotAvailableInfoError(AsusWrtError): """Error information not available.""" ollo69-pyasuswrt-84b62f6/pyasuswrt/helpers.py000066400000000000000000000077641453763665700214210ustar00rootroot00000000000000"""PyAsusWRT Parser implementation""" from __future__ import annotations import json import re from typing import Any from .exceptions import AsusWrtValueError _MAP_TEMPERATURES: dict[str, list[str]] = { "2.4GHz": [ 'curr_coreTmp_2_raw="([0-9.]+)°C', 'curr_coreTmp_0_raw="([0-9.]+)°C', 'curr_coreTmp_wl0_raw="([0-9.]+)°C', ], "5.0GHz": [ 'curr_coreTmp_5_raw="([0-9.]+)°C', 'curr_coreTmp_1_raw="([0-9.]+)°C', 'curr_coreTmp_wl1_raw="([0-9.]+)°C', ], "5.0GHz_2": [ 'curr_coreTmp_52_raw="([0-9.]+)°C', 'curr_coreTmp_2_raw="([0-9.]+)°C', 'curr_coreTmp_wl2_raw="([0-9.]+)°C', ], "6.0GHz": [ 'curr_coreTmp_3_raw="([0-9.]+)°C', 'curr_coreTmp_wl3_raw="([0-9.]+)°C', ], "CPU": ['curr_cpuTemp="([0-9.]+)"', 'curr_coreTmp_cpu="([0-9.]+)"'], } _MAP_SYSINFO: dict[str, list[str]] = { "conn_stats_arr": [ "conn_total", "conn_active", ], "cpu_stats_arr": [ "load_avg_1", "load_avg_5", "load_avg_15", ], "mem_stats_arr": [ "ram_total", "ram_free", "buffers", "cache", "swap_size", "swap_total", "nvram_used", "jffs", ], } def _get_json_result(result: str, json_key: str | None = None): """Return the json result from a text result.""" try: json_res = json.loads(result) except json.JSONDecodeError as exc: raise AsusWrtValueError(str(exc)) from exc if not json_key: return json_res if (json_val := json_res.get(json_key)) is None: raise AsusWrtValueError("No value available") return json_val def _parse_temperatures(raw: str) -> dict[str, Any]: """Temperature parser""" if type(raw) != str: raise AsusWrtValueError("Invalid temperatures values") if raw.strip() == str(): return {} to_parse = raw.replace(" = ", "=") temps = dict() for sensor in _MAP_TEMPERATURES: for reg in _MAP_TEMPERATURES[sensor]: value = re.search(reg, to_parse) if value: temps[sensor] = round(float(value[1]), 1) return temps def _parse_sysinfo(raw: str) -> dict[str, Any]: """Sysinfo parser""" if type(raw) != str: raise AsusWrtValueError("Invalid sysinfo values") if raw.strip() == str(): return {} raw = raw.replace("\n", "") raw = raw.replace("\ufeff", "") raw = raw.replace(" = ", "=") raw = raw.replace("=", '": ') raw = raw.replace(";", ',"') raw = '{"' + raw[:-2] + "}" to_parse = _get_json_result(raw) sys_info = dict() for cat_info in _MAP_SYSINFO: if cat_info not in to_parse: continue cat = {} values = to_parse[cat_info] for idx, key in enumerate(_MAP_SYSINFO[cat_info]): try: value = values[idx] except (IndexError, ValueError): value = None cat[key] = value sys_info[cat_info] = cat return sys_info def _calculate_cpu_usage(cur_val: dict[str, int], prev_val: dict[str, int]) -> float: """Calculate cpu usage as percentage.""" values = {} for key in ["total", "usage"]: if key not in cur_val or key not in prev_val: return 0.0 values[key] = cur_val[key] - prev_val[key] total = values["total"] usage = values["usage"] if total <= 0 or usage < 0: return 0.0 try: return round((usage / total) * 100, 2) except ValueError: return 0.0 def _parse_fw_info(fw_info: str | None) -> str | None: """Parse information related to new firmware.""" if not fw_info: return None split_info = fw_info.split(";") new_ver = None for prop in split_info: if prop.find("webs_state_info") >= 0: res = prop.split("=") if len(res) >= 2: new_ver = res[1].strip().replace("'", "") break return new_ver ollo69-pyasuswrt-84b62f6/requirements.txt000066400000000000000000000001421453763665700205670ustar00rootroot00000000000000# Actions requirements aiohttp>=3.7.4 pre-commit==3.0.0 flake8==6.0.0 isort==5.12.0 black==23.1.0 ollo69-pyasuswrt-84b62f6/setup.cfg000066400000000000000000000014201453763665700171240ustar00rootroot00000000000000[isort] # https://github.com/timothycrosley/isort # https://github.com/timothycrosley/isort/wiki/isort-Settings # splits long import on multiple lines indented by 4 spaces profile = black line_length = 88 # will group `import x` and `from x import` of the same module. force_sort_within_sections = true known_first_party = tests forced_separate = tests combine_as_imports = true [flake8] exclude = .venv,.git,.tox,docs,venv,bin,lib,deps,build max-complexity = 25 doctests = True # To work with Black # E501: line too long # W503: Line break occurred before a binary operator # E203: Whitespace before ':' # D202 No blank lines allowed after function docstring # W504 line break after binary operator ignore = E501, W503, E203, D202, W504 noqa-require-code = True ollo69-pyasuswrt-84b62f6/setup.py000066400000000000000000000032431453763665700170220ustar00rootroot00000000000000#!/usr/bin/env python """A setuptools based setup module. See: https://packaging.python.org/en/latest/distributing.html https://github.com/pypa/sampleproject """ # Always prefer setuptools over distutils from os.path import dirname, join import re from setuptools import find_packages, setup LIB = "pyasuswrt" install_requires = ["aiohttp>=3.7.4"] github_url = "https://github.com/ollo69/pyasuswrt" with open(join(dirname(__file__), LIB, "__init__.py"), "r") as fp: for line in fp: m = re.search(r'^\s*__version__\s*=\s*([\'"])([^\'"]+)\1\s*$', line) if m: version = m.group(2) break else: raise RuntimeError("Unable to find own __version__ string") with open("README.md", "r") as f: readme = f.read() setup( name="pyasuswrt", version=version, description="Api wrapper for Asuswrt https://www.asus.com/ASUSWRT/ using protocol HTTP", long_description=readme, long_description_content_type="text/markdown", keywords=["asuswrt", "asuswrt wrapper"], url=github_url, download_url=f"{github_url}/archive/{version}.tar.gz", license="MIT", author="ollo69", author_email="ollo69@users.noreply.github.com", packages=find_packages(exclude=["contrib", "docs", "tests"]), python_requires=">=3.7", install_requires=install_requires, extras_require={}, classifiers=[ "Development Status :: 3 - Alpha", "Intended Audience :: Developers", "Topic :: Software Development :: Libraries", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Programming Language :: Python :: 3", ], test_suite="tests", ) ollo69-pyasuswrt-84b62f6/test.py000066400000000000000000000040261453763665700166410ustar00rootroot00000000000000"""Test for PyAsusWrt.""" import asyncio from datetime import datetime import logging import sys from pyasuswrt import AsusWrtError, AsusWrtHttp NUM_LOOP = 1 component = AsusWrtHttp("192.168.10.1", "admin", "****", use_https=False) logging.basicConfig(stream=sys.stdout, level=logging.DEBUG) logger = logging.getLogger(__name__) async def print_data(): for i in range(NUM_LOOP): try: logger.debug("Starting loop at: %s", datetime.now()) logger.debug("await async_get_settings()") dev = await component.async_get_settings() logger.debug(dev) logger.debug("await async_get_clients_fullinfo()") dev = await component.async_get_clients_fullinfo() logger.debug(dev) logger.debug("await async_get_connected_devices()") dev = await component.async_get_connected_devices() logger.debug(dev) logger.debug("await async_get_memory_usage()") dev = await component.async_get_memory_usage() logger.debug(dev) logger.debug("await async_get_cpu_usage()") dev = await component.async_get_cpu_usage() logger.debug(dev) logger.debug("await async_get_traffic_bytes()") dev = await component.async_get_traffic_bytes() logger.debug(dev) logger.debug("await async_get_uptime()") dev = await component.async_get_uptime() logger.debug(dev) logger.debug("await async_get_wan_info()") dev = await component.async_get_wan_info() logger.debug(dev) logger.debug("await async_get_temperatures()") dev = await component.async_get_temperatures() logger.debug(dev) except AsusWrtError as ex: logger.exception("Time: %s, Error: %s", datetime.now(), ex) if i < NUM_LOOP - 1: await asyncio.sleep(10) await component.async_disconnect() loop = asyncio.get_event_loop() loop.run_until_complete(print_data())