├── tests ├── __init__.py ├── conftest.py ├── test_connection.py ├── test_asuswrt.py └── common.py ├── aioasuswrt ├── py.typed ├── helpers.py ├── constant.py ├── __init__.py ├── parsers.py ├── structure.py ├── connection.py └── asuswrt.py ├── .flake8 ├── Pipfile ├── .github └── workflows │ ├── python-package.yml │ └── python-publish.yml ├── LICENSE.md ├── .gitignore ├── .pre-commit-config.yaml ├── CHANGELOG.txt ├── pyproject.toml ├── README.md └── Pipfile.lock /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /aioasuswrt/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 79 3 | exclude = tests/common.py,tests/conftest.py 4 | -------------------------------------------------------------------------------- /aioasuswrt/helpers.py: -------------------------------------------------------------------------------- 1 | """AioAsusWrt helpers.""" 2 | 3 | from collections.abc import Iterable 4 | from copy import deepcopy 5 | 6 | 7 | def empty_iter(iterable: Iterable[str]) -> bool: 8 | """Checks if an iterator is empty, without consuming.""" 9 | try: 10 | _ = next(iter(deepcopy(iterable))) 11 | return False 12 | except StopIteration: 13 | return True 14 | -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | url = "https://pypi.org/simple" 3 | verify_ssl = true 4 | name = "pypi" 5 | 6 | [packages] 7 | asyncssh = "*" 8 | 9 | [dev-packages] 10 | pre-commit = "*" 11 | pytest = "*" 12 | pytest-asyncio = "*" 13 | pytest-mock = "*" 14 | pytest-cov = "*" 15 | ruff = "*" 16 | pylint = "*" 17 | flake8 = "*" 18 | mypy = "*" 19 | bandit = "*" 20 | 21 | [requires] 22 | python_version = "3.12" 23 | -------------------------------------------------------------------------------- /aioasuswrt/constant.py: -------------------------------------------------------------------------------- 1 | """Constant decleration.""" 2 | 3 | ALLOWED_KEY_HASHES = [ 4 | "ssh-rsa", 5 | "rsa-sha2-256", 6 | "rsa-sha2-512", 7 | "ecdsa-sha2-nistp256", 8 | "ecdsa-sha2-nistp384", 9 | "ecdsa-sha2-nistp521", 10 | "ssh-ed25519", 11 | "ssh-ed448", 12 | ] 13 | 14 | NETDEV_FIELDS = [ 15 | "tx_bytes", 16 | "tx_packets", 17 | "tx_errs", 18 | "tx_drop", 19 | "tx_fifo", 20 | "tx_frame", 21 | "tx_compressed", 22 | "tx_multicast", 23 | "rx_bytes", 24 | "rx_packets", 25 | "rx_errs", 26 | "rx_drop", 27 | "rx_fifo", 28 | "rx_colls", 29 | "rx_carrier", 30 | "rx_compressed", 31 | ] 32 | 33 | VPN_COUNT = 5 34 | DEFAULT_DNSMASQ = "/var/lib/misc" 35 | DEFAULT_WAN_INTERFACE = "eth0" 36 | -------------------------------------------------------------------------------- /.github/workflows/python-package.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a variety of Python versions 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python 3 | 4 | name: Python package 5 | 6 | on: 7 | push: 8 | branches: ["master"] 9 | pull_request: 10 | branches: ["master"] 11 | 12 | jobs: 13 | install-test: 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - uses: actions/checkout@v2 18 | - uses: actions/setup-python@v5 19 | - name: Lint 20 | run: | 21 | pip install pipenv 22 | pipenv install --dev 23 | pipenv run pre-commit run --all-files 24 | 25 | - name: Test 26 | run: | 27 | pipenv run pytest --cov 28 | -------------------------------------------------------------------------------- /aioasuswrt/__init__.py: -------------------------------------------------------------------------------- 1 | """aioasuswrt package.""" 2 | 3 | from importlib.metadata import PackageNotFoundError, version 4 | 5 | from .asuswrt import AsusWrt 6 | from .structure import AuthConfig, ConnectionType, Device, Mode, Settings 7 | 8 | try: 9 | __version__ = version("aioasuswrt") 10 | except PackageNotFoundError: 11 | __version__ = "dev" 12 | 13 | 14 | def connect_to_router( 15 | host: str, auth_config: AuthConfig, settings: Settings | None 16 | ) -> AsusWrt: 17 | """ 18 | Connect to the router and get an AsusWrt instance 19 | 20 | Args: 21 | host (str): The IP or hostname 22 | auth_config (AuthConfig): authentication configuration 23 | settings (Settings): aioasuswrt settings 24 | """ 25 | return AsusWrt( 26 | host, 27 | auth_config, 28 | settings=settings, 29 | ) 30 | 31 | 32 | __all__ = ( 33 | "AsusWrt", 34 | "AuthConfig", 35 | "ConnectionType", 36 | "Device", 37 | "Mode", 38 | "Settings", 39 | "connect_to_router", 40 | ) 41 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright 2018-2025 Magnus Knutas 2 | 3 | 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: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | 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. 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .Python 2 | [Bb]in 3 | [Ii]nclude 4 | [Ll]ib 5 | [Ll]ib64 6 | [Ll]ocal 7 | [Ss]cripts 8 | pyvenv.cfg 9 | .venv 10 | pip-selfcheck.json 11 | ### Python template 12 | # Byte-compiled / optimized / DLL files 13 | __pycache__/ 14 | *.py[cod] 15 | *$py.class 16 | 17 | # Distribution / packaging 18 | .Python 19 | build/ 20 | develop-eggs/ 21 | dist/ 22 | downloads/ 23 | eggs/ 24 | .eggs/ 25 | lib/ 26 | lib64/ 27 | parts/ 28 | sdist/ 29 | var/ 30 | wheels/ 31 | *.egg-info/ 32 | .installed.cfg 33 | *.egg 34 | MANIFEST 35 | 36 | # PyInstaller 37 | # Usually these files are written by a python script from a template 38 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 39 | *.manifest 40 | *.spec 41 | 42 | # Installer logs 43 | pip-log.txt 44 | pip-delete-this-directory.txt 45 | 46 | # Unit test / coverage reports 47 | htmlcov/ 48 | .tox/ 49 | .coverage 50 | .coverage.* 51 | .cache 52 | nosetests.xml 53 | coverage.xml 54 | *.cover 55 | .hypothesis/ 56 | 57 | # Sphinx documentation 58 | docs/_build/ 59 | 60 | # Jupyter Notebook 61 | .ipynb_checkpoints 62 | 63 | # pyenv 64 | .python-version 65 | 66 | # Environments 67 | .env 68 | .venv 69 | env/ 70 | venv/ 71 | ENV/ 72 | env.bak/ 73 | venv.bak/ 74 | 75 | # IntelliJ 76 | out/ 77 | .idea 78 | 79 | # JIRA plugin 80 | atlassian-ide-plugin.xml 81 | 82 | # vs code configs 83 | .vscode 84 | 85 | # pytest 86 | .pytest_cache 87 | 88 | # ruff 89 | .ruff_cache 90 | 91 | # mypy 92 | .mypy_cache/ 93 | 94 | # Rope project settings 95 | .ropeproject 96 | 97 | # project stuff 98 | test.py 99 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v6.0.0 4 | hooks: 5 | - id: check-added-large-files 6 | - id: check-case-conflict 7 | - id: check-docstring-first 8 | - id: debug-statements 9 | - id: check-yaml 10 | - id: end-of-file-fixer 11 | - id: trailing-whitespace 12 | - id: check-case-conflict 13 | - id: check-json 14 | - id: check-symlinks 15 | - id: check-toml 16 | - id: detect-private-key 17 | - id: forbid-new-submodules 18 | - id: pretty-format-json 19 | exclude: Pipfile.lock 20 | args: ["--autofix"] 21 | - repo: https://github.com/pre-commit/mirrors-mypy 22 | rev: v1.18.2 23 | hooks: 24 | - id: mypy 25 | additional_dependencies: 26 | - types-toml 27 | exclude: tests 28 | - repo: https://github.com/pycqa/flake8 29 | rev: 7.3.0 30 | hooks: 31 | - id: flake8 32 | - repo: https://github.com/PyCQA/bandit 33 | rev: 1.8.6 34 | hooks: 35 | - id: bandit 36 | args: ["-c", "pyproject.toml"] 37 | - repo: https://github.com/astral-sh/ruff-pre-commit 38 | rev: v0.13.2 39 | hooks: 40 | - id: ruff 41 | name: ruff-isort 42 | types_or: [python, pyi] 43 | args: ["check", "--select", "I", "--fix"] 44 | - id: ruff-format 45 | - repo: local 46 | hooks: 47 | - id: pylint 48 | name: pylint 49 | entry: pylint 50 | language: system 51 | types: [python] 52 | require_serial: true 53 | args: [ 54 | "-rn", # Only display messages 55 | "-sn", # Don't display the score 56 | ] 57 | types_or: [python, pyi] 58 | -------------------------------------------------------------------------------- /CHANGELOG.txt: -------------------------------------------------------------------------------- 1 | Version 2.0.8 2 | * Rework connected devices after 48h testing (filter out leases that are not connected) 3 | * Add error handling for connection error to Telnet 4 | 5 | Version 2.0.7 6 | * Checked and fixed `get_transfer_rates` 7 | * Checked and fixed `get_interfaces_count` and renamed to `get_interface_counters` 8 | 9 | Version 2.0.6 10 | * STALE, REACHABLE and None counts as not home 11 | 12 | Version 2.0.5 13 | * Handle router disconnects 14 | 15 | Version 2.0.0 16 | * Splitting configs into dataclasses 17 | * Modern typing 18 | * Refactor tests to actually check outcome after all the different router querys are executed. 19 | * Fixing bugs for collected devices in `neigh` command 20 | * Create a structure module and collect all structs 21 | * Using map / filter and return iterator where possible to reduce number of iterations in device collecting 22 | 23 | 24 | Version 1.5.1 25 | * Adding py.typed 26 | * Adding async_disconnect to the router class 27 | 28 | Version 1.5.0 29 | * More accurate rx/tx a big thanks to @tempura-san @kennedyshead 30 | * Added rssi, interface, interface_name, interface_mac @kennedyshead 31 | * Refactored get devices to be able to keep a live list through the run @kennedyshead 32 | * Removed the timout for devices, this should be handled elsewhere @kennedyshead 33 | * Remove all cache @kennedyshead 34 | * Remove broken transfer rates @kennedyshead 35 | * Rewrite of disconnect/connect logic @kennedyshead 36 | * Modernize python build @kennedyshead 37 | * Typing @kennedyshead 38 | * Adding pre-commit linting @kennedyshead 39 | * Adding new secure connection algorithms to fix certificate based authentication on new Asus WRT firmware (3006) @321Kami 40 | * Add function to list/start/stop a vpn @zerataxz 41 | * Fix connection leak and add Coroutine-Safety @danielskowronski 42 | 43 | 44 | Version 1.4.0 45 | * Fix UnicodeDecodeError error on Telnet connection @ollo69 46 | * fix: Alternative Temperature commands @Chen-IL 47 | * Better temperature fetching @Chen-IL 48 | * Use /tmp/clientlist.json in async_get_connected_devices @ollo69 49 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "aioasuswrt" 3 | version = "2.1.0" 4 | description = "Api wrapper for Asuswrt https://www.asus.com/ASUSWRT/" 5 | readme = "README.md" 6 | requires-python = ">= 3.13" 7 | license = "MIT" 8 | license-files = ["LICEN[CS]E.*"] 9 | authors = [ 10 | { name = "Magnus Knutas", email = "magnusknutas@gmail.com" }, 11 | ] 12 | maintainers = [ 13 | { name = "Magnus Knutas", email = "magnusknutas@gmail.com" }, 14 | ] 15 | keywords = [ 16 | "Asus", 17 | "development", 18 | "hass", 19 | "homeassistant", 20 | "iot", 21 | "router", 22 | ] 23 | classifiers = [ 24 | "Development Status :: 3 - Alpha", 25 | "Intended Audience :: Developers", 26 | "Programming Language :: Python :: 3", 27 | "Topic :: Software Development :: Libraries", 28 | ] 29 | dependencies = [ 30 | "asyncssh", 31 | "bcrypt", 32 | ] 33 | 34 | [project.urls] 35 | "Bug Tracker" = "https://github.com/kennedyshead/aioasuswrt/issues" 36 | Changelog = "https://github.com/kennedyshead/aioasuswrt/blob/master/CHANGELOG.md" 37 | Documentation = "https://github.com/kennedyshead/aioasuswrt/blob/master/README.md" 38 | Homepage = "https://github.com/kennedyshead/aioasuswrt" 39 | Repository = "https://github.com/kennedyshead/aioasuswrt.git" 40 | 41 | [build-system] 42 | requires = ["setuptools >= 80.9.0"] 43 | build-backend = "setuptools.build_meta" 44 | 45 | [tool.bandit] 46 | exclude_dirs = ["tests"] 47 | 48 | [tool.mypy] 49 | python_version = "3.13" 50 | warn_return_any = true 51 | warn_unused_configs = true 52 | disallow_untyped_defs = true 53 | warn_unused_ignores = true 54 | warn_redundant_casts = true 55 | warn_unreachable = true 56 | explicit_package_bases = true 57 | strict = true 58 | follow_imports = "silent" 59 | exclude = ["build", "tests"] 60 | mypy_path = "$MYPY_CONFIG_FILE_DIR/aioasuswrt/" 61 | 62 | [[tool.mypy.overrides]] 63 | module = ["asyncssh"] 64 | ignore_missing_imports = true 65 | 66 | [tool.pytest.ini_options] 67 | pythonpath = ["aioasuswrt", "."] 68 | 69 | [tool.rope] 70 | split_imports = false 71 | ignored_resources = [ 72 | "**/*.pyc", 73 | "*~", 74 | ".git", 75 | ".tox", 76 | ".venv", 77 | "venv", 78 | "build", 79 | "dist", 80 | ".mypy_cache", 81 | ".pytest_cache", 82 | ".coverage", 83 | ".DS_Store", 84 | ".gitignore", 85 | "*.yaml", 86 | ] 87 | 88 | [tool.ruff] 89 | line-length = 79 90 | 91 | [tool.yapf.style] 92 | based_on_style = "pep8" 93 | column_limit = 79 94 | indent_width = 4 95 | -------------------------------------------------------------------------------- /.github/workflows/python-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflow will upload a Python Package to PyPI when a release is created 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python#publishing-to-package-registries 3 | 4 | # This workflow uses actions that are not certified by GitHub. 5 | # They are provided by a third-party and are governed by 6 | # separate terms of service, privacy policy, and support 7 | # documentation. 8 | 9 | name: Upload Python Package 10 | 11 | on: 12 | release: 13 | types: [published] 14 | 15 | permissions: 16 | contents: read 17 | 18 | jobs: 19 | release-build: 20 | runs-on: ubuntu-latest 21 | 22 | steps: 23 | - uses: actions/checkout@v4 24 | 25 | - uses: actions/setup-python@v5 26 | with: 27 | python-version: "3.13" 28 | 29 | - name: Build release distributions 30 | run: | 31 | # NOTE: put your own distribution build steps here. 32 | python -m pip install build 33 | python -m build 34 | 35 | - name: Upload distributions 36 | uses: actions/upload-artifact@v4 37 | with: 38 | name: release-dists 39 | path: dist/ 40 | 41 | pypi-publish: 42 | runs-on: ubuntu-latest 43 | needs: 44 | - release-build 45 | permissions: 46 | # IMPORTANT: this permission is mandatory for trusted publishing 47 | id-token: write 48 | 49 | # Dedicated environments with protections for publishing are strongly recommended. 50 | # For more information, see: https://docs.github.com/en/actions/deployment/targeting-different-environments/using-environments-for-deployment#deployment-protection-rules 51 | environment: 52 | name: pypi 53 | # OPTIONAL: uncomment and update to include your PyPI project URL in the deployment status: 54 | url: https://pypi.org/project/aioasuswrt/ 55 | # 56 | # ALTERNATIVE: if your GitHub Release name is the PyPI project version string 57 | # ALTERNATIVE: exactly, uncomment the following line instead: 58 | # url: https://pypi.org/project/YOURPROJECT/${{ github.event.release.name }} 59 | 60 | steps: 61 | - name: Retrieve release distributions 62 | uses: actions/download-artifact@v4 63 | with: 64 | name: release-dists 65 | path: dist/ 66 | 67 | - name: Publish release distributions to PyPI 68 | uses: pypa/gh-action-pypi-publish@release/v1 69 | with: 70 | packages-dir: dist/ 71 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # AioAsusWRT 2 | ## Moving 3 | 4 | I have come to the decision that I do not want to use github for my projects going forward, there is just to much corporate (closed source) AI going on. 5 | I will prepare a move and as soon as I am done I will update with links and more information. 6 | 7 | ![Python package](https://github.com/kennedyshead/aioasuswrt/workflows/Python%20package/badge.svg) [![Upload Python Package](https://github.com/kennedyshead/aioasuswrt/actions/workflows/python-publish.yml/badge.svg)](https://github.com/kennedyshead/aioasuswrt/actions/workflows/python-publish.yml) 8 | Small wrapper for asuswrt. 9 | 10 | ## Setup 11 | 12 | ```bash 13 | pipenv install --dev 14 | pre-commit install 15 | ``` 16 | 17 | ## Run lint/tests 18 | 19 | ```bash 20 | pre-commit run --all-files 21 | pytest . 22 | ``` 23 | 24 | ## Credits: 25 | [@mvn23](https://github.com/mvn23) 26 | [@halkeye](https://github.com/halkeye) 27 | [@maweki](https://github.com/maweki) 28 | [@quarcko](https://github.com/quarcko) 29 | [@wdullaer](https://github.com/wdullaer) 30 | 31 | ## Info 32 | There are many different versions of asuswrt and sometimes they just dont work in current implementation. 33 | If you have a problem with your specific router open an issue, but please add as much info as you can and atleast: 34 | 35 | * Version of router 36 | * Version of Asuswrt 37 | 38 | ## Known issues 39 | 40 | ## Bugs 41 | You can always create an issue in this tracker. 42 | To test and give us the information needed you should run: 43 | ```python 44 | #!/usr/bin/env python 45 | import asyncio 46 | import logging 47 | 48 | import sys 49 | 50 | from aioasuswrt.asuswrt import AsusWrt 51 | 52 | component = AsusWrt('192.168.1.1', 22, username='****', password='****') 53 | logging.basicConfig(stream=sys.stdout, level=logging.DEBUG) 54 | logger = logging.getLogger(__name__) 55 | 56 | 57 | async def print_data(): 58 | dev = {} 59 | await component.async_get_wl(dev) 60 | await component.async_get_arp(dev) 61 | dev.update(await component.async_get_neigh(dev)) 62 | dev.update(await component.async_get_leases(dev)) 63 | dev.update(await component.async_filter_dev_list(dev)) 64 | await component.async_get_connected_devices(dev) 65 | __import__("pprint").pprint(dev) 66 | 67 | i = 0 68 | while True: 69 | print(await component.async_current_transfer_human_readable()) 70 | await asyncio.sleep(10) 71 | i += 1 72 | if i > 6: 73 | break 74 | 75 | 76 | 77 | loop = asyncio.get_event_loop() 78 | 79 | loop.run_until_complete(print_data()) 80 | loop.close() 81 | ``` 82 | 83 | ## Documentation 84 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | """Conftest setup.""" 2 | 3 | # pylint: disable=protected-access 4 | # pyright: reportPrivateUsage=false, reportAssignmentType=false 5 | 6 | from asyncio import StreamReader, StreamWriter 7 | from unittest.mock import AsyncMock, MagicMock, patch 8 | 9 | import pytest 10 | from asyncssh import SSHClientConnection 11 | 12 | from aioasuswrt.asuswrt import AsusWrt 13 | from aioasuswrt.connection import ( 14 | BaseConnection, 15 | SshConnection, 16 | TelnetConnection, 17 | create_connection, 18 | ) 19 | from aioasuswrt.constant import DEFAULT_DNSMASQ 20 | from aioasuswrt.structure import AuthConfig, Command, ConnectionType, Settings 21 | from tests.common import ( 22 | ARP_DATA, 23 | CLIENTLIST_DATA, 24 | LEASES_DATA, 25 | NEIGH_DATA, 26 | WL_DATA, 27 | ) 28 | 29 | 30 | def successful_get_devices_commands( 31 | command: str, 32 | ) -> list[str | None] | list[str] | None: 33 | """Commands mapped to data for successful call.""" 34 | if command == Command.WL: 35 | return WL_DATA 36 | if command == Command.ARP: 37 | return ARP_DATA 38 | if command == Command.IP_NEIGH: 39 | return NEIGH_DATA 40 | if command == Command.LEASES.format(DEFAULT_DNSMASQ): 41 | return LEASES_DATA 42 | if command == Command.CLIENTLIST: 43 | return CLIENTLIST_DATA 44 | return None 45 | 46 | 47 | @pytest.fixture 48 | def mocked_wrt() -> AsusWrt: 49 | """AsusWrt with mocked connection.""" 50 | with patch( 51 | "aioasuswrt.asuswrt.create_connection", 52 | return_value=MagicMock( 53 | autospec=BaseConnection, run_command=AsyncMock(return_value=None) 54 | ), 55 | ): 56 | router = AsusWrt( 57 | "fake", 58 | AuthConfig( 59 | username="test", 60 | password="test", 61 | connection_type=ConnectionType.SSH, 62 | ssh_key=None, 63 | port=None, 64 | passphrase=None, 65 | ), 66 | settings=Settings(), 67 | ) 68 | return router 69 | 70 | 71 | @pytest.fixture 72 | def mocked_ssh_connection() -> SshConnection: 73 | """Mocked SshConnection.""" 74 | with patch("aioasuswrt.connection.connect", autospec=SSHClientConnection): 75 | _connection: SshConnection = create_connection( 76 | "host", 77 | AuthConfig( 78 | username="Test", 79 | password="Test", 80 | connection_type=ConnectionType.SSH, 81 | ssh_key="test", 82 | passphrase="test", 83 | port=None, 84 | ), 85 | ) 86 | return _connection 87 | 88 | 89 | @pytest.fixture 90 | def mocked_telnet_connection() -> TelnetConnection: 91 | """Mocked SshConnection.""" 92 | with patch( 93 | "aioasuswrt.connection.open_connection", 94 | return_value=( 95 | MagicMock(autospec=StreamReader), 96 | MagicMock(autospec=StreamWriter), 97 | ), 98 | ): 99 | _connection: TelnetConnection = create_connection( 100 | "host", 101 | AuthConfig( 102 | username="Test", 103 | password="Test", 104 | connection_type=ConnectionType.TELNET, 105 | ssh_key="test", 106 | passphrase="test", 107 | port=None, 108 | ), 109 | ) 110 | return _connection 111 | -------------------------------------------------------------------------------- /tests/test_connection.py: -------------------------------------------------------------------------------- 1 | """Unit test connection.""" 2 | 3 | # pylint: disable=protected-access 4 | # pyright: reportPrivateUsage=false, reportAssignmentType=false 5 | 6 | from asyncio import Lock, StreamReader, StreamWriter 7 | from unittest.mock import AsyncMock, MagicMock 8 | 9 | import pytest 10 | from asyncssh import SSHClientConnection 11 | 12 | from aioasuswrt.connection import ( 13 | SshConnection, 14 | TelnetConnection, 15 | ) 16 | 17 | 18 | def test_sets_ssh_default_values(mocked_ssh_connection: SshConnection) -> None: 19 | """Test that the SSH init method sets values.""" 20 | assert mocked_ssh_connection._port == 22 21 | assert mocked_ssh_connection._host == "host" 22 | assert mocked_ssh_connection._username == "Test" 23 | assert mocked_ssh_connection._password == "Test" 24 | assert mocked_ssh_connection._passphrase == "test" 25 | assert mocked_ssh_connection._ssh_key == "test" 26 | assert mocked_ssh_connection._known_hosts is None 27 | assert mocked_ssh_connection._client is None 28 | assert isinstance(mocked_ssh_connection._io_lock, Lock) 29 | assert mocked_ssh_connection.description == "Test@host:22" 30 | assert not mocked_ssh_connection.is_connected 31 | 32 | 33 | def test_sets_telnet_default_values( 34 | mocked_telnet_connection: TelnetConnection, 35 | ): 36 | """Test that the SSH init method sets values.""" 37 | assert mocked_telnet_connection._port == 110 38 | assert mocked_telnet_connection._host == "host" 39 | assert mocked_telnet_connection._username == "Test" 40 | assert mocked_telnet_connection._password == "Test" 41 | assert mocked_telnet_connection._reader is None 42 | assert mocked_telnet_connection._writer is None 43 | assert mocked_telnet_connection._prompt_string == "".encode("ascii") 44 | assert mocked_telnet_connection._linebreak is None 45 | assert isinstance(mocked_telnet_connection._io_lock, Lock) 46 | assert mocked_telnet_connection.description == "Test@host:110" 47 | assert not mocked_telnet_connection.is_connected 48 | 49 | 50 | @pytest.mark.asyncio 51 | async def test_ssh_connect_locks(mocked_ssh_connection: SshConnection) -> None: 52 | """Test that SshConnection locks while connecting.""" 53 | mocked_ssh_connection._connect = AsyncMock() 54 | _mock_lock = AsyncMock() 55 | mocked_ssh_connection._io_lock = MagicMock( 56 | autospec=Lock, __aenter__=_mock_lock 57 | ) 58 | assert await mocked_ssh_connection.connect() is None 59 | _mock_lock.assert_awaited_once() 60 | 61 | 62 | @pytest.mark.asyncio 63 | async def test_telnet_connect_locks( 64 | mocked_telnet_connection: TelnetConnection, 65 | ) -> None: 66 | """Test that SshConnection locks while connecting.""" 67 | mocked_telnet_connection._connect = AsyncMock() 68 | _mock_lock = AsyncMock() 69 | mocked_telnet_connection._io_lock = MagicMock( 70 | autospec=Lock, __aenter__=_mock_lock 71 | ) 72 | assert await mocked_telnet_connection.connect() is None 73 | _mock_lock.assert_awaited_once() 74 | 75 | 76 | @pytest.mark.asyncio 77 | async def test_ssh_already_connected( 78 | mocked_ssh_connection: SshConnection, 79 | ) -> None: 80 | """Test that SshConnection locks while connecting.""" 81 | _mock_lock = AsyncMock() 82 | mocked_ssh_connection._client = MagicMock(autospec=SSHClientConnection) 83 | mocked_ssh_connection._io_lock = MagicMock( 84 | autospec=Lock, __aenter__=_mock_lock 85 | ) 86 | assert await mocked_ssh_connection.connect() is None 87 | _mock_lock.assert_not_awaited() 88 | 89 | 90 | @pytest.mark.asyncio 91 | async def test_telnet_already_connected( 92 | mocked_telnet_connection: TelnetConnection, 93 | ) -> None: 94 | """Test that SshConnection locks while connecting.""" 95 | _mock_lock = AsyncMock() 96 | mocked_telnet_connection._reader = MagicMock(autospec=StreamReader) 97 | mocked_telnet_connection._writer = MagicMock(autospec=StreamWriter) 98 | mocked_telnet_connection._io_lock = MagicMock( 99 | autospec=Lock, __aenter__=_mock_lock 100 | ) 101 | assert await mocked_telnet_connection.connect() is None 102 | _mock_lock.assert_not_awaited() 103 | -------------------------------------------------------------------------------- /aioasuswrt/parsers.py: -------------------------------------------------------------------------------- 1 | """Parser module""" 2 | 3 | from collections.abc import Iterable 4 | from json import JSONDecodeError, loads 5 | from logging import getLogger 6 | from re import Pattern 7 | 8 | from .structure import Device, InterfaceJson, new_device 9 | 10 | _LOGGER = getLogger(__name__) 11 | 12 | 13 | async def parse_raw_lines( 14 | lines: Iterable[str], regex: Pattern[str] 15 | ) -> Iterable[dict[str, str]]: 16 | """ 17 | Parse the lines using the given regular expression. 18 | 19 | If a line can't be parsed it is logged and skipped in the output. 20 | We first map all the lines, and after this filter out any None rows. 21 | 22 | Args: 23 | lines (Iterable[str]): A list of lines to parse 24 | regex (Pattern[str]): The regex pattern to use on each line 25 | """ 26 | 27 | def _match(line: str) -> dict[str, str] | None: 28 | """ 29 | Match a single line, will return a None value if no match is made. 30 | 31 | Args: 32 | line (str): single line to process 33 | """ 34 | if not line: 35 | return None 36 | 37 | match = regex.search(line) 38 | if not match: 39 | _LOGGER.debug("Could not parse row: %s", line) 40 | return None 41 | 42 | return match.groupdict() 43 | 44 | return filter(None, map(_match, lines)) 45 | 46 | 47 | def _parse_wl_row(row: dict[str, str], devices: dict[str, Device]) -> None: 48 | mac = row["mac"].upper() 49 | devices[mac] = new_device(mac) 50 | 51 | 52 | def parse_wl( 53 | data: Iterable[dict[str, str]], devices: dict[str, Device] 54 | ) -> None: 55 | """Parse wl data.""" 56 | _ = list(map(lambda row: _parse_wl_row(row, devices), data)) 57 | 58 | 59 | def _parse_arp_row(row: dict[str, str], devices: dict[str, Device]) -> None: 60 | mac = row["mac"].upper() 61 | if mac not in devices: 62 | devices[mac] = new_device(mac) 63 | devices[mac].device_data["ip"] = row["ip"] 64 | devices[mac].interface["id"] = row["interface"] 65 | 66 | 67 | def _parse_leases(device: dict[str, str], devices: dict[str, Device]) -> None: 68 | mac = device["mac"].upper() 69 | host = device.get("host") 70 | if host is not None and host != "*": 71 | devices[mac].device_data["name"] = host 72 | 73 | devices[mac].device_data["ip"] = device["ip"] 74 | 75 | 76 | def _lease_in_devices( 77 | device: dict[str, str], devices: dict[str, Device] 78 | ) -> bool: 79 | return device["mac"].upper() in devices 80 | 81 | 82 | def parse_leases( 83 | data: Iterable[dict[str, str]], devices: dict[str, Device] 84 | ) -> None: 85 | """Parse leases data.""" 86 | _ = list( 87 | map( 88 | lambda row: _parse_leases(row, devices), 89 | filter(lambda row: _lease_in_devices(row, devices), data), 90 | ) 91 | ) 92 | 93 | 94 | def parse_arp( 95 | data: Iterable[dict[str, str]], devices: dict[str, Device] 96 | ) -> None: 97 | """Parse arp data.""" 98 | _ = list(map(lambda row: _parse_arp_row(row, devices), data)) 99 | 100 | 101 | def _parse_neigh_row( 102 | device: dict[str, str], devices: dict[str, Device] 103 | ) -> None: 104 | if not device.get("mac"): 105 | return 106 | status = device["status"] 107 | mac = device["mac"].upper() 108 | if mac not in devices: 109 | devices[mac] = new_device(mac) 110 | devices[mac].device_data["status"] = status 111 | devices[mac].device_data["ip"] = device.get( 112 | "ip", devices[mac].device_data["ip"] 113 | ) 114 | 115 | 116 | def parse_neigh( 117 | data: Iterable[dict[str, str]], devices: dict[str, Device] 118 | ) -> None: 119 | """Parse neigh data.""" 120 | _ = list(map(lambda row: _parse_neigh_row(row, devices), data)) 121 | 122 | 123 | def _parse_device_clientjson( 124 | interface_mac: str, 125 | conn_type: str, 126 | conn_items: dict[str, dict[str, str | int]], 127 | dev_mac: str, 128 | devices: dict[str, Device], 129 | ) -> None: 130 | mac = dev_mac.upper() 131 | device = conn_items[mac] 132 | ip = device.get("ip") 133 | if not isinstance(ip, str): 134 | ip = None 135 | rssi = device.get("rssi", None) 136 | if mac not in devices: 137 | devices[mac] = new_device(mac) 138 | devices[mac].device_data["ip"] = ip 139 | devices[mac].device_data["rssi"] = int(rssi) if rssi else None 140 | devices[mac].interface["name"] = conn_type 141 | devices[mac].interface["mac"] = interface_mac 142 | 143 | 144 | def _map_device_clientjson( 145 | interface_mac: str, 146 | conn_type: str, 147 | conn_items: dict[str, dict[str, str | int]], 148 | devices: dict[str, Device], 149 | ) -> None: 150 | _ = list( 151 | map( 152 | lambda item: _parse_device_clientjson( 153 | interface_mac, 154 | conn_type, 155 | conn_items, 156 | item, 157 | devices, 158 | ), 159 | conn_items, 160 | ) 161 | ) 162 | 163 | 164 | def _handle_clientjson( 165 | interface_mac: str, 166 | interface: dict[str, dict[str, dict[str, str | int]]], 167 | devices: dict[str, Device], 168 | ) -> None: 169 | _ = list( 170 | map( 171 | lambda conn_type: _map_device_clientjson( 172 | interface_mac, conn_type, interface[conn_type], devices 173 | ), 174 | interface, 175 | ) 176 | ) 177 | 178 | 179 | def parse_clientjson(data: str, devices: dict[str, Device]) -> None: 180 | """Parse clientlist.json file""" 181 | try: 182 | device_list = InterfaceJson(loads(data)) 183 | except JSONDecodeError: 184 | _LOGGER.info("clientlist.json is corrupt.") 185 | return 186 | _ = list( 187 | map( 188 | lambda interface_mac: _handle_clientjson( 189 | interface_mac, device_list[interface_mac], devices 190 | ), 191 | device_list, 192 | ) 193 | ) 194 | -------------------------------------------------------------------------------- /aioasuswrt/structure.py: -------------------------------------------------------------------------------- 1 | """Mappings of structures and typing.""" 2 | 3 | from enum import StrEnum 4 | from re import Pattern 5 | from re import compile as re_compile 6 | from typing import Callable, NamedTuple, TypeAlias, TypedDict 7 | 8 | from .constant import DEFAULT_DNSMASQ, DEFAULT_WAN_INTERFACE 9 | 10 | InterfaceJson: TypeAlias = dict[ 11 | str, dict[str, dict[str, dict[str, str | int]]] 12 | ] 13 | 14 | 15 | class AsyncSSHConnectKwargs(TypedDict): 16 | """Kwargs mapping for the asyncssh.connect method.""" 17 | 18 | username: str 19 | port: int 20 | server_host_key_algs: list[str] 21 | password: str | None 22 | passphrase: str | None 23 | known_hosts: list[str] | None 24 | client_keys: list[str] | None 25 | 26 | 27 | class Interface(TypedDict): 28 | """ 29 | Interface representation. 30 | 31 | Attributes: 32 | id (str | None): id of the interface (for example eth0) 33 | name (str | None): Name of the interface (for example 5g) 34 | mac (str | None): MAC address for the interface 35 | """ 36 | 37 | id: str | None 38 | name: str | None 39 | mac: str | None 40 | 41 | 42 | class DeviceData(TypedDict): 43 | """ 44 | Device status representation. 45 | 46 | Attributes: 47 | ip (str |None): The IP of the device 48 | name (str | None): The hostname of the device 49 | status (str | None): The status of the device 50 | rssi (int | None): Signal strength, 51 | in a mesh systems this is to closest node. 52 | """ 53 | 54 | ip: str | None 55 | name: str | None 56 | status: str | None 57 | rssi: int | None 58 | 59 | 60 | class Device(NamedTuple): 61 | """ 62 | Representation of a connected device. 63 | 64 | Attributes: 65 | mac (str): The MAC adrress for the device 66 | device_data (DeviceData): The current status 67 | interface (Interface): Information about the 68 | Interface device is connected to 69 | """ 70 | 71 | mac: str 72 | device_data: DeviceData 73 | interface: Interface 74 | 75 | 76 | class TransferRates(NamedTuple): 77 | """ 78 | Representation of transfer rates. 79 | 80 | Attributes: 81 | rx (int): Received bytes 82 | tx (int): Transferred bytes 83 | """ 84 | 85 | rx: int = 0 86 | tx: int = 0 87 | 88 | 89 | class ConnectionType(StrEnum): 90 | """Connection type definition.""" 91 | 92 | SSH = "SSH" 93 | TELNET = "TELNET" 94 | 95 | 96 | class DNSRecord(TypedDict): 97 | """DNS record representation.""" 98 | 99 | ip: str 100 | host_names: list[str] 101 | 102 | 103 | class AuthConfig(TypedDict): 104 | """ 105 | Authentication configuration 106 | 107 | There are multiple ways to connect to the router, 108 | we recomend using ssh_key with a passphrase if possible 109 | 110 | Attributes: 111 | username (str | None): The username to use 112 | password (str | None): The password to use 113 | required if no ssh_key is set 114 | connection_type (ConnectionType | None): 115 | Defaults to ConnectionType.SSH 116 | ssh_key (str |None): An optional ssh_key 117 | passphrase (str |None): An optional passphrase, used for ssh_key 118 | port (int | None): Defaults to 22 for ssh and 110 for telnet 119 | """ 120 | 121 | username: str | None 122 | password: str | None 123 | connection_type: ConnectionType | None 124 | ssh_key: str | None 125 | passphrase: str | None 126 | port: int | None 127 | 128 | 129 | class Mode(StrEnum): 130 | """Router modes definition.""" 131 | 132 | ROUTER = "router" 133 | AP = "ap" 134 | 135 | 136 | class Settings(NamedTuple): 137 | """ 138 | Settings for communicating with asuswrt router. 139 | 140 | Args: 141 | require_ip (bool | None): Defaults to False 142 | if set to True we will not fetch any device we cannot map ip for 143 | mode (Mode | None): Defaults to Mode.ROUTER 144 | dnsmasq (str | None): Defaults to "/var/lib/misc" 145 | Directory where dnsmasq.leases can be found 146 | wan_interface (str | None): Defaults to eth0 147 | The name of the WAN interface connection 148 | used to get external IP and transfer rates 149 | """ 150 | 151 | require_ip: bool = False 152 | mode: Mode | None = Mode.ROUTER 153 | dnsmasq: str = DEFAULT_DNSMASQ 154 | wan_interface: str = DEFAULT_WAN_INTERFACE 155 | 156 | 157 | class TempCommand(NamedTuple): 158 | """ 159 | Representation of a temperature command. 160 | 161 | Attributes: 162 | cli_command (str): The actual cli-command to run 163 | example "cat /proc/version" 164 | result_location (int): We .split(" ") the result of the command 165 | this tells which index of the resulting list to use 166 | eval_function (Callable): Method we run on the retrieved value 167 | """ 168 | 169 | cli_command: str 170 | result_location: int 171 | eval_function: Callable[..., float] 172 | 173 | 174 | class _Regex(NamedTuple): 175 | """Regex Mapped to a key.""" 176 | 177 | MEMINFO: Pattern[str] = re_compile( 178 | r"(?P