pax_global_header00006660000000000000000000000064147407530540014523gustar00rootroot0000000000000052 comment=db5aba4b96458c64e2d9bc3835ca4d2bd2b82dda tplink-omada-api-release-v1.4.4/000077500000000000000000000000001474075305400164645ustar00rootroot00000000000000tplink-omada-api-release-v1.4.4/.devcontainer/000077500000000000000000000000001474075305400212235ustar00rootroot00000000000000tplink-omada-api-release-v1.4.4/.devcontainer/devcontainer.json000066400000000000000000000032521474075305400246010ustar00rootroot00000000000000// For format details, see https://aka.ms/devcontainer.json. For config options, see the README at: // https://github.com/microsoft/vscode-dev-containers/tree/v0.245.0/containers/docker-existing-dockerfile { "name": "Existing Dockerfile", // Sets the run context to one level up instead of the .devcontainer folder. "context": "..", // Update the 'dockerFile' property if you aren't using the standard 'Dockerfile' filename. "dockerFile": "../Dockerfile.dev", // Use 'forwardPorts' to make a list of ports inside the container available locally. // "forwardPorts": [], // Install package in "dev mode" to expose CLI "postCreateCommand": "pip3 install -e .", // Uncomment when using a ptrace-based debugger like C++, Go, and Rust // "runArgs": [ "--cap-add=SYS_PTRACE", "--security-opt", "seccomp=unconfined" ], // Uncomment to use the Docker CLI from inside the container. See https://aka.ms/vscode-remote/samples/docker-from-docker. // "mounts": [ "source=/var/run/docker.sock,target=/var/run/docker.sock,type=bind" ], // Uncomment to connect as a non-root user if you've added one. See https://aka.ms/vscode-remote/containers/non-root. // "remoteUser": "vscode" "customizations": { "vscode": { "extensions": [ "charliermarsh.ruff", "ms-python.pylint" ] }, "settings": { "editor.formatOnPaste": false, "editor.formatOnSave": true, "editor.formatOnType": true, "files.trimTrailingWhitespace": true, "terminal.integrated.defaultProfile.linux": "zsh", "[python]": { "editor.defaultFormatter": "charliermarsh.ruff" }, "ruff.format.args": [ "--config", "line-length=127" ], "pylint.args": [ "--max-line-length=127" ] } } }tplink-omada-api-release-v1.4.4/.github/000077500000000000000000000000001474075305400200245ustar00rootroot00000000000000tplink-omada-api-release-v1.4.4/.github/workflows/000077500000000000000000000000001474075305400220615ustar00rootroot00000000000000tplink-omada-api-release-v1.4.4/.github/workflows/python-package.yml000066400000000000000000000025521474075305400255220ustar00rootroot00000000000000# This workflow will install Python dependencies, run tests and lint with a variety of Python versions # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python name: Python package on: push: branches: [ "master" ] pull_request: branches: [ "master" ] jobs: build: runs-on: ubuntu-latest strategy: fail-fast: false matrix: python-version: ["3.11"] steps: - uses: actions/checkout@v3 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v3 with: python-version: ${{ matrix.python-version }} - name: Install dependencies run: | python -m pip install --upgrade pip python -m pip install flake8 pytest if [ -f requirements.txt ]; then pip install -r requirements.txt; fi python -m pip install build - name: Lint with flake8 run: | # stop the build if there are Python syntax errors or undefined names flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics - name: Test with pytest run: | pytest - name: Build package run: python -m build tplink-omada-api-release-v1.4.4/.github/workflows/python-publish.yml000066400000000000000000000020771474075305400255770ustar00rootroot00000000000000# This workflow will upload a Python Package using Twine when a release is created # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python#publishing-to-package-registries # This workflow uses actions that are not certified by GitHub. # They are provided by a third-party and are governed by # separate terms of service, privacy policy, and support # documentation. name: Upload Python Package on: release: types: [published] permissions: contents: read jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - name: Set up Python uses: actions/setup-python@v3 with: python-version: '3.11.x' - name: Install dependencies run: | python -m pip install --upgrade pip pip install build - name: Build package run: python -m build - name: Publish package uses: pypa/gh-action-pypi-publish@27b31702a0e7fc50959f5ad993c78deac1bdfc29 with: user: __token__ password: ${{ secrets.PYPI_API_TOKEN }} tplink-omada-api-release-v1.4.4/.gitignore000066400000000000000000000000321474075305400204470ustar00rootroot00000000000000**/__pycache__/** dist/** tplink-omada-api-release-v1.4.4/.pylintrc000066400000000000000000000000341474075305400203260ustar00rootroot00000000000000[FORMAT] max-line-length=120tplink-omada-api-release-v1.4.4/.vscode/000077500000000000000000000000001474075305400200255ustar00rootroot00000000000000tplink-omada-api-release-v1.4.4/.vscode/launch.json000066400000000000000000000064731474075305400222040ustar00rootroot00000000000000{ // Use IntelliSense to learn about possible attributes. // Hover to view descriptions of existing attributes. // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 "version": "0.2.0", "configurations": [ { "name": "CLI: List Devices", "type": "debugpy", "request": "launch", "module": "tplink_omada_client.cli", "justMyCode": true, "args": ["devices"] }, { "name": "CLI: List Targets", "type": "debugpy", "request": "launch", "module": "tplink_omada_client.cli", "justMyCode": true, "args": ["targets"] }, { "name": "CLI: Update Target", "type": "debugpy", "request": "launch", "module": "tplink_omada_client.cli", "justMyCode": true, "args": ["--target", "OC200b", "target", "--no-verify-ssl"] }, { "name": "CLI: List Switches", "type": "debugpy", "request": "launch", "module": "tplink_omada_client.cli", "justMyCode": true, "args": ["switches"] }, { "name": "CLI: Get Switch", "type": "debugpy", "request": "launch", "module": "tplink_omada_client.cli", "justMyCode": true, "args": ["switch", "Main Switch", "--dump"] }, { "name": "CLI: Get Switch Ports", "type": "debugpy", "request": "launch", "module": "tplink_omada_client.cli", "justMyCode": true, "args": ["switch_ports", "Main Switch", "-t", "-p", "5"] }, { "name": "CLI: Get Gateway", "type": "debugpy", "request": "launch", "module": "tplink_omada_client.cli", "justMyCode": true, "args": ["gateway", "--dump"] }, { "name": "CLI: Get Gateway Port Details", "type": "debugpy", "request": "launch", "module": "tplink_omada_client.cli", "justMyCode": true, "args": ["wan", "--port", "2"] }, { "name": "CLI: Set Port Port PoE", "type": "debugpy", "request": "launch", "module": "tplink_omada_client.cli", "justMyCode": true, "args": ["poe", "Main Switch", "--port", "2", "--on", "-d"] }, { "name": "CLI: Get Clients", "type": "debugpy", "request": "launch", "module": "tplink_omada_client.cli", "justMyCode": true, "args": ["clients"] }, { "name": "CLI: Get Client", "type": "debugpy", "request": "launch", "module": "tplink_omada_client.cli", "justMyCode": true, "args": ["client", "Nanoleaf", "--dump"] }, { "name": "CLI: Upload certificate", "type": "debugpy", "request": "launch", "module": "tplink_omada_client.cli", "justMyCode": true, "args": ["upload-certificate", "/workspaces/tplink-omada-api/omada3.pfx"] } ] }tplink-omada-api-release-v1.4.4/.vscode/settings.json000066400000000000000000000005761474075305400225700ustar00rootroot00000000000000{ "editor.formatOnPaste": false, "editor.formatOnSave": true, "editor.formatOnType": true, "files.trimTrailingWhitespace": true, "terminal.integrated.defaultProfile.linux": "zsh", "[python]": { "editor.defaultFormatter": "charliermarsh.ruff" }, "ruff.format.args": [ "--config", "line-length=127" ], "pylint.args": [ "--max-line-length=127" ] }tplink-omada-api-release-v1.4.4/Dockerfile.dev000066400000000000000000000014171474075305400212360ustar00rootroot00000000000000FROM mcr.microsoft.com/devcontainers/python:1-3.11 SHELL ["/bin/bash", "-o", "pipefail", "-c"] RUN \ curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - \ && apt-get update \ && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ # Additional library needed by some tests and accordingly by VScode Tests Discovery git \ cmake \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* WORKDIR /workspaces # Install Python dependencies from requirements COPY requirements.txt ./ COPY requirements_test.txt ./ RUN pip3 install -r requirements.txt RUN pip3 install -r requirements_test.txt RUN rm -f requirements.txt requirements_test.txt # Set the default shell to bash instead of sh ENV SHELL /bin/bash tplink-omada-api-release-v1.4.4/LICENSE000066400000000000000000000020541474075305400174720ustar00rootroot00000000000000Copyright 2022-2024 Mark Godwin, Mike Heath 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. tplink-omada-api-release-v1.4.4/README.md000066400000000000000000000054471474075305400177550ustar00rootroot00000000000000# What's this? A basic Python client for calling the TP-Link Omada controller API. ## Installation ```console pip install tplink-omada-client ``` Note: Version 1.4 and later requires Python 3.11. ## Supported features Only a subset of the controller's features are supported: * Automatic Login/Re-login * Basic controller information * List sites * Within site: * List Devices (APs, Gateways and Switches) * Basic device information * Get firmware information and initiating automatic updates * Port status and configuraton for Switches * Lan port configuration for Access Points * Gateway port status and WAN port Connect/Disconnect control Tested with OC200 on Omada Controller Version 5.5.7 - 5.12.x. Other versions may not be fully compatible. Version 5.0.x is definitely not compatible. ## CLI This package provides a simple CLI for interacting with one or more Omada Controllers. To start using the CLI, you must first target a Controller. ```sh $ omada -t NAME target --url https://your.omada.controller.here --user admin --password password --site MySite --set-default ``` Where `NAME` is a name of your choosing to identify the targeted controller. `--site` defaults to the Omada default site name, 'Default'. If you do not provide a password as an argument, you will be prompted for a password. Once you have successfully targeted a controller you can test that things are working by running: ```sh $ omada devices ``` This will list all the devices being managed by your controller. To see a list of all the available commands, run: ```sh $ omada -h ``` You can set up multiple targets (controllers and sites), and specify the target with the `-t ` parameter. If you don't specify a target, the default will be used, if that has been set. The CLI is still young so if there is any functionality you need, please create an issue and let us know. ## Future The available API surface is quite large. More of this could be exposed in the future. There is an undocumented Websocket API which could potentially be used to get a stream of updates. However, I'm not sure how fully featured this subscription channel is on the controller. It seems to be rarely used, so probably doesn't include client connect/disconnect notifications. The Omada platform is transitioning to a new OpenAPI API which this library will need to switch over to using eventually. We will try to avoid breaking changes when this happens, but some will be unavoidable - particularly authentication. At the moment, the new API imposes severe daily call limits, even though it is a local device API. Hopefully this will change, because it is unusable as it stands. ## Contributing There is a VS Code development container, which sets up all of the requirements for running the package. ## License MIT Open Source license. tplink-omada-api-release-v1.4.4/pyproject.toml000066400000000000000000000014441474075305400214030ustar00rootroot00000000000000[build-system] requires = ["hatchling"] build-backend = "hatchling.build" [project] name = "tplink_omada_client" version = "1.4.4" authors = [ { name="Mark Godwin", email="10632972+MarkGodwin@users.noreply.github.com" }, ] description = "Python wrapper for TP-Link Omada SDN Controller API (OC200/OC300/Software Controller)" readme = "README.md" requires-python = ">=3.11" classifiers = [ "Programming Language :: Python :: 3.11", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", ] dependencies = [ "aiohttp >= 3.9.3, <4", "awesomeversion >= 22.9.0" ] [project.scripts] omada = "tplink_omada_client.cli:main" [project.urls] "Homepage" = "https://github.com/MarkGodwin/tplink-omada-api" "Bug Tracker" = "https://github.com/MarkGodwin/tplink-omada-api/issues" tplink-omada-api-release-v1.4.4/requirements.txt000066400000000000000000000004341474075305400217510ustar00rootroot00000000000000aiohttp==3.9.3 #async_timeout==4.0.2 #attrs==21.2.0 awesomeversion==22.9.0 #bcrypt==3.1.7 #certifi>=2021.5.30 #ciso8601==2.2.0 #ifaddr==0.1.7 #lru-dict==1.1.8 #cryptography==37.0.4 #orjson==3.7.11 #pip>=21.0,<22.3 #python-slugify==4.0.1 #typing-extensions>=3.10.0.2,<5.0 #yarl==1.7.2 tplink-omada-api-release-v1.4.4/requirements_test.txt000066400000000000000000000005101474075305400230030ustar00rootroot00000000000000# linters such as flake8 and pylint should be pinned, as new releases # make new things fail. Manually update these pins when pulling in a # new version # types-* that have versions roughly corresponding to the packages they # contain hints for available should be kept in sync with them coverage==7.4.4 pytest-aiohttp==1.0.5 tplink-omada-api-release-v1.4.4/sample_client.py000077500000000000000000000064571474075305400216740ustar00rootroot00000000000000#!/usr/bin/env python3 """ Cmd line to work out the API. """ import asyncio import sys from pprint import pprint from src.tplink_omada_client.omadaclient import OmadaClient from src.tplink_omada_client.omadasiteclient import AccessPointPortSettings async def do_the_magic(url: str, username: str, password: str): """Not a real test client.""" async with OmadaClient(url, username, password, verify_ssl=False) as client: print(f"Found Omada Controller: {await client.get_controller_name()}") sites = await client.get_sites() print(f"Found {len(sites)} sites") for site in sites: print(f"Connecting to {site.name}") site_client = await client.get_site_client(sites[0]) devices = await site_client.get_devices() print(f"Found {len(devices)} Omada devices in site {site.name}.") print(f" {len([d for d in devices if d.type == 'ap'])} Access Points.") print(f" {len([d for d in devices if d.type == 'switch'])} Switches.") print(f" {len([d for d in devices if d.type == 'gateway'])} Routers.") for firmware_details in [ await site_client.get_firmware_details(d) for d in devices if d.need_upgrade ]: print("Available firmware upgrade:") pprint(vars(firmware_details)) access_points = [ await site_client.get_access_point(a) for a in devices if a.type == "ap" ] for access_point in access_points: print(f"Access Point: {access_point.name}") if access_point.name == "Office": port_status = await site_client.update_access_point_port( access_point, "ETH3", AccessPointPortSettings(enable_poe=True) ) pprint(vars(port_status)) port_status = await site_client.update_access_point_port( access_point, "ETH3", AccessPointPortSettings(enable_poe=False) ) pprint(vars(port_status)) # pprint(vars(devices[0])) # Get full info of all switches switches = [ await site_client.get_switch(s) for s in devices if s.type == "switch" ] # pprint(vars(switches[0])) # ports = await client.get_switch_ports(switches[0]) port = await site_client.get_switch_port(switches[0], switches[0].ports[4]) print(f"Port index 4: {port.name} Profile: {port.profile_name}") pprint(vars(port)) updated_port = await site_client.update_switch_port( switches[0], port, new_name="Port5" ) pprint(vars(updated_port)) profiles = await site_client.get_port_profiles() pprint(vars(profiles[0])) print("Done.") def main(): """Basic sample test client.""" if len(sys.argv) < 2 or len(sys.argv) > 4: print("Usage: client [username] [password]") return omadaurl = sys.argv[1] username = "admin" if len(sys.argv) < 3 else sys.argv[2] password = "admin" if len(sys.argv) < 4 else sys.argv[3] asyncio.run(do_the_magic(omadaurl, username, password)) main() tplink-omada-api-release-v1.4.4/src/000077500000000000000000000000001474075305400172535ustar00rootroot00000000000000tplink-omada-api-release-v1.4.4/src/tplink_omada_client/000077500000000000000000000000001474075305400232535ustar00rootroot00000000000000tplink-omada-api-release-v1.4.4/src/tplink_omada_client/__init__.py000066400000000000000000000012531474075305400253650ustar00rootroot00000000000000"""TP-Link Omada API Client""" from .devices import OmadaSwitchPortDetails from .omadaclient import OmadaClient, OmadaSite from .omadasiteclient import ( OmadaSiteClient, SwitchPortOverrides, AccessPointPortSettings, GatewayPortSettings, OmadaClientSettings, OmadaClientFixedAddress, ) from . import definitions from . import exceptions from . import clients __all__ = [ "OmadaClient", "OmadaSite", "OmadaSiteClient", "AccessPointPortSettings", "GatewayPortSettings", "OmadaClientSettings", "OmadaClientFixedAddress", "SwitchPortOverrides", "OmadaSwitchPortDetails", "definitions", "exceptions", "clients", ] tplink-omada-api-release-v1.4.4/src/tplink_omada_client/cli/000077500000000000000000000000001474075305400240225ustar00rootroot00000000000000tplink-omada-api-release-v1.4.4/src/tplink_omada_client/cli/__init__.py000066400000000000000000000045751474075305400261460ustar00rootroot00000000000000"""TP-Link Omada CLI""" import argparse import asyncio import sys from typing import Sequence from tplink_omada_client.exceptions import LoginFailed from . import ( command_access_point, command_access_points, command_block_client, command_certificate, command_client, command_clients, command_default, command_devices, command_gateway, command_known_clients, command_poe, command_reboot, command_reconnect_client, command_set_client_name, command_set_device_led, command_switch, command_switch_ports, command_switches, command_target, command_targets, command_unblock_client, command_wan, ) def main(argv: Sequence[str] | None = None) -> int: """Entry point for Omada CLI""" if argv is None: argv = sys.argv[1:] parser = argparse.ArgumentParser() parser.add_argument("-t", "--target", help="The target Omada controller", default="") subparsers = parser.add_subparsers( title="Available commands", metavar="command", ) command_access_point.arg_parser(subparsers) command_access_points.arg_parser(subparsers) command_block_client.arg_parser(subparsers) command_client.arg_parser(subparsers) command_clients.arg_parser(subparsers) command_default.arg_parser(subparsers) command_devices.arg_parser(subparsers) command_gateway.arg_parser(subparsers) command_known_clients.arg_parser(subparsers) command_poe.arg_parser(subparsers) command_reboot.arg_parser(subparsers) command_reconnect_client.arg_parser(subparsers) command_certificate.arg_parser(subparsers) command_set_client_name.arg_parser(subparsers) command_set_device_led.arg_parser(subparsers) command_switch.arg_parser(subparsers) command_switch_ports.arg_parser(subparsers) command_switches.arg_parser(subparsers) command_target.arg_parser(subparsers) command_targets.arg_parser(subparsers) command_unblock_client.arg_parser(subparsers) command_wan.arg_parser(subparsers) try: args = parser.parse_args(args=argv) if "func" in args: return asyncio.run(args.func(vars(args))) parser.print_help() return 1 except argparse.ArgumentError as error: parser.print_usage() print(error.message) return 1 except LoginFailed as error: print(error) return 2 tplink-omada-api-release-v1.4.4/src/tplink_omada_client/cli/__main__.py000066400000000000000000000002001474075305400261040ustar00rootroot00000000000000"""Module execution entry point""" import sys from . import main if __name__ == "__main__": sys.exit(main(sys.argv[1:])) tplink-omada-api-release-v1.4.4/src/tplink_omada_client/cli/command_access_point.py000066400000000000000000000053101474075305400305430ustar00rootroot00000000000000"""Implementation for 'access-point' command""" from argparse import ArgumentParser from .config import get_target_config, to_omada_connection from .util import ( dump_raw_data, get_checkbox_char, get_device_mac, get_power_char, get_target_argument, ) async def command_access_point(args) -> int: """Executes 'access-point' command""" controller = get_target_argument(args) config = get_target_config(controller) async with to_omada_connection(config) as client: site_client = await client.get_site_client(config.site) mac = await get_device_mac(site_client, args["mac"]) access_point = await site_client.get_access_point(mac) print(f"Name: {access_point.name}") print(f"Address: {access_point.mac} ({access_point.ip_address})") print(f"Status: {access_point.status.name} ({access_point.status_category.name})") print(f"LAN Ports: {len(access_point.lan_port_settings)}") for p in access_point.lan_port_settings: print(f" Port {p.port_name} ", end="") print( f"PoE Supported: {get_checkbox_char(p.supports_poe)} {get_power_char(p.poe_enable)} ", end="", ) print(f"Vlan: {get_checkbox_char(p.supports_vlan and p.local_vlan_enable)} {p.local_vlan_id}") print(f"Model: {access_point.model_display_name}") print(f"LED Setting: {access_point.led_setting.name}") print(f"Uptime: {access_point.display_uptime}") print(f"WiFi uplink: {get_checkbox_char(access_point.wireless_linked)}") uplink = access_point.wired_uplink if uplink is not None: print(f"Uplink switch: {uplink.mac} {uplink.name}") else: print("Uplink switch: ") print("WiFi Features:") print(f" 802.11ag: {get_checkbox_char(access_point.supports_11ac)}") print(f" 5G: {get_checkbox_char(access_point.supports_5g)}") print(f" 5G2: {get_checkbox_char(access_point.supports_5g2)}") print(f" Wifi 6: {get_checkbox_char(access_point.supports_6g)}") print(f" Mesh: {get_checkbox_char(access_point.supports_mesh)}") dump_raw_data(args, access_point) return 0 def arg_parser(subparsers) -> None: """Configures arguments parser for 'access-point' command""" parser: ArgumentParser = subparsers.add_parser("access-point", help="Shows details about the specified access point") parser.set_defaults(func=command_access_point) parser.add_argument( "mac", help="The MAC address or name of the access point", ) parser.add_argument("-d", "--dump", help="Output raw device information", action="store_true") tplink-omada-api-release-v1.4.4/src/tplink_omada_client/cli/command_access_points.py000066400000000000000000000031651474075305400307340ustar00rootroot00000000000000"""Implementation for 'access-points' command""" from argparse import _SubParsersAction from .config import get_target_config, to_omada_connection from .util import dump_raw_data, get_checkbox_char, get_target_argument async def command_access_points(args) -> int: """Executes 'access-points' command""" controller = get_target_argument(args) config = get_target_config(controller) async with to_omada_connection(config) as client: site_client = await client.get_site_client(config.site) for access_point in await site_client.get_access_points(): print(f"{access_point.mac} {access_point.ip_address:>15} {access_point.name:20} ", end="") print(f"11ac: {get_checkbox_char(access_point.supports_11ac)} ", end="") print(f"5g: {get_checkbox_char(access_point.supports_5g)} ", end="") print(f"5g2: {get_checkbox_char(access_point.supports_5g2)} ", end="") print(f"6g: {get_checkbox_char(access_point.supports_6g)} ", end="") print(f"mesh: {get_checkbox_char(access_point.supports_mesh)} ", end="") print(f" {access_point.model_display_name:20} ", end="") print() dump_raw_data(args, access_point) return 0 def arg_parser(subparsers: _SubParsersAction) -> None: """Configures arguments parser for 'access-points' command""" parser = subparsers.add_parser("access-points", aliases=["ap"], help="Lists access points managed by Omada Controller") parser.set_defaults(func=command_access_points) parser.add_argument("-d", "--dump", help="Output raw device information", action="store_true") tplink-omada-api-release-v1.4.4/src/tplink_omada_client/cli/command_block_client.py000066400000000000000000000017561474075305400305330ustar00rootroot00000000000000"""Implementation for 'client' command""" from argparse import _SubParsersAction from .config import get_target_config, to_omada_connection from .util import get_client_mac, get_target_argument async def command_block_client(args) -> int: """Executes 'block-client' command""" controller = get_target_argument(args) config = get_target_config(controller) async with to_omada_connection(config) as client: site_client = await client.get_site_client(config.site) mac = await get_client_mac(site_client, args["mac"]) await site_client.block_client(mac) return 0 def arg_parser(subparsers: _SubParsersAction) -> None: """Configures arguments parser for 'block-client' command""" block_parser = subparsers.add_parser("block-client", help="Blocks a client from the accessing the network") block_parser.add_argument( "mac", help="The MAC address or name of the client", ) block_parser.set_defaults(func=command_block_client) tplink-omada-api-release-v1.4.4/src/tplink_omada_client/cli/command_certificate.py000066400000000000000000000023131474075305400303530ustar00rootroot00000000000000"""Implementation for 'set-certificate' command""" from argparse import ArgumentParser import getpass from .config import get_target_config, to_omada_connection from .util import get_target_argument async def command_certificate(args) -> int: """Executes 'set-certificate' command""" controller = get_target_argument(args) config = get_target_config(controller) if args["password"]: password = args["password"] else: password = getpass.getpass() async with to_omada_connection(config) as client: await client.set_certificate(args["cert-file"], password) print("Certificate uploaded successfully, and enabled. Please reboot the controller to apply the changes.") return 0 def arg_parser(subparsers) -> None: """Configures arguments parser for 'set-certificate' command""" parser: ArgumentParser = subparsers.add_parser("set-certificate", help="Sets a new certificate for the Omada controller.") parser.set_defaults(func=command_certificate) parser.add_argument("cert-file", help="The certificate file to upload. Must be in PKCS12 PFX format.") parser.add_argument("-p", "--password", help="The password for the certificate", required=False) tplink-omada-api-release-v1.4.4/src/tplink_omada_client/cli/command_client.py000066400000000000000000000104101474075305400273440ustar00rootroot00000000000000"""Implementation for 'client' command""" from argparse import _SubParsersAction, ArgumentError import datetime from tplink_omada_client.clients import OmadaConnectedClient, OmadaWiredClientDetails, OmadaWirelessClientDetails from tplink_omada_client.omadasiteclient import OmadaClientFixedAddress, OmadaClientSettings from .config import get_target_config, to_omada_connection from .util import dump_raw_data, get_client_mac, get_target_argument async def command_client(args) -> int: """Executes 'client' command""" controller = get_target_argument(args) config = get_target_config(controller) async with to_omada_connection(config) as client: site_client = await client.get_site_client(config.site) mac = await get_client_mac(site_client, args["mac"]) if args["set_name"] or args["lock_to_ap"] or args["unlock"] or args["fixed_ip"] or args["dynamic_ip"]: settings = OmadaClientSettings() if args["set_name"]: settings.name = args["set_name"] if args["lock_to_ap"]: settings.lock_to_aps = args["lock_to_ap"] if args["unlock"]: settings.lock_to_aps = [] if args["dynamic_ip"]: settings.fixed_address = OmadaClientFixedAddress() elif args["fixed_ip"]: if not args["network"]: raise ArgumentError(args["network"], "Network ID must be specified when reserving an IP address") settings.fixed_address = OmadaClientFixedAddress(network_id=args["network"], ip_address=args["fixed_ip"]) client = await site_client.update_client(mac, settings) else: client = await site_client.get_client(mac) print_client(client) dump_raw_data(args, client) return 0 def print_client(client: OmadaConnectedClient): """Prints details of a client to the console.""" print(f"Name: {client.name}") print(f"MAC: {client.mac}") print(f"Connection type: {client.connect_type.name}") if client.ip: print(f"IP: {client.ip}") if client.host_name: print(f"Hostname: {client.host_name}") print(f"Blocked: {client.is_blocked}") if client.is_active: uptime = str(datetime.timedelta(seconds=float(client.connection_time or 0))) print(f"Uptime: {uptime}") if isinstance(client, OmadaWiredClientDetails): if client.connect_dev_type == "switch": print(f"Switch: {client.switch_name} ({client.switch_mac})") print(f"Switch port: {client.port}") elif client.connect_dev_type == "gateway": print(f"Gateway: {client.gateway_name} ({client.gateway_mac})") elif isinstance(client, OmadaWirelessClientDetails): print(f"SSID: {client.ssid}") print(f"Access Point: {client.ap_name} ({client.ap_mac})") def list_of_strings(arg): """Converts a comma-separated list of strings into a list of strings.""" return arg.split(",") def arg_parser(subparsers: _SubParsersAction) -> None: """Configures arguments parser for 'client' command""" client_parser = subparsers.add_parser("client", help="Shows details of a client known to Omada controller") client_parser.add_argument( "mac", help="The MAC address or name of the client", ) client_parser.add_argument("-sn", "--set-name", help="Set the client's name", metavar="NAME") lock_grp = client_parser.add_mutually_exclusive_group() lock_grp.add_argument( "-l", "--lock-to-ap", help="Lock the client to the specified access point(s)", metavar="MACs", type=list_of_strings ) lock_grp.add_argument("-u", "--unlock", help="Unlock the client", action="store_true") fixed_ip_grp = client_parser.add_argument_group("IP Reservation") fixed_ip_en_dis_grp = fixed_ip_grp.add_mutually_exclusive_group() fixed_ip_en_dis_grp.add_argument("-ip", "--fixed-ip", help="Reserve the client's IP address") fixed_ip_en_dis_grp.add_argument("-dyn", "--dynamic-ip", help="Remove the client's IP reservation", action="store_true") fixed_ip_grp.add_argument("-n", "--network", help="Network ID for reservation") client_parser.add_argument("-d", "--dump", help="Output raw client information", action="store_true") client_parser.set_defaults(func=command_client) tplink-omada-api-release-v1.4.4/src/tplink_omada_client/cli/command_clients.py000066400000000000000000000032031474075305400275310ustar00rootroot00000000000000"""Implementation for 'clients' command""" from argparse import _SubParsersAction from tplink_omada_client.clients import OmadaWiredClient, OmadaWirelessClient from .config import get_target_config, to_omada_connection from .util import dump_raw_data, get_target_argument async def command_clients(args) -> int: """Executes 'clients' command""" controller = get_target_argument(args) config = get_target_config(controller) async with to_omada_connection(config) as client: site_client = await client.get_site_client(config.site) async for client in site_client.get_connected_clients(): if client.ip: ip = client.ip else: ip = "-" print(f"{client.mac} {ip:15} {(client.name if not None else ''):20} ", end="") if isinstance(client, OmadaWiredClient): if client.connect_dev_type == "switch": print(f"{client.switch_name} ({client.port})") elif client.connect_dev_type == "gateway": print(f"{client.gateway_name}") elif isinstance(client, OmadaWirelessClient): print(f"{client.ssid} ({client.ap_name})") dump_raw_data(args, client) return 0 def arg_parser(subparsers: _SubParsersAction) -> None: """Configures arguments parser for 'clients' command""" clients_parser = subparsers.add_parser("clients", aliases=["c"], help="Lists clients connected to site network") clients_parser.add_argument("-d", "--dump", help="Output raw client information", action="store_true") clients_parser.set_defaults(func=command_clients) tplink-omada-api-release-v1.4.4/src/tplink_omada_client/cli/command_default.py000066400000000000000000000012071474075305400275160ustar00rootroot00000000000000"""Implementation of 'default' command""" from argparse import _SubParsersAction from .util import assert_target_argument from .config import set_default_target, get_target_config async def command_target(args) -> int: """Executes 'default' command""" target = assert_target_argument(args) get_target_config(target) set_default_target(target) return 0 def arg_parser(subparsers: _SubParsersAction) -> None: """Configures argument parser for 'default' command""" set_parser = subparsers.add_parser( "default", help="Sets the default target", ) set_parser.set_defaults(func=command_target) tplink-omada-api-release-v1.4.4/src/tplink_omada_client/cli/command_devices.py000066400000000000000000000022221474075305400275120ustar00rootroot00000000000000"""Implementation for 'devices' command""" from argparse import _SubParsersAction from .config import get_target_config, to_omada_connection from .util import dump_raw_data, get_target_argument async def command_devices(args) -> int: """Executes 'devices' command""" controller = get_target_argument(args) config = get_target_config(controller) async with to_omada_connection(config) as client: site_client = await client.get_site_client(config.site) for device in await site_client.get_devices(): print(f"{device.mac} {device.ip_address:>15} {device.type:>7} {device.status_category.name:16} ", end="") print(f"{device.name:20} {device.model_display_name}") dump_raw_data(args, device) return 0 def arg_parser(subparsers: _SubParsersAction) -> None: """Configures arguments parser for 'devices' command""" devices_parser = subparsers.add_parser("devices", aliases=["d"], help="Lists devices managed by Omada Controller") devices_parser.add_argument("-d", "--dump", help="Output raw device information", action="store_true") devices_parser.set_defaults(func=command_devices) tplink-omada-api-release-v1.4.4/src/tplink_omada_client/cli/command_gateway.py000066400000000000000000000060721474075305400275400ustar00rootroot00000000000000"""Implementation for 'gateway' command""" from argparse import ArgumentParser from tplink_omada_client.definitions import GatewayPortMode, PoEMode from .config import get_target_config, to_omada_connection from .util import ( dump_raw_data, get_checkbox_char, get_display_bytes, get_link_status_char, get_device_mac, get_power_char, get_target_argument, ) async def command_gateway(args) -> int: """Executes 'gateway' command""" controller = get_target_argument(args) config = get_target_config(controller) async with to_omada_connection(config) as client: site_client = await client.get_site_client(config.site) mac = args["mac"] if mac: mac = await get_device_mac(site_client, mac) gateway = await site_client.get_gateway(mac) print(f"Name: {gateway.name}") print(f"Address: {gateway.mac} ({gateway.ip_address})") print(f"Status: {gateway.status.name} ({gateway.status_category.name})") print(f"Ports: {gateway.number_of_ports}") print(f"Supports PoE: {gateway.supports_poe}") print(f"Model: {gateway.model_display_name}") print(f"Uptime: {gateway.display_uptime}") wan_ports = (p for p in gateway.port_status if p.mode == GatewayPortMode.WAN) lan_ports = (p for p in gateway.port_configs if p.port_status.mode == GatewayPortMode.LAN) print("WAN Ports:") print(" No. Name IP Addresss Proto Link Online Received Transmitted") for p in wan_ports: print( f" Port: {p.port_number:>2} {p.type.name:7} {p.ip:>15} {p.wan_protocol:>8} ", end="", ) print("\u2611 " if p.wan_connected else "\u2610 ", end="") print("\u2611" if p.online_detection else "\u2610", end="") print(f" {get_display_bytes(p.bytes_rx):>12} {get_display_bytes(p.bytes_tx):>12}") print("LAN Ports:") print(" No. Name Link PoE Received Transmitted") for p in lan_ports: ps = p.port_status print( f" Port: {ps.port_number:>2} {ps.type.name:7} {get_link_status_char(ps.link_status)} ", end="", ) print( f"{get_checkbox_char(p.poe_mode == PoEMode.ENABLED)} {get_power_char(ps.poe_active)} ", end="", ) print(f"{get_display_bytes(ps.bytes_rx):>12} {get_display_bytes(ps.bytes_tx):>12}") print(f"LED Setting: {gateway.led_setting.name}") dump_raw_data(args, gateway) return 0 def arg_parser(subparsers) -> None: """Configures arguments parser for 'gateway' command""" switch_parser: ArgumentParser = subparsers.add_parser("gateway", help="Shows details about the site's gateway") switch_parser.set_defaults(func=command_gateway) switch_parser.add_argument("--mac", help="The MAC address of the gateway (optional)", required=False) switch_parser.add_argument("-d", "--dump", help="Output raw device information", action="store_true") tplink-omada-api-release-v1.4.4/src/tplink_omada_client/cli/command_known_clients.py000066400000000000000000000026531474075305400307550ustar00rootroot00000000000000"""Implementation for 'known-clients' command""" from argparse import _SubParsersAction from datetime import datetime from .config import get_target_config, to_omada_connection from .util import dump_raw_data, get_target_argument async def command_known_clients(args) -> int: """Executes 'known-clients' command""" controller = get_target_argument(args) config = get_target_config(controller) async with to_omada_connection(config) as client: site_client = await client.get_site_client(config.site) async for client in site_client.get_known_clients(): if client.mac == client.name: name = "" else: name = f" {client.name}" if client.is_blocked: blocked = "blocked" else: blocked = "" lastseen = str(datetime.utcfromtimestamp(client.last_seen)) print(f"{client.mac}{name:<25}{blocked:<8} {lastseen}") dump_raw_data(args, client) return 0 def arg_parser(subparsers: _SubParsersAction) -> None: """Configures arguments parser for 'known-clients' command""" known_clients_parser = subparsers.add_parser("known-clients", help="Lists all clients known to the Omada controller") known_clients_parser.add_argument("-d", "--dump", help="Output raw client information", action="store_true") known_clients_parser.set_defaults(func=command_known_clients) tplink-omada-api-release-v1.4.4/src/tplink_omada_client/cli/command_poe.py000066400000000000000000000072171474075305400266640ustar00rootroot00000000000000"""Implementation for 'poe' command""" from argparse import ArgumentError, ArgumentParser from tplink_omada_client.definitions import OmadaApiData, PoEMode from tplink_omada_client.devices import OmadaDevice from tplink_omada_client.omadasiteclient import ( AccessPointPortSettings, GatewayPortSettings, OmadaSiteClient, SwitchPortOverrides, ) from .config import get_target_config, to_omada_connection from .util import dump_raw_data, get_device_by_mac_or_name, get_target_argument async def _set_gateway_poe( site_client: OmadaSiteClient, device: OmadaDevice, port: int, change: bool, on: bool ) -> OmadaApiData: if change: result = await site_client.set_gateway_port_settings(port, GatewayPortSettings(enable_poe=on), device) else: result = await site_client.get_gateway_port(port, device) print(f"Gateway {device.name} Port {port} PoE is {result.poe_mode.name}") return result async def _set_switch_poe( site_client: OmadaSiteClient, device: OmadaDevice, port: int, change: bool, on: bool ) -> OmadaApiData: if change: result = await site_client.update_switch_port(device, port, overrides=SwitchPortOverrides(enable_poe=on)) else: result = await site_client.get_switch_port(device, port) print(f"Switch {device.name} Port {port} PoE now is {result.poe_mode.name}") return result async def _set_access_point_poe( site_client: OmadaSiteClient, device: OmadaDevice, port: int, change: bool, on: bool ) -> OmadaApiData: if change: result = await site_client.update_access_point_port(device, f"ETH{port}", AccessPointPortSettings(enable_poe=on)) else: result = await site_client.get_access_point_port(device, f"ETH{port}") print(f"Access point {device.name} Port {result.port_name} ", end="") print( f"PoE is {(PoEMode.ENABLED if result.poe_enable else (PoEMode.DISABLED if result.supports_poe else PoEMode.NONE)).name}" ) return result async def command_poe(args) -> int: """Executes 'poe' command""" controller = get_target_argument(args) config = get_target_config(controller) async with to_omada_connection(config) as client: site_client = await client.get_site_client(config.site) device = await get_device_by_mac_or_name(site_client, args["mac"]) port = int(args["port"]) change = args["on"] or args["off"] on = bool(args["on"]) match device.type: case "gateway": handler = _set_gateway_poe case "switch": handler = _set_switch_poe case "ap": handler = _set_access_point_poe case _: raise ArgumentError(args["mac"], "Device type not supported") result = await handler(site_client, device, port, change, on) dump_raw_data(args, result) return 0 def arg_parser(subparsers) -> None: """Configures arguments parser for 'poe' command""" switch_parser: ArgumentParser = subparsers.add_parser("poe", help="Controls a device's PoE ports") switch_parser.set_defaults(func=command_poe) switch_parser.add_argument("mac", help="The MAC address or name of the gateway, switch or access point with PoE ports") switch_parser.add_argument("-p", "--port", help="The port number on the device to set the PoE state.", required=True) con_discon_grp = switch_parser.add_mutually_exclusive_group() con_discon_grp.add_argument("--on", help="Turn PoE On", action="store_true") con_discon_grp.add_argument("--off", help="Turn PoE Off", action="store_true") switch_parser.add_argument("-d", "--dump", help="Output raw port information", action="store_true") tplink-omada-api-release-v1.4.4/src/tplink_omada_client/cli/command_reboot.py000066400000000000000000000014321474075305400273640ustar00rootroot00000000000000"""Implementation for 'reboot' command""" from argparse import ArgumentParser from .config import get_target_config, to_omada_connection from .util import get_target_argument async def command_reboot(args) -> int: """Executes 'reboot' command""" controller = get_target_argument(args) config = get_target_config(controller) async with to_omada_connection(config) as client: reboot_time = await client.reboot() print(f"Controller is rebooting, and should be back up in approximately {reboot_time} seconds.") return 0 def arg_parser(subparsers) -> None: """Configures arguments parser for 'gateway' command""" parser: ArgumentParser = subparsers.add_parser("reboot", help="Reboot the Omada Controller") parser.set_defaults(func=command_reboot) tplink-omada-api-release-v1.4.4/src/tplink_omada_client/cli/command_reconnect_client.py000066400000000000000000000017671474075305400314230ustar00rootroot00000000000000"""Implementation for 'client' command""" from argparse import _SubParsersAction from .config import get_target_config, to_omada_connection from .util import get_client_mac, get_target_argument async def command_reconnect_client(args) -> int: """Executes 'reconnect-client' command""" controller = get_target_argument(args) config = get_target_config(controller) async with to_omada_connection(config) as client: site_client = await client.get_site_client(config.site) mac = await get_client_mac(site_client, args["mac"]) await site_client.reconnect_client(mac) return 0 def arg_parser(subparsers: _SubParsersAction) -> None: """Configures arguments parser for 'reconnect-client' command""" reconnect_parser = subparsers.add_parser("reconnect-client", help="Reconnects a client") reconnect_parser.add_argument( "mac", help="The MAC address or name of the client", ) reconnect_parser.set_defaults(func=command_reconnect_client) tplink-omada-api-release-v1.4.4/src/tplink_omada_client/cli/command_set_client_name.py000066400000000000000000000026751474075305400312350ustar00rootroot00000000000000"""Implementation for 'set-client-name' command""" from argparse import _SubParsersAction from tplink_omada_client.cli.command_client import print_client from tplink_omada_client.omadasiteclient import OmadaClientSettings from .config import get_target_config, to_omada_connection from .util import dump_raw_data, get_client_mac, get_target_argument async def command_set_client_name(args) -> int: """Executes 'set-client-name' command""" controller = get_target_argument(args) config = get_target_config(controller) async with to_omada_connection(config) as client: site_client = await client.get_site_client(config.site) mac = await get_client_mac(site_client, args["mac"]) name = args["name"] client = await site_client.update_client(mac, OmadaClientSettings(name=name)) print_client(client) dump_raw_data(args, client) return 0 def arg_parser(subparsers: _SubParsersAction) -> None: """Configures arguments parser for 'set-client-name' command""" parser = subparsers.add_parser("set-client-name", help="Sets the name of an omada client") parser.add_argument( "mac", help="The MAC address or name of the client to set the name for", ) parser.add_argument("name", help="The new name of the client") parser.add_argument("-d", "--dump", help="Output raw client information", action="store_true") parser.set_defaults(func=command_set_client_name) tplink-omada-api-release-v1.4.4/src/tplink_omada_client/cli/command_set_device_led.py000066400000000000000000000023271474075305400310340ustar00rootroot00000000000000"""Implementation for 'set-device-led' command""" from argparse import _SubParsersAction from tplink_omada_client.definitions import LedSetting from .config import get_target_config, to_omada_connection from .util import get_device_by_mac_or_name, get_target_argument async def command_set_device_led(args) -> int: """Executes 'set-device-led' command""" controller = get_target_argument(args) config = get_target_config(controller) async with to_omada_connection(config) as client: site_client = await client.get_site_client(config.site) device = await get_device_by_mac_or_name(site_client, args["mac"]) setting = LedSetting[args["mode"]] await site_client.set_led_setting(device, setting) return 0 def arg_parser(subparsers: _SubParsersAction) -> None: """Configures arguments parser for 'set-device-led' command""" parser = subparsers.add_parser("set-device-led", help="Sets the LED mode of an omada device") parser.add_argument( "mac", help="The MAC address or name of the device to set the LED mode of", ) parser.add_argument("mode", help="The LED mode to set (ON|OFF|SITE_SETTINGS)") parser.set_defaults(func=command_set_device_led) tplink-omada-api-release-v1.4.4/src/tplink_omada_client/cli/command_switch.py000066400000000000000000000037251474075305400274020ustar00rootroot00000000000000"""Implementation for 'switch' command""" from argparse import ArgumentParser from .config import get_target_config, to_omada_connection from .util import dump_raw_data, get_device_mac, get_target_argument async def command_switch(args) -> int: """Executes 'switch' command""" controller = get_target_argument(args) config = get_target_config(controller) async with to_omada_connection(config) as client: site_client = await client.get_site_client(config.site) mac = await get_device_mac(site_client, args["mac"]) switch = await site_client.get_switch(mac) print(f"Name: {switch.name}") print(f"Address: {switch.mac} ({switch.ip_address})") print(f"Status: {switch.status.name} ({switch.status_category.name})") print(f"Ports: {switch.number_of_ports}") print(f"Supports PoE: {switch.device_capabilities.supports_poe}") if switch.device_capabilities.supports_poe: print(f"PoE ports: {switch.device_capabilities.poe_ports}") print(f"Model: {switch.model_display_name}") print(f"LED Setting: {switch.led_setting.name}") print(f"Uptime: {switch.display_uptime}") if switch.uplink: print(f"Uplink switch: {switch.uplink.mac} {switch.uplink.name}") if len(switch.downlink) > 0: print("Downlink devices:") for downlink in switch.downlink: print(f"- {downlink.mac} {downlink.name}") dump_raw_data(args, switch) return 0 def arg_parser(subparsers) -> None: """Configures arguments parser for 'switches' command""" switch_parser: ArgumentParser = subparsers.add_parser("switch", help="Shows details about the specified switch") switch_parser.set_defaults(func=command_switch) switch_parser.add_argument( "mac", help="The MAC address or name of the switch", ) switch_parser.add_argument("-d", "--dump", help="Output raw device information", action="store_true") tplink-omada-api-release-v1.4.4/src/tplink_omada_client/cli/command_switch_ports.py000066400000000000000000000102151474075305400306210ustar00rootroot00000000000000"""Implementation for 'switch_ports' command""" from argparse import ArgumentParser from tplink_omada_client.definitions import PoEMode, PortType from tplink_omada_client.devices import OmadaSwitch, OmadaSwitchPortDetails from .config import get_target_config, to_omada_connection from .util import ( dump_raw_data, get_checkbox_char, get_display_bytes, get_link_status_char, get_device_mac, get_power_char, get_target_argument, ) async def command_switch_ports(args) -> int: """Executes 'switch_ports' command""" controller = get_target_argument(args) config = get_target_config(controller) async with to_omada_connection(config) as client: site_client = await client.get_site_client(config.site) mac = await get_device_mac(site_client, args["mac"]) switch = await site_client.get_switch(mac) ports = await site_client.get_switch_ports(mac) filter_port = args["port"] if filter_port: ports = list((p for p in ports if p.port == int(filter_port))) if args["table"]: _print_port_table(switch, ports) else: for p in ports: print(f"Port: {p.port:2} - {p.name}") print(f" Type: {p.type}") print(f" Profile: {p.profile_name} - {get_checkbox_char(p.has_profile_override)}") print(f" Enabled: {get_checkbox_char(not p.is_disabled)}") print(f" Status: {get_link_status_char(p.port_status.link_status)} ") if switch.device_capabilities.supports_poe and p.type != PortType.SFP: print(f" PoE: {get_checkbox_char(p.poe_mode == PoEMode.ENABLED)} ", end="") print(f"{get_power_char(p.port_status.poe_active)} {p.port_status.poe_power}W ") else: print(" PoE: Not supported") print(f" Link Speed: {p.port_status.link_speed.name} (Max: {p.max_speed.name})") print(f" Transmitted: {get_display_bytes(p.port_status.bytes_tx)}") print(f" Received: {get_display_bytes(p.port_status.bytes_rx)}") print(f" Operation: {p.operation}") print(f" Limit Mode: {p.bandwidth_limit_mode.name}") dump_raw_data(args, p) return 0 def _print_port_table(switch: OmadaSwitch, ports: list[OmadaSwitchPortDetails]): print("No. Name Profile Ena Ovr Link Speed PoE Power Received Transmitted") for p in ports: print(f"{p.port:2} {p.name[:12]:12} {p.profile_name[:10]:>10} {get_checkbox_char(not p.is_disabled)} ", end="") print(f"{get_checkbox_char(p.has_profile_override)} {get_link_status_char(p.port_status.link_status)} ", end="") if p.port_status.link_status: print(f"{p.port_status.link_speed.name[6:]:>10} ", end="") else: print(" --- ", end="") if switch.device_capabilities.supports_poe and p.type != PortType.SFP: print(f" {get_checkbox_char(p.poe_mode == PoEMode.ENABLED)}", end="") print(f"{get_power_char(p.port_status.poe_active)} {p.port_status.poe_power:>4}W ", end="") else: print(" x -W ", end="") print(f" {get_display_bytes(p.port_status.bytes_rx):>12} {get_display_bytes(p.port_status.bytes_tx):>12}") def arg_parser(subparsers) -> None: """Configures arguments parser for 'switch_ports' command""" switch_ports_parser: ArgumentParser = subparsers.add_parser( "switch_ports", help="Shows detailed information about the ports on the specified switch" ) switch_ports_parser.set_defaults(func=command_switch_ports) switch_ports_parser.add_argument( "mac", help="The MAC address or name of the switch", ) switch_ports_parser.add_argument("-d", "--dump", help="Output raw device information", action="store_true") switch_ports_parser.add_argument("-t", "--table", help="Show data in a compact table", action="store_true") switch_ports_parser.add_argument("-p", "--port", help="Port number to show information about", default=None) tplink-omada-api-release-v1.4.4/src/tplink_omada_client/cli/command_switches.py000066400000000000000000000026351474075305400277310ustar00rootroot00000000000000"""Implementation for 'switches' command""" from argparse import _SubParsersAction from .config import get_target_config, to_omada_connection from .util import dump_raw_data, get_link_status_char, get_power_char, get_target_argument async def command_switches(args) -> int: """Executes 'switches' command""" controller = get_target_argument(args) config = get_target_config(controller) async with to_omada_connection(config) as client: site_client = await client.get_site_client(config.site) for switch in await site_client.get_switches(): print(f"{switch.mac} {switch.ip_address:>15} {switch.name:20} ", end="") for port in switch.ports: if port.is_disabled: print("x", end="") else: print(get_link_status_char(port.port_status.link_status), end="") print(get_power_char(port.port_status.poe_active), end="") print() dump_raw_data(args, switch) return 0 def arg_parser(subparsers: _SubParsersAction) -> None: """Configures arguments parser for 'switches' command""" switches_parser = subparsers.add_parser("switches", aliases=["s"], help="Lists switches managed by Omada Controller") switches_parser.set_defaults(func=command_switches) switches_parser.add_argument("-d", "--dump", help="Output raw device information", action="store_true") tplink-omada-api-release-v1.4.4/src/tplink_omada_client/cli/command_target.py000066400000000000000000000100711474075305400273570ustar00rootroot00000000000000"""Implementation of 'target' command""" from argparse import _SubParsersAction, ArgumentError import getpass from tplink_omada_client.exceptions import OmadaClientException from .util import get_target_argument, assert_target_argument from .config import ControllerConfig, delete_target_config, get_target_config, set_target_config, to_omada_connection async def command_target(args) -> int: """Executes 'target' command""" assert_target_argument(args) target = get_target_argument(args) try: # Update existing with any provided values config = get_target_config(target) if args["delete"]: delete_target_config(target) return 0 if args["url"]: config.url = args["url"] if args["username"]: config.username = args["username"] if args["password"]: config.password = args["password"] if args["site"]: config.site = args["site"] if args["verify_ssl"]: config.verify_ssl = True elif args["no_verify_ssl"]: config.verify_ssl = False except ValueError as exc: if args["delete"]: raise ArgumentError(None, "Specified target does not exist") from exc # Create new config if not args["url"] or not args["username"]: raise ArgumentError(None, "URL and username are required for new targets") from exc if args["password"]: password = args["password"] else: password = getpass.getpass() config = ControllerConfig( url=args["url"], username=args["username"], password=password, site=args["site"] if args["site"] else "Default", verify_ssl=args["verify_ssl"] or not args["no_verify_ssl"], ) # Connect to controller to validate config try: async with to_omada_connection(config) as client: name = await client.get_controller_name() for site in await client.get_sites(): if config.site == site.name: print(f"Set target {target} to controller {name} and site {site.name}") set_target_config(target, config, args["set_default"]) return 0 print(f"Count not find site with name '{args['site']}' on the controller.") print("Make sure you specify the correct site name with the --site parameter.") print("Available sites are:") for site in await client.get_sites(): print(f" {site.name}") except OmadaClientException as e: print(f"Could not connect to controller with provided credentials: {e}") print("Target has not been added.") return 0 def arg_parser(subparsers: _SubParsersAction) -> None: """Configures argument parser for 'target' command""" set_parser = subparsers.add_parser( "target", help="Add Omada Controller to list of targets", ) set_parser.set_defaults(func=command_target) add_group = set_parser.add_argument_group( title="Add/Update Targets", description="Specify options to add or update a named target" ) add_group.add_argument( "--url", help="The URL of the Omada controller", required=False, ) add_group.add_argument( "--username", help="The name of the user used to authenticate", required=False, ) add_group.add_argument( "--password", help="The user's password, password will be prompted if not provided", ) add_group.add_argument("--site", help="The Omada site to control") add_group.add_argument("-sd", "--set-default", help="Set this target as the default", action="store_true") add_group.add_argument( "--verify-ssl", help="Verify the controller's SSL certificate (default on add)", action="store_true" ) add_group.add_argument("--no-verify-ssl", help="Do not verify the controller's SSL certificate", action="store_true") set_parser.add_argument("--delete", help="Delete the target", action="store_true") tplink-omada-api-release-v1.4.4/src/tplink_omada_client/cli/command_targets.py000066400000000000000000000014731474075305400275500ustar00rootroot00000000000000"""Implementation of 'targets' command""" from argparse import _SubParsersAction from .config import ( get_targets, ) async def command_targets(args) -> int: # pylint: disable=unused-argument """Executes 'targets' command""" controllers = get_targets() for controller, config, is_default in controllers: print(f"{('*' if is_default else' ')} {controller:15} {config.url:30} Site: {config.site:15} ", end="") print(f"Username: {config.username} Verify SSL: {config.verify_ssl}") return 0 def arg_parser(subparsers: _SubParsersAction) -> None: """Configures argument parser for 'targets' command""" # targets controllers_parser = subparsers.add_parser("targets", aliases=["t"], help="Lists the configured targets") controllers_parser.set_defaults(func=command_targets) tplink-omada-api-release-v1.4.4/src/tplink_omada_client/cli/command_unblock_client.py000066400000000000000000000020021474075305400310570ustar00rootroot00000000000000"""Implementation for 'client' command""" from argparse import _SubParsersAction from .config import get_target_config, to_omada_connection from .util import get_client_mac, get_target_argument async def command_unblock_client(args) -> int: """Executes 'unblock-client' command""" controller = get_target_argument(args) config = get_target_config(controller) async with to_omada_connection(config) as client: site_client = await client.get_site_client(config.site) mac = await get_client_mac(site_client, args["mac"]) await site_client.unblock_client(mac) return 0 def arg_parser(subparsers: _SubParsersAction) -> None: """Configures arguments parser for 'unblock-client' command""" unblock_parser = subparsers.add_parser("unblock-client", help="Unblocks a client allowing access to the network") unblock_parser.add_argument( "mac", help="The MAC address or name of the client", ) unblock_parser.set_defaults(func=command_unblock_client) tplink-omada-api-release-v1.4.4/src/tplink_omada_client/cli/command_wan.py000066400000000000000000000067301474075305400266650ustar00rootroot00000000000000"""Implementation for 'wan' command""" from argparse import ArgumentParser from tplink_omada_client.definitions import GatewayPortMode from .config import get_target_config, to_omada_connection from .util import dump_raw_data, get_checkbox_char, get_link_status_char, get_device_mac, get_target_argument async def command_wan(args) -> int: """Executes 'wan' command""" controller = get_target_argument(args) config = get_target_config(controller) async with to_omada_connection(config) as client: site_client = await client.get_site_client(config.site) mac = args["mac"] if mac: mac = await get_device_mac(site_client, mac) gateway = await site_client.get_gateway(mac) port = int(args["port"]) port_status = next((p for p in gateway.port_status if p.port_number == port), None) if not port_status: print(f"Port {port} not found") return -1 if port_status.mode != GatewayPortMode.WAN: print(f"Port {port} is not in WAN mode") return -1 if args["connect"] or args["disconnect"]: if args["ipv6"] and not port_status.wan_ipv6_enabled: print(f"Port {port} is not configured for IPv6") return -1 port_status = await site_client.set_gateway_wan_port_connect_state(port, args["connect"], mac, args["ipv6"]) print("Ok! Note that the gateway may take a few seconds or more to apply the change.") print(f"Port: {port_status.port_number}") print(f"Name: {port_status.display_name} ({port_status.name})") print(f"Link: {get_link_status_char(port_status.link_status)}") print(f"Mode: {port_status.mode.name}") print(f"Ipv4: {get_checkbox_char(port_status.wan_connected)}", end="") if port_status.wan_connected: print(f" {port_status.wan_ip_address}") else: print() print(f"Ipv4Proto: {port_status.wan_protocol}") if port_status.wan_ipv6_enabled: print(f"Ipv6: {get_checkbox_char(port_status.ipv6_wan_connected)}", end="") if port_status.ipv6_wan_connected: print(f" {port_status.wan_ipv6_address}") else: print() print(f"Online: {get_checkbox_char(port_status.online_detection)}") print(f"Speed: {port_status.link_speed.name}") print(f"Duplex: {port_status.link_duplex.name}") dump_raw_data(args, port_status) return 0 def arg_parser(subparsers) -> None: """Configures arguments parser for 'wan' command""" switch_parser: ArgumentParser = subparsers.add_parser("wan", help="Controls the gateway's wan ports") switch_parser.set_defaults(func=command_wan) switch_parser.add_argument("--mac", help="The MAC address of the gateway (optional)", required=False) switch_parser.add_argument("-p", "--port", help="The port number of the gateway.", required=True) con_discon_grp = switch_parser.add_mutually_exclusive_group() con_discon_grp.add_argument("--connect", help="Connect the port to the internet", action="store_true") con_discon_grp.add_argument("--disconnect", help="Connect the port from the internet", action="store_true") switch_parser.add_argument("--ipv6", help="Connect/Disconnect IPv6 Wan (defaults to IPv4)", action="store_true") switch_parser.add_argument("-d", "--dump", help="Output raw port information", action="store_true") tplink-omada-api-release-v1.4.4/src/tplink_omada_client/cli/config.py000066400000000000000000000101651474075305400256440ustar00rootroot00000000000000"""Responsible for ~/.omada.cfg""" from configparser import ConfigParser, SectionProxy from dataclasses import dataclass from pathlib import Path from tplink_omada_client import OmadaClient _CONFIG_FILE: Path = Path.home() / ".omada.cfg" _CONTROLLER_SECTION_PREFIX: str = "controller:" _CLI_SECTION: str = "cli" _DEFAULT_TARGET: str = "default_target" @dataclass class ControllerConfig: """Holds config needed to issue calls to Omada controller""" url: str username: str password: str site: str verify_ssl: bool def _parse_controller_config(config: SectionProxy) -> ControllerConfig: """Parse controller config section""" return ControllerConfig( config["url"], config["username"], config["password"], config["site"], config.get("verify_ssl", "True").lower() == "true", ) def get_targets() -> list[tuple[str, ControllerConfig, bool]]: """Get all the controllers in config file""" config_parser = _read_config_file() default_target = config_parser[_CLI_SECTION][_DEFAULT_TARGET] if _CLI_SECTION in config_parser else "" targets = [] for target, config in config_parser.items(): if target.startswith(_CONTROLLER_SECTION_PREFIX): name = target[len(_CONTROLLER_SECTION_PREFIX) :] targets.append((name, _parse_controller_config(config), name == default_target)) return targets def get_target_config(name: str) -> ControllerConfig: """Using controller name to create Omada site client""" config = _read_config_file() if name == "": if _CLI_SECTION in config: name = config[_CLI_SECTION][_DEFAULT_TARGET] if not name: raise ValueError("No target specified, and no default target has been configured.") stored_name = _to_stored_name(name) if config.has_section(stored_name): stored_config = config[stored_name] return _parse_controller_config(stored_config) raise ValueError(f"Could not find target named '{name}'") def set_target_config(name: str, config: ControllerConfig, set_default: bool) -> None: """Stores controller config in users's config file""" stored_name = _to_stored_name(name) config_parser = _read_config_file() config_parser.remove_section(stored_name) config_parser.add_section(stored_name) config_parser.set(stored_name, "url", config.url) config_parser.set(stored_name, "username", config.username) config_parser.set(stored_name, "password", config.password) config_parser.set(stored_name, "site", config.site) config_parser.set(stored_name, "verify_ssl", str(config.verify_ssl)) if set_default: if _CLI_SECTION not in config_parser: config_parser[_CLI_SECTION] = {} config_parser[_CLI_SECTION][_DEFAULT_TARGET] = name _write_config_file(config_parser) def set_default_target(name: str) -> None: """Set default target in user's config file.""" config_parser = _read_config_file() if _CLI_SECTION not in config_parser: config_parser[_CLI_SECTION] = {} config_parser[_CLI_SECTION][_DEFAULT_TARGET] = name _write_config_file(config_parser) def delete_target_config(name: str) -> None: """Delete a target from the user's config file.""" config = _read_config_file() stored_name = _to_stored_name(name) if config.has_section(stored_name): config.remove_section(stored_name) _write_config_file(config) def to_omada_connection(config: ControllerConfig) -> OmadaClient: """Create a OmadaClient based on a ControllerConfig object""" return OmadaClient(config.url, username=config.username, password=config.password, verify_ssl=config.verify_ssl) def _read_config_file() -> ConfigParser: """Read's the user's config file""" config = ConfigParser() if _CONFIG_FILE.exists(): with _CONFIG_FILE.open() as file: config.read_file(file) return config def _write_config_file(config_parser): """Write the user's config file""" with _CONFIG_FILE.open(mode="w") as file: config_parser.write(file) def _to_stored_name(name: str) -> str: return _CONTROLLER_SECTION_PREFIX + name tplink-omada-api-release-v1.4.4/src/tplink_omada_client/cli/util.py000066400000000000000000000065321474075305400253570ustar00rootroot00000000000000"""Common functionality for multiple commands""" import argparse import json from typing import Any from re import IGNORECASE, match from tplink_omada_client.devices import OmadaApiData, OmadaDevice from tplink_omada_client.definitions import LinkStatus from tplink_omada_client import OmadaSiteClient TARGET_ARG: str = "target" def assert_target_argument(args: dict[str, Any]) -> str: """Throws ArgumentError if target arg missing""" if args[TARGET_ARG] == "": # The default is now empty raise argparse.ArgumentError(None, f"error: Target name must be supplied using --{TARGET_ARG} argument") return args[TARGET_ARG] def get_target_argument(args: dict[str, Any]) -> str: """Get the target argument from the args dictionary.""" return args[TARGET_ARG] async def get_client_mac(site_client: OmadaSiteClient, mac_or_name: str) -> str: """Get the MAC address of a client given the MAC or name of the client.""" if match("([0-9A-F]{2}[-]){5}[0-9A-F]{2}$", string=mac_or_name, flags=IGNORECASE): return mac_or_name async for client in site_client.get_known_clients(): if client.name == mac_or_name: return client.mac raise argparse.ArgumentError(None, f"Client with name {mac_or_name} not found") async def get_device_mac(site_client: OmadaSiteClient, mac_or_name: str) -> str: """Get the MAC address of a device, given the MAC or Name of the device.""" if match("([0-9A-F]{2}[-]){5}[0-9A-F]{2}$", string=mac_or_name, flags=IGNORECASE): return mac_or_name device = next((d for d in await site_client.get_devices() if d.name == mac_or_name), None) if not device: raise argparse.ArgumentError(None, f"Device with name {mac_or_name} not found") return device.mac async def get_device_by_mac_or_name(site_client: OmadaSiteClient, mac_or_name: str) -> OmadaDevice: """Get an Omada device given the MAC or name of the device.""" device = next((d for d in await site_client.get_devices() if mac_or_name in (d.name, d.mac)), None) if not device: raise argparse.ArgumentError(None, f"Device with name {mac_or_name} not found") return device def dump_raw_data(args: dict[str, Any], data: OmadaApiData): """Prints a raw dump of Json data, if the --dump argument is present.""" if args["dump"]: print("--- BEGIN RAW DATA ---") print(json.dumps(data.raw_data, indent=2, ensure_ascii=False)) print("--- END RAW DATA ---") def get_checkbox_char(checked: bool) -> str: """Returns a checkbox char.""" if checked: return "\u2611" else: return "\u2610" def get_link_status_char(link_status: LinkStatus) -> str: """Returns a checkbox char representing the link status.""" return get_checkbox_char(link_status == LinkStatus.LINK_UP) def get_power_char(power: bool) -> str: """Returns a high-voltage symbol if true.""" if power: return "\u26a1" else: return " " def get_display_bytes(bytes_size: int, short: bool = True) -> str: """Converts size in bytes to a human-readable string.""" if bytes_size / (1 if short else 1024) > 1048576 * 1024 * 512: return f"{bytes_size / (1048576.0 * 1048576.0):,.1f}TB" if bytes_size / (1 if short else 1024) > 1048576 * 512: return f"{bytes_size / (1048576.0 * 1024):,.1f}GB" return f"{bytes_size / (1048576.0):,.1f}MB" tplink-omada-api-release-v1.4.4/src/tplink_omada_client/clients.py000066400000000000000000000207161474075305400252740ustar00rootroot00000000000000"""Definitions of Clients connected to Omada devices.""" from .definitions import ( AuthenticationStatus, ConnectType, OmadaApiData, RadioId, WifiMode, ) class OmadaNetworkClient(OmadaApiData): """Base representation of Omada client""" @property def connection_time(self) -> int | None: """Client connection time in seconds.""" if "uptime" in self._data: return self._data["uptime"] if "duration" in self._data: return self._data["duration"] return None @property def is_blocked(self) -> bool: """Indicates if a client is blocked.""" # Most API calls return 'blocked' but the 'known_clients' call returns 'block' return self._data.get("blocked", self._data.get("block", False)) @property def is_guest(self) -> bool: """Indicates if client is a 'guest'.""" return self._data["guest"] @property def last_seen(self) -> float: """Timestamp in second from Unix Epoch when client was last connected.""" return self._data["lastSeen"] / 1000 @property def mac(self) -> str: """The MAC address of the client.""" return self._data["mac"] @property def name(self) -> str: """The name of the client.""" return self._data["name"] @property def is_wireless(self) -> bool | None: """Indicates if client is a wireless client""" return self._data.get("wireless") class OmadaConnectedClient(OmadaNetworkClient): """Details of a client connected to an Omada device.""" @property def is_active(self) -> bool: """Indicates if client is active""" return self._data["active"] @property def activity(self) -> int: """Realtime downlink rate (Byte/s)""" return self._data["activity"] @property def auth_status(self) -> AuthenticationStatus: """Authentication status""" return self._data["authStatus"] @property def connect_dev_type(self) -> str | None: """Connected device type (ap, gateway, switch)""" return self._data.get("connectDevType") @property def connect_type(self) -> ConnectType: """Connection type""" return ConnectType(self._data["connectType"]) @property def device_type(self) -> str: """Device type (Android, iPhone, iPod... mostly Unkown)""" return self._data["deviceType"] @property def down_packet(self) -> int: """Number of packets sent to client""" return self._data["downPacket"] @property def host_name(self) -> str | None: """The client's hostname.""" return self._data.get("hostName") @property def ip(self) -> str | None: """The client's IP address.""" return self._data.get("ip") @property def traffic_down(self) -> int: """Number of bytes sent to client""" return self._data["trafficDown"] @property def traffic_up(self) -> int: """Number of bytes sent by client""" return self._data["trafficUp"] @property def up_packet(self) -> int: """Number of packets received from client""" return self._data["upPacket"] @property def vlan(self) -> int | None: """ VLAN ID This property appears to work with some access points but is not provided by others. Known to work on: EAP653 Not provided by: EAP660 """ return self._data.get("vid") class IpSetting(OmadaApiData): """IP settings of a client connected to an Omada device.""" @property def used_fixed_addr(self) -> bool: """True if a fixed IP address is reserved.""" return bool(self._data["useFixedAddr"]) class RateLimit(OmadaApiData): """Rate limit settings of a client connected to an Omada device.""" @property def enabled(self) -> bool: """True if rate limiting is enabled.""" return bool(self._data["enable"]) @property def down_enabled(self) -> bool: """True if downstream rate limiting is enabled.""" return bool(self._data["downEnable"]) @property def down_limit(self) -> int: """Downstream rate limit.""" return int(self._data["downLimit"]) @property def down_unit(self) -> int: """Downstream rate limit unit (Enum).""" return int(self._data["downUnit"]) @property def up_enabled(self) -> bool: """True if upstream rate limiting is enabled.""" return bool(self._data["upEnable"]) @property def up_limit(self) -> int: """Upstream rate limit.""" return int(self._data["upLimit"]) @property def up_unit(self) -> int: """Upstream rate limit unit (Enum).""" return int(self._data["upUnit"]) class OmadaClientDetails(OmadaConnectedClient): """Details of a client connected to an Omada device.""" @property def device_category(self) -> str | None: """Device category.""" return self._data.get("deviceCategory") @property def ip_setting(self) -> IpSetting: """IP Reservation settings of client.""" return IpSetting(self._data["ipSetting"]) @property def os_name(self) -> str | None: """Operating system name.""" return self._data.get("osName") @property def rate_limit(self) -> RateLimit: """Rate limit settings of client.""" return RateLimit(self._data["rateLimit"]) @property def vendor(self) -> str | None: """Vendor of the device.""" return self._data.get("vendor") class OmadaWiredClient(OmadaConnectedClient): """Details of a wired connected client.""" @property def dot1x_vlan(self) -> int: """Network name corresponding to the VLAN obtained by 802.1x D-VLAN""" return self._data["dot1xVlan"] @property def gateway_mac(self) -> str | None: """Mac address of gateway the client is connected to""" return self._data.get("switchMac") @property def gateway_name(self) -> str | None: """Name of gateway the client is connected to""" return self._data.get("gatewayName") @property def network_name(self) -> str: """Network name""" return self._data["networkName"] @property def port(self) -> int: """Switch port client is connected to""" return self._data["port"] @property def switch_mac(self) -> str | None: """Mac address of switch the client is connected to""" return self._data.get("switchMac") @property def switch_name(self) -> str | None: """Name of switch the client is connected to""" return self._data.get("switchName") class OmadaWiredClientDetails(OmadaWiredClient, OmadaClientDetails): """Details of a wired connected client.""" class OmadaWirelessClient(OmadaConnectedClient): """Details of a wireless connected client.""" @property def ap_mac(self) -> str: """Access point mac address""" return self._data["apMac"] @property def ap_name(self) -> str: """Access point name""" return self._data["apName"] @property def channel(self) -> int: """WiFi channel number.""" return self._data["channel"] @property def is_power_save(self) -> bool: """Indicates if power save mode is enabled""" return self._data["powerSave"] @property def radio_id(self) -> RadioId: """Radio frequency id""" return RadioId(self._data["radioId"]) @property def rssi(self) -> int: """Signal strength in dBm""" return self._data["rssi"] @property def rx_rate(self) -> int: """Uplink negotiation rate in Kbit/s""" return self._data["rxRate"] @property def signal_level(self) -> int: """Signal strength percentage""" return self._data["signalLevel"] @property def signal_rank(self) -> int: """Signal strength (0-5)""" return self._data["signalRank"] @property def ssid(self) -> str: """SSID name""" return self._data["ssid"] @property def tx_rate(self) -> int: """Downlink negotiation rate (Kbit/s)""" return self._data["txRate"] @property def wifi_mode(self) -> WifiMode: """WiFi mode""" return WifiMode(self._data["wifiMode"]) class OmadaWirelessClientDetails(OmadaWirelessClient, OmadaClientDetails): """Details of an Omada Wireless Client.""" tplink-omada-api-release-v1.4.4/src/tplink_omada_client/definitions.py000066400000000000000000000113771474075305400261510ustar00rootroot00000000000000"""Definitions for Omada enums.""" from abc import ABC from enum import IntEnum from typing import Any class OmadaApiData(ABC): """Base representation of Omada API data.""" def __init__(self, data: dict[str, Any]): self._data = data def __repr__(self) -> str: repr_str = self.__class__.__name__ repr_str += "{" for name in self.__dir__(): if not name.startswith("_") and name != "raw_data": repr_str += f"{name}={getattr(self, name)}," repr_str += "}" return repr_str @property def raw_data(self) -> dict[str, Any]: """Raw data obtained from Omada API.""" return self._data class DeviceStatus(IntEnum): """Known status codes for devices.""" UNKNOWN = -1 DISCONNECTED = 0 DISCONNECTED_MIGRATING = 1 PROVISIONING = 10 CONFIGURING = 11 UPGRADING = 12 REBOOTING = 13 CONNECTED = 14 CONNECTED_WIRELESS = 15 CONNECTED_MIGRATING = 16 CONNECTED_WIRELESS_MIGRATING = 17 PENDING = 20 PENDING_WIRELESS = 21 ADOPTING = 22 ADOPTING_WIRELESS = 23 ADOPT_FAILED = 24 ADOPT_FAILED_WIRELESS = 25 MANAGED_EXTERNALLY = 26 MANAGED_EXTERNALLY_WIRELESS = 27 HEARTBEAT_MISSED = 30 HEARTBEAT_MISSED_WIRELESS = 31 HEARTBEAT_MISSED_MIGRATING = 32 HEARTBEAT_MISSED_WIRELESS_MIGRATING = 33 ISOLATED = 40 ISOLATED_MIGRATING = 41 @classmethod def _missing_(cls, _): return DeviceStatus.UNKNOWN class DeviceStatusCategory(IntEnum): """Known status categories for devices""" UNKNOWN = -1 DISCONNECTED = 0 CONNECTED = 1 PENDING = 2 HEARTBEAT_MISSED = 3 ISOLATED = 4 @classmethod def _missing_(cls, _): return DeviceStatusCategory.UNKNOWN class PortType(IntEnum): """Known types of switch port.""" UNKNOWN = -1 COPPER = 1 COMBO = 2 SFP = 3 @classmethod def _missing_(cls, _): return PortType.UNKNOWN class GatewayPortType(IntEnum): """Known types of gateway port.""" UNKNOWN = -1 WAN = 0 WAN_LAN = 1 LAN = 2 SFP_WAN = 3 @classmethod def _missing_(cls, _): return GatewayPortType.UNKNOWN class GatewayPortMode(IntEnum): """Modes of gateway port.""" DISABLED = -1 WAN = 0 LAN = 1 @classmethod def _missing_(cls, _): return GatewayPortMode.DISABLED class LinkStatus(IntEnum): """Known link statuses.""" UNKNOWN = -1 LINK_DOWN = 0 LINK_UP = 1 @classmethod def _missing_(cls, _): return LinkStatus.UNKNOWN class LinkSpeed(IntEnum): """Known link speeds.""" UNKNOWN = -1 SPEED_AUTO = 0 SPEED_10_MBPS = 1 SPEED_100_MBPS = 2 SPEED_1_GBPS = 3 SPEED_2_5_GBPS = 4 SPEED_10_GBPS = 5 @classmethod def _missing_(cls, _): return LinkSpeed.UNKNOWN class LinkDuplex(IntEnum): """Known link duplex modes""" UNKNOWN = -1 AUTO = 0 HALF = 1 FULL = 2 @classmethod def _missing_(cls, _): return LinkDuplex.UNKNOWN class Eth802Dot1X(IntEnum): """802.1x auth modes.""" UNKNOWN = -1 FORCE_UNAUTHORIZED = 0 FORCE_AUTHORIZED = 1 AUTO = 2 @classmethod def _missing_(cls, _): return Eth802Dot1X.UNKNOWN class BandwidthControl(IntEnum): """Modes of bandwidth control.""" UNKNOWN = -1 OFF = 0 RATE_LIMIT = 1 STORM_CONTROL = 2 @classmethod def _missing_(cls, _): return BandwidthControl.UNKNOWN class PoEMode(IntEnum): """Settings for PoE policy.""" NONE = -1 DISABLED = 0 ENABLED = 1 USE_DEVICE_SETTINGS = 2 @classmethod def _missing_(cls, _): return PoEMode.NONE class AuthenticationStatus: """Client authentication status.""" UNKNOWN = -1 CONNECTED = 0 PENDING = 1 AUTHORIZED = 2 AUTH_FREE = 3 @classmethod def _missing_(cls, _): return AuthenticationStatus.UNKNOWN class ConnectType(IntEnum): """Client connection types.""" UNKNOWN = -1 GUEST_WIRELESS = 0 WIRELESS = 1 WIRED = 2 @classmethod def _missing_(cls, _): return ConnectType.UNKNOWN class RadioId(IntEnum): """WiFi radio frequencies""" UNKNOWN = -1 FREQ_2_4 = 0 FREQ_5_1 = 1 FREQ_5_2 = 2 FREQ_6 = 3 @classmethod def _missing_(cls, _): return RadioId.UNKNOWN class WifiMode(IntEnum): """WiFi modes.""" UNKNOWN = -1 A = 0 B = 1 G = 2 NA = 3 NG = 4 AC = 5 AXA = 6 AXG = 7 @classmethod def _missing_(cls, _): return WifiMode.UNKNOWN class LedSetting(IntEnum): """LED Setting""" UNKNOWN = -1 OFF = 0 ON = 1 SITE_SETTINGS = 2 @classmethod def _missing_(cls, _): return LedSetting.UNKNOWN tplink-omada-api-release-v1.4.4/src/tplink_omada_client/devices.py000066400000000000000000000562411474075305400252570ustar00rootroot00000000000000""" Definitions for Omada device objects APs, Switches and Routers """ from abc import ABC, abstractmethod from typing import Any from .definitions import ( BandwidthControl, DeviceStatus, DeviceStatusCategory, Eth802Dot1X, GatewayPortMode, GatewayPortType, LinkDuplex, LinkSpeed, LinkStatus, OmadaApiData, PoEMode, PortType, LedSetting, ) class OmadaDevice(OmadaApiData): """Details of a device connected to the controller""" @property def type(self) -> str: """The type of the device. Its value can be "ap", "gateway", and "switch".""" return self._data["type"] @property def resource_path(self): """The API path for querying detailed data about this device""" types = {"ap": "eaps", "gateway": "gateways", "switch": "switches"} return f"{types[self.type]}/{self.mac}" @property def mac(self) -> str: """The MAC address of the device.""" return self._data["mac"] @property def name(self) -> str: """The device name.""" return self._data["name"] @property def model(self) -> str: """The device model, such as EAP225.""" return self._data["model"] @property def model_display_name(self) -> str: """Model description for front-end display.""" return self._data["showModel"] @property def status(self) -> DeviceStatus: """The status of the device.""" return DeviceStatus(self._data["status"]) @property def status_category(self) -> DeviceStatusCategory: """The high-level status of the device.""" return DeviceStatusCategory(self._data["statusCategory"]) @property def ip_address(self) -> str: """IP address of the device.""" return self._data["ip"] @property def display_uptime(self) -> str | None: """Uptime of the device, as a display string""" if self._data["statusCategory"] == DeviceStatusCategory.CONNECTED: return self._data["uptime"] else: return None @property def cpu_usage(self) -> int: """Currenct CPU usage of the device.""" if self._data["statusCategory"] == DeviceStatusCategory.CONNECTED: return self._data.get("cpuUtil", 0) else: return 0 @property def mem_usage(self) -> int: """Current memory usage of the device.""" if self._data["statusCategory"] == DeviceStatusCategory.CONNECTED: return self._data.get("memUtil", 0) else: return 0 @property def uptime(self) -> int: """Uptime of the device, as a display string""" if self._data["statusCategory"] == DeviceStatusCategory.CONNECTED: return self._data["uptimeLong"] else: return 0 @property def firmware_version(self) -> str: """Firmware version of the device""" return self._data["firmwareVersion"] class OmadaListDevice(OmadaDevice): """An Omada Device (router, switch, eap) as represented in the device list""" @property def need_upgrade(self) -> bool: """True, if a firmware upgrade is available for the device.""" if self._data["statusCategory"] == DeviceStatusCategory.CONNECTED: return self._data.get("needUpgrade", False) else: return False @property def fw_download(self) -> bool: """True, if a firmware upgrade is being downloaded.""" if self._data["statusCategory"] == DeviceStatusCategory.CONNECTED: return self._data.get("fwDownload", False) else: return False class OmadaDetailedDevice(OmadaDevice): """Generic properties for Omada Devices (router, switch, eap) as returned the device-type specific endpoints""" @property def led_setting(self) -> LedSetting: """The onboard LED setting for the device""" return LedSetting(self._data["ledSetting"]) class OmadaLink(OmadaApiData): """Up/Downlink connection from a switch/ap device.""" @property def mac(self) -> str: """The MAC of the linked device.""" return self._data.get("mac", self._data.get("uplinkMac")) @property def name(self) -> str: """The name of the linked device.""" return self._data["name"] @property def type(self) -> str: """The type of device linked to.""" return self._data["type"] @property def port(self) -> int: """The port's number.""" return self._data["port"] class OmadaDownlink(OmadaLink): """Downlink connection from a switch/ap port.""" @property def type(self) -> str: """The type of device downlinked to.""" return "ap" @property def model(self) -> str: """The model name of device linked to.""" return self._data["model"] class OmadaUplink(OmadaLink): """Uplink connection from a switch/ap device.""" class OmadaPortStatus(ABC): """Status information for a port.""" @property @abstractmethod def link_status(self) -> LinkStatus: """Port's link status.""" @property @abstractmethod def link_speed(self) -> LinkSpeed: """Port's link speed.""" @property @abstractmethod def bytes_tx(self) -> int: """Number of bytes transmitted by the port.""" @property @abstractmethod def bytes_rx(self) -> int: """Number of bytes received by the port.""" @property @abstractmethod def poe_active(self) -> bool: """Is the port powering a PoE device?""" class OmadaSwitchPortStatus(OmadaApiData, OmadaPortStatus): """Status information for a switch port.""" @property def link_status(self) -> LinkStatus: """Port's link status.""" return LinkStatus(self._data["linkStatus"]) @property def link_speed(self) -> LinkSpeed: """Port's link speed.""" return LinkSpeed(self._data["linkSpeed"]) @property def poe_active(self) -> bool: """Is the port powering a PoE device?""" return self._data["poe"] @property def poe_power(self) -> float | None: """Power (W) supplied over PoE.""" return self._data.get("poePower") @property def bytes_tx(self) -> int: """Number of bytes transmitted by the port.""" return self._data["tx"] @property def bytes_rx(self) -> int: """Number of bytes received by the port.""" return self._data["rx"] @property def stp_discarding(self) -> bool: """Stp blocking status in spanning tree.""" return self._data["stpDiscarding"] class OmadaSwitchPort(OmadaApiData): """Port on a switch/gateway device.""" @property def port(self) -> int: """The port's number.""" return self._data["port"] @property def name(self) -> str: """The device name.""" return self._data["name"] @property def profile_id(self) -> str: """ID of the port's config profile.""" return self._data["profileId"] @property def type(self) -> PortType: """The type of the port.""" return self._data["type"] @property def operation(self) -> str: """Port config: switching, mirroring or aggregating.""" return self._data["operation"] @property def is_disabled(self) -> bool: """Is the port disabled?""" return self._data["disable"] @property def port_status(self) -> OmadaSwitchPortStatus: """Status of the port.""" return OmadaSwitchPortStatus(self._data["portStatus"]) class OmadaSwitchDeviceCaps(OmadaApiData): """Capabilities of a switch.""" @property def poe_ports(self) -> int: """Number of PoE ports supported.""" return self._data["poePortNum"] @property def supports_poe(self) -> bool: """Is PoE supported.""" return self._data["poeSupport"] @property def supports_bt(self) -> bool: """Is BT supported.""" return self._data["supportBt"] class OmadaSwitch(OmadaDetailedDevice): """Details of a switch connected to the controller.""" @property def number_of_ports(self) -> int: """The number of ports on the switch.""" if "portNum" in self._data: return self._data["portNum"] # So much for the docs return self._data["deviceMisc"]["portNum"] @property def ports(self) -> list[OmadaSwitchPort]: """List of ports attached to the switch.""" return [OmadaSwitchPort(p) for p in self._data["ports"]] @property def uplink(self) -> OmadaUplink | None: """Uplink device for this switch.""" if "uplink" not in self._data: return None uplink = self._data["uplink"] if uplink is None: return None return OmadaUplink(uplink) @property def downlink(self) -> list[OmadaDownlink]: """Downlink devices attached to switch.""" if "downlinkList" in self._data: return [OmadaDownlink(d) for d in self._data["downlinkList"]] return [] @property def device_capabilities(self) -> OmadaSwitchDeviceCaps: """Capabilities of the switch.""" return OmadaSwitchDeviceCaps(self._data["devCap"]) class OmadaAccesPointLanPortSettings(OmadaApiData): """A LAN port on an access point.""" @property def port_name(self) -> str: """Name of the port - can't be edited""" return self._data["lanPort"] @property def supports_vlan(self) -> bool: """True if the port supports VLAN tagging""" return self._data["supportVlan"] @property def local_vlan_enable(self) -> bool: """True if VLAN tagging is enabled for the port explicitly""" return self._data.get("localVlanEnable", False) @property def local_vlan_id(self) -> int: """VLAN ID for this port""" return self._data.get("localVlanId", -1) @property def supports_poe(self) -> bool: """True if the port supports PoE output""" return self._data.get("supportPoe", False) @property def poe_enable(self) -> bool: """ True to enable PoE. WARNING: Do not enable PoE for EAPs powered from another EAP """ return self._data["poeOutEnable"] class OmadaAccessPoint(OmadaDetailedDevice): """Details of an Access Point connected to the controller.""" @property def wireless_linked(self) -> bool: """True, if the AP is connected wirelessley.""" return self._data["wirelessLinked"] @property def supports_5g(self) -> bool: """True if 5G wifi is supported""" return self._data["deviceMisc"]["support5g"] @property def supports_5g2(self) -> bool: """True if 5G2 wifi is supported""" return self._data["deviceMisc"]["support5g2"] @property def supports_6g(self) -> bool: """True if Wifi 6 is supported""" return self._data["deviceMisc"]["support6g"] @property def supports_11ac(self) -> bool: """True if PoE is supported""" return self._data["deviceMisc"]["support11ac"] @property def supports_mesh(self) -> bool: """True if mesh networking is supported""" return self._data["deviceMisc"]["supportMesh"] @property def lan_port_settings(self) -> list[OmadaAccesPointLanPortSettings]: """Settings for the LAN ports on the access point""" return [OmadaAccesPointLanPortSettings(p) for p in self._data["lanPortSettings"]] @property def wired_uplink(self) -> OmadaUplink | None: """Wired Uplink device for this ap.""" uplink = self._data.get("wiredUplink", None) if uplink is None: return None return OmadaUplink(uplink) class OmadaSwitchPortDetails(OmadaSwitchPort): """Full details of a port on a switch.""" @property def port_id(self) -> str: """The ID of the port""" return self._data["id"] @property def max_speed(self) -> LinkSpeed: """The max speed of the port.""" return LinkSpeed(self._data["maxSpeed"]) @property def link_speed(self) -> LinkSpeed: """The link speed of the port.""" return LinkSpeed(self._data["linkSpeed"]) @property def duplex(self) -> LinkDuplex: """The link duplex state of the port.""" return LinkDuplex(self._data["duplex"]) @property def profile_name(self) -> str: """Name of the port's config profile""" return self._data["profileName"] @property def has_profile_override(self) -> bool: """True if the port's config profile has been overridden.""" return self._data["profileOverrideEnable"] @property def poe_mode(self) -> PoEMode: """PoE config for this port.""" return PoEMode(self._data.get("poe", PoEMode.NONE)) @property def bandwidth_limit_mode(self) -> BandwidthControl: """Type of bandwidth control applied.""" return BandwidthControl(self._data["bandWidthCtrlType"]) # "bandCtrl": { # "egressEnable": false, # "egressLimit": 0, # "egressUnit": 1, # "ingressEnable": false, # "ingressLimit": 0, # "ingressUnit": 1 # }, # "stormCtrl": { # "unknownUnicastEnable": false, # "unknownUnicast": 0, # "multicastEnable": false, # "multicast": 0, # "broadcastEnable": false, # "broadcast": 0, # "action": 0, # "recoverTime": 3600 # }, @property def eth_802_1x_control(self) -> Eth802Dot1X: """802.1x Auth mode""" return Eth802Dot1X(self._data["dot1x"]) @property def lldp_med_enabled(self) -> bool: """LLDP Mode""" return self._data["lldpMedEnable"] @property def topology_notify_enabled(self) -> bool: """Topology notify mode""" return self._data["topoNotifyEnable"] @property def spanning_tree_enabled(self) -> bool: """Spanning tree loopback control""" return self._data["spanningTreeEnable"] @property def loopback_detect_enabled(self) -> bool: """Loopback detection""" return self._data["loopbackDetectEnable"] @property def port_isolation_enabled(self) -> bool: """Port isolation (Danger!)""" return self._data["portIsolationEnable"] class OmadaPortProfile(OmadaApiData): """Definition of a switch port configuration profile.""" @property def profile_id(self) -> str: """ID of this profile.""" return self._data["id"] @property def site(self) -> str: """Site which this profile is valid for.""" return self._data["site"] @property def name(self) -> str: """Name of the profile.""" return self._data["name"] @property def poe_mode(self) -> PoEMode: """PoE mode.""" return PoEMode(self._data.get("poe", PoEMode.NONE)) @property def bandwidth_limit_mode(self) -> BandwidthControl: """Type of bandwidth control applied.""" return BandwidthControl(self._data["bandWidthCtrlType"]) @property def eth_802_1x_control(self) -> Eth802Dot1X: """802.1x Auth mode""" return Eth802Dot1X(self._data["dot1x"]) @property def lldp_med_enabled(self) -> bool: """LLDP Mode""" return self._data["lldpMedEnable"] @property def topology_notify_enabled(self) -> bool: """Topology notify mode""" return self._data["topoNotifyEnable"] @property def spanning_tree_enabled(self) -> bool: """Spanning tree loopback control""" return self._data["spanningTreeEnable"] @property def loopback_detect_enabled(self) -> bool: """Loopback detection""" return self._data["loopbackDetectEnable"] @property def port_isolation_enabled(self) -> bool: """Port isolation (Danger!)""" return self._data["portIsolationEnable"] class OmadaInterfaceDetails(OmadaApiData): """Basic UI Information about controller.""" @property def controller_name(self) -> str: """Display name of the controller.""" return self._data["controllerName"] class OmadaFirmwareUpdate(OmadaApiData): """Status information for a switch port.""" @property def current_version(self) -> str: """Device's current firmware version.""" return self._data["curFwVer"] @property def latest_version(self) -> str: """Latest firmware version available.""" return self._data["lastFwVer"] @property def release_notes(self) -> str: """Release notes for the new firmware.""" return self._data["fwReleaseLog"] class OmadaGatewayPortStatus(OmadaApiData, OmadaPortStatus): """Status information for a gateway port.""" @property def port_number(self) -> int: """Port number""" return self._data["port"] @property def name(self) -> str: """Port name""" return self._data["name"] @property def display_name(self) -> str: """Port display name""" return self._data.get("portDesc", self.name) @property def type(self) -> GatewayPortType: """Type of the port - SFP, WAN, WAN/LAN or LAN only.""" return GatewayPortType(self._data["type"]) @property def mode(self) -> GatewayPortMode: """Whether the port is operating in WAN or LAN mode""" return GatewayPortMode(self._data["mode"]) @property def link_status(self) -> LinkStatus: """Low level connectivity status of the link.""" return LinkStatus(self._data["status"]) @property def bytes_tx(self) -> int: """Number of bytes transmitted by the port.""" return self._data["tx"] @property def bytes_rx(self) -> int: """Number of bytes received by the port.""" return self._data["rx"] @property def poe_active(self) -> bool: """True if the port is powering a PoE device.""" return self._data.get("poe", 0) != 0 @property def wan_connected(self) -> bool: """True if the port is connected to the internet/WAN""" return self._data.get("internetState", 0) != 0 @property def ipv6_wan_connected(self) -> bool: """True if the port is connected to the internet/WAN with IPv6""" return dict[str, Any](self._data.get("wanPortIpv6Config", {})).get("internetState", 0) != 0 @property def online_detection(self) -> bool: """True regular internet ping tests are working""" return (self.wan_connected or self.ipv6_wan_connected) and self._data.get("onlineDetection", 0) != 0 @property def ip(self) -> str | None: """DEPRECATED: The WAN IP of the port (for WAN ports only)""" return self._data.get("ip") @property def wan_ip_address(self) -> str | None: """The WAN IPv4 Address of the port (for WAN ports only)""" return self._data.get("ip") @property def wan_ipv6_enabled(self) -> bool: """The WAN IPv6 Address of the port (for WAN ports only)""" return dict[str, Any](self._data.get("wanPortIpv6Config", {})).get("enable", 0) != 0 @property def wan_ipv6_address(self) -> str | None: """The WAN IPv6 Address of the port (for WAN ports only)""" return dict[str, Any](self._data.get("wanPortIpv6Config", {})).get("addr") @property def link_speed(self) -> LinkSpeed: """The established link speed of the port""" return LinkSpeed(self._data.get("speed", LinkSpeed.SPEED_10_MBPS)) @property def link_duplex(self) -> LinkDuplex: """Actual duplex mode of the port""" return LinkDuplex(self._data.get("duplex", LinkDuplex.FULL)) @property def wan_protocol(self) -> str | None: """May be: static, dhcp, pppoe, l2tp, pptp""" return self._data.get("proto") # For connected ports # "rxPkt":217028151,"rxPktRate":35,"rxRate":6,"tx":15157457326,"txPkt":60843861,"txPktRate":24,"txRate":5, # "mirroredPorts":[] # FOR WAN PORTS: # "wanPortIpv6Config":{"enable":0,"addr":"","gateway":"","priDns":"","sndDns":"","internetState":0}, # "wanPortIpv4Config":{ # "ip":"x.x.x.x", # "gateway":"x.x.x.x", # "gateway2":"0.0.0.0", # "priDns":"194.168.4.100", # "sndDns":"194.168.8.100", # "priDns2":"0.0.0.0", # "sndDns2":"0.0.0.0" # } class OmadaGatewayPortConfig(OmadaApiData): """Configuration of a gateway port. Includes status.""" def __init__(self, data: dict, poe_enabled: bool | None): super().__init__(data) self._poe_enabled = poe_enabled @property def port_number(self) -> int: """Port number""" return self._data["port"] @property def duplex(self) -> LinkDuplex: """Configured duplex mode for the port.""" return self._data.get("duplex", LinkDuplex.AUTO) @property def link_speed(self) -> LinkSpeed: """Configured link speed for the port.""" return self._data.get("linkSpeed", LinkSpeed.SPEED_AUTO) @property def mirror_enable(self) -> bool: """True if port mirroring is enabled""" return self._data.get("mirrorEnable", False) @property def port_status(self) -> OmadaGatewayPortStatus: """Full status of the port""" return OmadaGatewayPortStatus(self._data["portStat"]) @property def poe_mode(self) -> PoEMode: """PoE mode for the port""" poe_mode_mapping = { True: PoEMode.ENABLED, False: PoEMode.DISABLED, None: PoEMode.NONE, } return poe_mode_mapping[self._poe_enabled] class OmadaGateway(OmadaDetailedDevice): """Details of an Omada Gateway device.""" @property def number_of_ports(self) -> int: """The number of ports on the switch.""" return self._data.get("portNum", 0) @property def supports_poe(self) -> bool: """True if the device supports PoE.""" return self._data["supportPoe"] @property def ip(self) -> str: """Gateway's LAN IP address.""" return self._data["ip"] @property def port_status(self) -> list[OmadaGatewayPortStatus]: """Status of the gateway's ports.""" return [OmadaGatewayPortStatus(p) for p in self._data["portStats"]] @property def port_configs(self) -> list[OmadaGatewayPortConfig]: """Configuration of the gateway's ports. Also includes status...""" poe_data = {} if self.supports_poe: # Combined Gateway+PoE switch has this extra data poe_data = {int(x["portId"]): bool(x["enable"]) for x in self._data["poeSettings"]} return [OmadaGatewayPortConfig(p, poe_data.get(p["port"])) for p in self._data["portConfigs"]] @property def lldp_enabled(self) -> bool: """LLDP Enabled for the whole gateway""" return self._data.get("lldpEnable", False) @property def echo_server(self) -> str | None: """Address of server to ping for online detection.""" return self._data.get("echoServer") @property def is_combined_gateway(self) -> bool: """True if this is a combined gateway/switch.""" return self._data.get("combinedGateway", False) tplink-omada-api-release-v1.4.4/src/tplink_omada_client/exceptions.py000066400000000000000000000031141474075305400260050ustar00rootroot00000000000000"""Exceptions that the library might throw.""" class OmadaClientException(Exception): """Base for all exceptions raised by the library.""" class RequestFailed(OmadaClientException): """Generic rejection of any command by the controller.""" def __init__(self, error_code: int, msg: str): self._error_code = error_code self._msg = msg super().__init__(f"Omada controller responded '{msg}' ({error_code})") class LoginFailed(RequestFailed): """Username/Password failure.""" class LoginSessionClosed(OmadaClientException): """ The login token isn't valid any more. If this happens immediately after logging on, and you are using IP addresses to contact the controller then make sure you supply a ClientSession that has an unsafe CookieJar, or the login session cookies won't work. """ class UnsupportedControllerVersion(OmadaClientException): """ Indicates the Omada controller has a software version that is not supported. Only controller versions 5.0 and later are supported. """ def __init__(self, version): super().__init__(f"Unsupported Omada controller version {version} found.") class SiteNotFound(OmadaClientException): """The specified site cannot be found on the Controller.""" class ConnectionFailed(OmadaClientException): """Connection to Omada controller failed at the network level.""" class BadControllerUrl(OmadaClientException): """URL of controller could not be resolved.""" class InvalidDevice(OmadaClientException): """Device type isn't valid for this operation.""" tplink-omada-api-release-v1.4.4/src/tplink_omada_client/omadaapiconnection.py000066400000000000000000000175021474075305400274650ustar00rootroot00000000000000"""Internal Omada API client.""" import time from typing import Any, AsyncIterable import re from urllib.parse import urlsplit, urljoin from aiohttp import Payload, client_exceptions, CookieJar from aiohttp.client import ClientSession from awesomeversion import AwesomeVersion from .exceptions import ( BadControllerUrl, ConnectionFailed, LoginFailed, LoginSessionClosed, RequestFailed, UnsupportedControllerVersion, ) _PAGE_SIZE: int = 100 class OmadaApiConnection: """Low level Omada API client.""" _own_session: bool _controller_id: str _controller_version: str _csrf_token: str | None _last_logon: float def __init__( self, url: str, username: str, password: str, websession: ClientSession | None = None, verify_ssl=True, ): if not url.lower().startswith(("http://", "https://")): url = "https://" + url url_parts = urlsplit(url, "https://") self._url = url_parts.geturl() self._host = url_parts.hostname or "" self._url = url self._username = username self._password = password self._session = websession self._verify_ssl = verify_ssl self._csrf_token = None async def _get_session(self) -> ClientSession: if self._session is None: self._own_session = True jar = None if re.fullmatch(r"\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}", self._host) is None else CookieJar(unsafe=True) self._session = ClientSession(cookie_jar=jar) return self._session async def __aenter__(self): try: await self.login() return self except Exception as error: if self._own_session: await self.close() raise error async def __aexit__(self, exc_type, exc_val, exc_tb) -> bool: """Call when the client is disposed.""" # Close the web session, if we created it (i.e. it was not passed in) if self._own_session: await self.close() return False async def close(self): """Close the current web session.""" if self._session: await self._session.close() self._session = None async def login(self) -> str: """ Call to obtain login token, controller id, and site id. Calls to login are optional, as the API will automatically authenticate as necessary. However, you may want to attempt a login to check connectivity. """ version, controller_id = await self._get_controller_info() if AwesomeVersion(version) < AwesomeVersion("5.1.0"): raise UnsupportedControllerVersion(self._controller_version) self._controller_id = controller_id self._controller_version = version auth = {"username": self._username, "password": self._password} response = await self._do_request("post", self.format_url("login"), json=auth) self._csrf_token = response["token"] self._last_logon = time.time() return self._controller_id async def _check_login(self) -> bool: if self._csrf_token is None: return False if time.time() - self._last_logon < 60 * 60: # Assume 1hr is good for a login to remain active return True try: response = await self._do_request("get", self.format_url("loginStatus")) logged_in = bool(response["login"]) if logged_in: self._last_logon = time.time() return logged_in except: # pylint: disable=bare-except # noqa: E722 return False async def _get_controller_info(self) -> tuple[str, str]: """Get Omada controller version and Id (unauthenticated).""" response = await self._do_request("get", urljoin(self._url, "/api/info")) return (response["controllerVer"], response["omadacId"]) def format_url(self, end_point: str, site: str | None = None) -> str: """Get a REST url for the controller action""" if site: end_point = f"sites/{site}/{end_point}" return urljoin(self._url, f"/{self._controller_id}/api/v2/{end_point}") async def iterate_pages(self, url: str, params: dict[str, Any] | None = None) -> AsyncIterable[dict[str, Any]]: """Iterates all the entries of a paged endpoint""" request_params = {} if params is not None: request_params.update(params) actual_page_size = _PAGE_SIZE current_page = 1 has_next = True while has_next: request_params["currentPageSize"] = actual_page_size request_params["currentPage"] = current_page response = await self.request("get", url, request_params) # Setup next page request actual_page_size = int(response["currentSize"]) total_rows = int(response["totalRows"]) has_next = total_rows > current_page * actual_page_size current_page += 1 data: list[dict[str, Any]] = response["data"] for item in data: yield item async def request(self, method: str, url: str, params=None, json=None, data: Payload | None = None) -> Any: """Perform a request specific to the controlller, with authentication""" if not await self._check_login(): await self.login() return await self._do_request(method, url, params=params, json=json, data=data) async def _do_request(self, method: str, url: str, params=None, json=None, data: Payload | None = None) -> Any: """Perform a request on the controller, and unpack the response.""" session = await self._get_session() # Note: Auth happens via cookies, set during the login command, but we also get a CSRF token # which we need to push back headers = {} if self._csrf_token: headers["Csrf-Token"] = self._csrf_token try: async with session.request( method, url, params=params, headers=headers, json=json, data=data, ssl=self._verify_ssl, ) as response: if response.status != 200: if response.content_type == "application/json": content = await response.json(encoding="utf-8") self._check_application_errors(content) raise RequestFailed(response.status, "HTTP Request Error") # If something goes wrong with the login session, Omada requests return "success", and a login page. :/ if response.content_type != "application/json": raise LoginSessionClosed() content = await response.json(encoding="utf-8") self._check_application_errors(content) # Unpack response data if "result" in content: return content["result"] return content except client_exceptions.InvalidURL as err: raise BadControllerUrl(err) from err except client_exceptions.ClientConnectionError as err: raise ConnectionFailed(err) from err except client_exceptions.ClientError as err: raise RequestFailed(0, f"Unexpected error: {err}") from None def _check_application_errors(self, response): if not isinstance(response, dict): return if "errorCode" not in response: raise RequestFailed(-30109, "Unexpected response: " + str(response)) if response["errorCode"] == 0: return if response["errorCode"] == -30109: raise LoginFailed(response["errorCode"], response["msg"]) raise RequestFailed(response["errorCode"], response["msg"]) tplink-omada-api-release-v1.4.4/src/tplink_omada_client/omadaclient.py000066400000000000000000000106571474075305400261160ustar00rootroot00000000000000"""Simple Http client for Omada controller REST api.""" import os from typing import NamedTuple from aiohttp import MultipartWriter from aiohttp.client import ClientSession from multidict import CIMultiDict from .omadasiteclient import OmadaSiteClient from .omadaapiconnection import OmadaApiConnection from .exceptions import ( SiteNotFound, ) from .devices import ( OmadaInterfaceDetails, ) class OmadaSite(NamedTuple): """Identifies a site controlled by the controller.""" name: str id: str class OmadaClient: """ Simple client for Omada controller API Provides a very limited subset of the API documented in the 'Omada_SDN_Controller_V5.0.15 API Document' """ def __init__( self, url: str, username: str, password: str, websession: ClientSession | None = None, verify_ssl=True, ): self._api = OmadaApiConnection(url, username, password, websession, verify_ssl) async def __aenter__(self): await self._api.__aenter__() return self async def __aexit__(self, *args) -> bool: """Call when the client is disposed.""" # Close the web session, if we created it (i.e. it was not passed in) return await self._api.__aexit__(*args) async def login(self) -> str: """ Log in to the controller and returns the controller's unique ID. Calls to login are optional, as the API will automatically authenticate as necessary. However, you may want to attempt a login to check connectivity. """ return await self._api.login() async def get_controller_name(self) -> str: """Get the display name of the Omada controller.""" result = await self._api.request("get", self._api.format_url("maintenance/uiInterface")) return OmadaInterfaceDetails(result).controller_name async def get_sites(self) -> list[OmadaSite]: """Get basic list of sites the user can see""" response = await self._api.request("get", self._api.format_url("users/current")) sites = [OmadaSite(s["name"], s["key"]) for s in response["privilege"]["sites"]] return sites async def get_site_client(self, site: str | OmadaSite) -> OmadaSiteClient: """Get a client that can query the specified Omada site.""" if isinstance(site, OmadaSite): site_id = site.id else: site_id = await self._get_site_id(site) return OmadaSiteClient(site_id, self._api) async def _get_site_id(self, site_name: str): """Get site id by (display) name""" # The current user object has a list of allowed sites to administer sites = await self.get_sites() site_id = next((s.id for s in sites if s.name == site_name), None) if site_id: return site_id raise SiteNotFound(f"Site '{site_name}' not found") async def reboot(self) -> int: """ Reboot the Omada controller. Returns the estimated number of seconds until the reboot finishes. """ url = self._api.format_url("cmd/reboot") result = await self._api.request("post", url) return result["rebootTime"] async def set_certificate(self, file: str, cert_password: str): """Upload a new PKCS12 PFX certificate to the controller.""" base_name = os.path.basename(file) with open(file, "rb") as upload_file: cert_data = upload_file.read() with MultipartWriter("form-data") as mpwriter: file_part = mpwriter.append(cert_data, CIMultiDict({"Content-Type": "application/x-pkcs12"})) file_part.set_content_disposition("form-data", name="file", filename=base_name) data_part = mpwriter.append_json({"cerName": base_name}) data_part.set_content_disposition("form-data", name="data") url = self._api.format_url("files/controller/certificate") upload_result = await self._api.request("post", url, data=mpwriter) cert_id = upload_result["cerId"] cert_name = upload_result["cerName"] payload = { "certificate": { "cerId": cert_id, "cerName": cert_name, "cerType": "PFX", "enable": True, "keyPassword": cert_password, } } url = self._api.format_url("controller/setting") await self._api.request("patch", url, json=payload) tplink-omada-api-release-v1.4.4/src/tplink_omada_client/omadasiteclient.py000066400000000000000000000546131474075305400270030ustar00rootroot00000000000000"""Client for Omada Site requests.""" from typing import AsyncIterable from dataclasses import dataclass from .clients import ( OmadaClientDetails, OmadaConnectedClient, OmadaNetworkClient, OmadaWiredClient, OmadaWiredClientDetails, OmadaWirelessClient, OmadaWirelessClientDetails, ) from .definitions import ( BandwidthControl, Eth802Dot1X, LinkDuplex, LinkSpeed, PoEMode, LedSetting, ) from .devices import ( OmadaAccessPoint, OmadaDevice, OmadaFirmwareUpdate, OmadaGateway, OmadaGatewayPortConfig, OmadaGatewayPortStatus, OmadaListDevice, OmadaPortProfile, OmadaSwitch, OmadaSwitchPort, OmadaSwitchPortDetails, OmadaAccesPointLanPortSettings, ) from .exceptions import ( InvalidDevice, ) from .omadaapiconnection import OmadaApiConnection @dataclass class SwitchPortOverrides: """ Overrides that can be applied to a switch port. Currently, we don't support bandwidth limits and mirroring modes. Due to the way the API works, we have to specify overrides for everything, we can't just override a single profile setting. Therefore, you may need to initialise all of these parameters to avoid overwriting settings. """ enable_poe: bool = True dot1x_mode: Eth802Dot1X = Eth802Dot1X.FORCE_AUTHORIZED duplex: LinkDuplex = LinkDuplex.AUTO link_speed: LinkSpeed = LinkSpeed.SPEED_AUTO lldp_med_enable: bool = True loopback_detect: bool = True spanning_tree_enable: bool = False port_isolation: bool = False @dataclass class AccessPointPortSettings: """ Settings that can be applied to network ports on access points Specify the values you want to modify. The remaining values will be unaffected """ enable_poe: bool | None = None vlan_enable: bool | None = None vlan_id: int | None = None @dataclass class GatewayPortSettings: """ Settings that can be applied to network ports on gateways Specify the values you want to modify. The remaining values will be unaffected """ enable_poe: bool | None = None @dataclass class OmadaClientFixedAddress: """ Describes a fixed IP address reservation for a client """ network_id: str | None = None ip_address: str | None = None @dataclass class OmadaClientSettings: """ Settings that can be applied to a client """ name: str | None = None lock_to_aps: list[str] | None = None fixed_address: OmadaClientFixedAddress | None = None class OmadaSiteClient: """Client for querying an Omada site's devices.""" def __init__(self, site_id: str, api: OmadaApiConnection): self._api = api self._site_id = site_id async def block_client(self, mac_or_client: str | OmadaNetworkClient) -> None: """Block the specified client from the network.""" if isinstance(mac_or_client, OmadaConnectedClient): mac = mac_or_client.mac else: mac = mac_or_client await self._api.request("post", self._api.format_url(f"cmd/clients/{mac}/block", self._site_id)) async def unblock_client(self, mac_or_client: str | OmadaNetworkClient) -> None: """Unblock the specified client from the network.""" if isinstance(mac_or_client, OmadaConnectedClient): mac = mac_or_client.mac else: mac = mac_or_client await self._api.request("post", self._api.format_url(f"cmd/clients/{mac}/unblock", self._site_id)) async def reconnect_client(self, mac_or_client: str | OmadaNetworkClient) -> None: """Reconnect the specified client.""" if isinstance(mac_or_client, OmadaConnectedClient): mac = mac_or_client.mac else: mac = mac_or_client await self._api.request("post", self._api.format_url(f"cmd/clients/{mac}/reconnect", self._site_id)) async def get_client(self, mac_or_client: str | OmadaNetworkClient) -> OmadaClientDetails: """Get the details of a client""" if isinstance(mac_or_client, OmadaConnectedClient): mac = mac_or_client.mac else: mac = mac_or_client result = await self._api.request("get", self._api.format_url(f"clients/{mac}", self._site_id)) if result.get("wireless"): return OmadaWirelessClientDetails(result) else: return OmadaWiredClientDetails(result) async def update_client(self, mac_or_client: str | OmadaNetworkClient, settings: OmadaClientSettings): """Update configuration of a client""" if isinstance(mac_or_client, OmadaConnectedClient): mac = mac_or_client.mac else: mac = mac_or_client payload = {} if settings.name: payload["name"] = settings.name if settings.lock_to_aps is not None: payload["clientLockToApSetting"] = { "enable": len(settings.lock_to_aps) > 0, "aps": settings.lock_to_aps, } if settings.fixed_address: if settings.fixed_address.ip_address: payload["ipSetting"] = { "useFixedAddr": True, "netId": settings.fixed_address.network_id, "ip": settings.fixed_address.ip_address, } else: payload["ipSetting"] = {"useFixedAddr": False} if not payload: return await self.get_client(mac_or_client) result = await self._api.request("patch", self._api.format_url(f"clients/{mac}", self._site_id), json=payload) if result.get("wireless"): return OmadaWirelessClientDetails(result) else: return OmadaWiredClientDetails(result) async def get_connected_clients(self) -> AsyncIterable[OmadaConnectedClient]: """Get the clients connected to the site network.""" async for client in self._api.iterate_pages( self._api.format_url("clients", self._site_id), {"filters.active": "false"} ): is_wireless = client.get("wireless") if is_wireless: yield OmadaWirelessClient(client) elif is_wireless is False: yield OmadaWiredClient(client) async def get_known_clients(self) -> AsyncIterable[OmadaNetworkClient]: """Get the clients connected to the site network.""" async for client in self._api.iterate_pages(self._api.format_url("insight/clients", self._site_id)): is_wireless = client.get("wireless") if is_wireless: yield OmadaWirelessClient(client) elif is_wireless is False: yield OmadaWiredClient(client) async def get_devices(self) -> list[OmadaListDevice]: """Get the list of devices on the site.""" result = await self._api.request("get", self._api.format_url("devices", self._site_id)) return [OmadaListDevice(d) for d in result] async def get_device(self, mac: str) -> OmadaListDevice: """Get a single device by mac.""" # So wasteful return next(d for d in await self.get_devices() if d.mac == mac) async def get_switches(self) -> list[OmadaSwitch]: """Get the list of switches on the site.""" return [await self.get_switch(d) for d in await self.get_devices() if d.type == "switch"] async def get_access_points(self) -> list[OmadaAccessPoint]: """Get the list of access points on the site.""" return [await self.get_access_point(d) for d in await self.get_devices() if d.type == "ap"] async def get_access_point(self, mac_or_device: str | OmadaDevice) -> OmadaAccessPoint: """Get an access point by Mac address or Omada device.""" if isinstance(mac_or_device, OmadaDevice): if mac_or_device.type != "ap": raise InvalidDevice() mac = mac_or_device.mac else: mac = mac_or_device result = await self._api.request("get", self._api.format_url(f"eaps/{mac}", self._site_id)) return OmadaAccessPoint(result) async def get_access_point_port(self, mac_or_device: str | OmadaDevice, port_name: str) -> OmadaAccesPointLanPortSettings: """Get the config of a single network port on an access point.""" ap = await self.get_access_point(mac_or_device) port = next(p for p in ap.lan_port_settings if p.port_name == port_name) if port is None: raise InvalidDevice(f"Port {port_name} not found") return port async def get_switch(self, mac_or_device: str | OmadaDevice) -> OmadaSwitch: """Get a switch by Mac address or Omada device.""" if isinstance(mac_or_device, OmadaDevice): if mac_or_device.type != "switch": raise InvalidDevice() mac = mac_or_device.mac else: mac = mac_or_device result = await self._api.request("get", self._api.format_url(f"switches/{mac}", self._site_id)) return OmadaSwitch(result) async def get_switch_ports(self, mac_or_device: str | OmadaDevice) -> list[OmadaSwitchPortDetails]: """Get ports of a switch by Mac address or Omada device.""" if isinstance(mac_or_device, OmadaDevice): if mac_or_device.type != "switch": raise InvalidDevice() mac = mac_or_device.mac else: mac = mac_or_device result = await self._api.request("get", self._api.format_url(f"switches/{mac}/ports", self._site_id)) return [OmadaSwitchPortDetails(p) for p in result] async def get_switch_port( self, mac_or_device: str | OmadaDevice, index_or_port: int | OmadaSwitchPort, ) -> OmadaSwitchPortDetails: """Get a single port of a switch by Mac address or Omada device, and port number or port object of OmadaSwitch device.""" if isinstance(mac_or_device, OmadaDevice): if mac_or_device.type != "switch": raise InvalidDevice() mac = mac_or_device.mac else: mac = mac_or_device if isinstance(index_or_port, OmadaSwitchPort): port = index_or_port.port else: port = index_or_port result = await self._api.request("get", self._api.format_url(f"switches/{mac}/ports/{port}", self._site_id)) return OmadaSwitchPortDetails(result) async def get_switch_port_overrides( self, mac_or_device: str | OmadaDevice, index_or_port: int | OmadaSwitchPort, ) -> SwitchPortOverrides: """Return the current override settings for the port of a switch, or the current profile settings as default.""" port = await self.get_switch_port(mac_or_device, index_or_port) # Returns the current overrides if port.has_profile_override: return SwitchPortOverrides( enable_poe=(port.poe_mode == PoEMode.ENABLED), dot1x_mode=port.eth_802_1x_control, duplex=port.duplex, link_speed=port.link_speed, lldp_med_enable=port.lldp_med_enabled, loopback_detect=port.loopback_detect_enabled, spanning_tree_enable=port.spanning_tree_enabled, port_isolation=port.port_isolation_enabled, ) # Otherwise the profile's config values are returned prof = await self.get_port_profile(port.profile_id) # The API doesn't provide the PoE mode of the switch (couldn't even find in Omada # GUI how to set the PoE mode of a switch). Thus, use True as a default value. poe_mode = prof.poe_mode != PoEMode.DISABLED return SwitchPortOverrides( enable_poe=poe_mode, dot1x_mode=prof.eth_802_1x_control, duplex=LinkDuplex.AUTO, link_speed=LinkSpeed.SPEED_AUTO, lldp_med_enable=prof.lldp_med_enabled, loopback_detect=prof.loopback_detect_enabled, spanning_tree_enable=prof.spanning_tree_enabled, port_isolation=prof.port_isolation_enabled, ) async def update_access_point_port( self, mac_or_device: str | OmadaDevice, port_name: str, setting: AccessPointPortSettings, ) -> OmadaAccesPointLanPortSettings: """Update the settings for a lan port on the access point.""" # Get the latest representation of the acccess point access_point = await self.get_access_point(mac_or_device) port_settings = [ { "id": port_name, "lanPort": port_name, "localVlanEnable": setting.vlan_enable if setting.vlan_enable is not None else ps.local_vlan_enable, "localVlanId": setting.vlan_id if setting.vlan_id is not None else ps.local_vlan_id, "poeOutEnable": setting.enable_poe if setting.enable_poe is not None and ps.supports_poe else ps.poe_enable, } for ps in access_point.lan_port_settings if ps.port_name == port_name ] payload = {"lanPortSettings": port_settings} result = await self._api.request( "patch", self._api.format_url(f"eaps/{access_point.mac}", self._site_id), json=payload, ) updated_ap = OmadaAccessPoint(result) # The caller probably only cares about the updated port status return next(p for p in updated_ap.lan_port_settings if p.port_name == port_name) async def update_switch_port( self, mac_or_device: str | OmadaDevice, index_or_port: int | OmadaSwitchPort, new_name: str | None = None, profile_id: str | None = None, overrides: SwitchPortOverrides | None = None, ) -> OmadaSwitchPortDetails: """Applies an existing profile to a switch on the port""" if isinstance(mac_or_device, OmadaDevice): if mac_or_device.type != "switch": raise InvalidDevice() mac = mac_or_device.mac else: mac = mac_or_device if isinstance(index_or_port, OmadaSwitchPort): port = index_or_port else: port = await self.get_switch_port(mac_or_device, index_or_port) payload = { "name": new_name or port.name, "profileId": profile_id or port.profile_id, "profileOverrideEnable": overrides is not None, } if overrides: payload["operation"] = "switching" payload["bandWidthCtrlType"] = BandwidthControl.OFF payload["poe"] = PoEMode.ENABLED if overrides.enable_poe else PoEMode.DISABLED payload["dot1x"] = overrides.dot1x_mode payload["duplex"] = overrides.duplex payload["linkSpeed"] = overrides.link_speed payload["lldpMedEnable"] = overrides.lldp_med_enable payload["loopbackDetectEnable"] = overrides.loopback_detect payload["spanningTreeEnable"] = overrides.spanning_tree_enable payload["portIsolationEnable"] = overrides.port_isolation payload["topoNotifyEnable"] = False await self._api.request( "patch", self._api.format_url(f"switches/{mac}/ports/{port.port}", self._site_id), json=payload, ) # Read back the new port settings return await self.get_switch_port(mac, port) async def get_port_profile(self, profile_id: str) -> OmadaPortProfile: """Get the details of a port profile by ID.""" profiles = await self.get_port_profiles() profile = next((p for p in profiles if p.profile_id == profile_id), None) if not profile: raise InvalidDevice(f"Port profile {profile_id} does not exist") return profile async def get_port_profiles(self) -> list[OmadaPortProfile]: """Lists the available switch port profiles that can be applied.""" result = await self._api.request("get", self._api.format_url("setting/lan/profileSummary", self._site_id)) return [OmadaPortProfile(p) for p in result["data"]] async def get_firmware_details(self, mac_or_device: str | OmadaDevice) -> OmadaFirmwareUpdate: """Get details of the device firware and available upgrades.""" if isinstance(mac_or_device, OmadaDevice): mac = mac_or_device.mac else: mac = mac_or_device result = await self._api.request("get", self._api.format_url(f"devices/{mac}/firmware", self._site_id)) return OmadaFirmwareUpdate(result) async def start_firmware_upgrade(self, mac_or_device: str | OmadaDevice) -> bool: """Begin an automatic firmware upgrade of the specified device""" if isinstance(mac_or_device, OmadaDevice): mac = mac_or_device.mac else: mac = mac_or_device payload = {"mac": mac} await self._api.request( "post", self._api.format_url(f"cmd/devices/{mac}/onlineUpgrade", self._site_id), json=payload, ) return True async def get_gateways(self) -> list[OmadaGateway]: """Get the list of gateways (routers) on the site. (Zero or one!)""" return [await self.get_gateway(d) for d in await self.get_devices() if d.type == "gateway"] async def _get_gateway_mac(self, mac_or_device: str | OmadaDevice | None) -> str: if mac_or_device is None: mac_or_device = next((d for d in await self.get_devices() if d.type == "gateway"), None) if mac_or_device is None: raise InvalidDevice("No gateways found in site") if isinstance(mac_or_device, OmadaDevice): if mac_or_device.type != "gateway": raise InvalidDevice() return mac_or_device.mac return mac_or_device async def get_gateway(self, mac_or_device: str | OmadaDevice | None = None) -> OmadaGateway: """Get the gatway (router) for the site by Mac address or Omada device. (There can be only one!)""" mac = await self._get_gateway_mac(mac_or_device) result = await self._api.request("get", self._api.format_url(f"gateways/{mac}", self._site_id)) return OmadaGateway(result) async def get_gateway_port(self, port_id: int, mac_or_deviec: str | OmadaDevice | None = None) -> OmadaGatewayPortConfig: """Get the port config for a specified port on the gateway""" gw = await self.get_gateway(mac_or_deviec) port_config = next(p for p in gw.port_configs if p.port_number == port_id) if port_config is None: raise InvalidDevice(f"Port {port_id} not found") return port_config async def set_gateway_wan_port_connect_state( self, port_id: int, connect: bool, mac_or_device: str | OmadaDevice | None = None, ipv6: bool = False, ) -> OmadaGatewayPortStatus: """Connects or disconnects the specified WAN port of the gateway to the internet.""" mac = await self._get_gateway_mac(mac_or_device) payload = {"portId": port_id, "operation": 1 if connect else 0} result = await self._api.request( "post", self._api.format_url( f"cmd/gateways/{mac}/{'ipv6State' if ipv6 else 'internetState'}", self._site_id, ), json=payload, ) return OmadaGatewayPortStatus(result) async def set_gateway_port_settings( self, port_id: int, settings: GatewayPortSettings, mac_or_device: str | OmadaDevice | None = None, ) -> OmadaGatewayPortConfig: """Sets the settings for the specified port of the gateway.""" mac = await self._get_gateway_mac(mac_or_device) # Currently, we (and the Omada API) only supports PoE, so if the caller isn't asking for a PoE change, # it's a no-op, but we should still return the current settings if settings.enable_poe is not None: # Reject requests that ask to set PoE on gateways that don't support it gw = await self.get_gateway(mac) if not gw.supports_poe and settings.enable_poe is not None: raise InvalidDevice("This gateway does not support PoE") # Thanks to dkriegner, we know the request format is: # { # "lldpEnable": false, # "echoServer": "0.0.0.0", # "poeSettings": [ # {"enable": true, "portId": 5}, # {"enable": true, "portId": 6}, # {"enable": true, "portId": 7}, # {"enable": true, "portId": 8}, # {"enable": true, "portId": 9}, # {"enable": true, "portId": 10}, # {"enable": true, "portId": 11}, # {"enable": true, "portId": 12}, # ], # } # We probably don't need to specify all of these for PATCH, but it's what the UI does, # and I have no way of testing payload = { "lldpEnable": gw.lldp_enabled, "echoServer": gw.echo_server, "poeSettings": [ # Output an entry for every port that supports PoE, setting the appropriate port as requested { "enable": settings.enable_poe if settings.enable_poe is not None and port_id == p.port_number else p.poe_mode == PoEMode.ENABLED, "portId": p.port_number, } for p in gw.port_configs if p.poe_mode != PoEMode.NONE ], } await self._api.request( "patch", self._api.format_url(gw.resource_path, self._site_id), json=payload, ) # The result data includes an incomplete representation of the gateway port state, # so we just request a new update return await self.get_gateway_port(port_id, mac) async def set_led_setting(self, mac_or_device: str | OmadaDevice, setting: LedSetting) -> bool: """Sets the onboard LED setting for the device""" if isinstance(mac_or_device, OmadaDevice): device = mac_or_device else: device = await self.get_device(mac_or_device) payload = {"mac": device.mac, "ledSetting": setting.value} await self._api.request( "patch", self._api.format_url(device.resource_path, self._site_id), json=payload, ) return True tplink-omada-api-release-v1.4.4/src/tplink_omada_client/py.typed000066400000000000000000000000001474075305400247400ustar00rootroot00000000000000tplink-omada-api-release-v1.4.4/tests/000077500000000000000000000000001474075305400176265ustar00rootroot00000000000000tplink-omada-api-release-v1.4.4/tests/test_conftest.py000066400000000000000000000002141474075305400230610ustar00rootroot00000000000000"""Setup test helpers""" import pytest def test_generic(): # TODO: Add your test logic here assert True # Placeholder assertion