├── .github └── workflows │ └── versions.yml ├── .gitignore ├── LICENSE ├── README.md ├── alerts.py ├── clients.py ├── devices.py ├── events.py ├── led.py ├── omada ├── __init__.py └── omada.py ├── requirements.txt ├── setup.py └── upload_cert.py /.github/workflows/versions.yml: -------------------------------------------------------------------------------- 1 | name: Test Python versions 2 | on: [push, pull_request] 3 | jobs: 4 | build: 5 | runs-on: ubuntu-latest 6 | strategy: 7 | matrix: 8 | python-version: ['3.7','3.8','3.9','3.10','3.11'] 9 | steps: 10 | - uses: actions/checkout@v3 11 | - name: Setup Python 12 | uses: actions/setup-python@v4 13 | with: 14 | python-version: ${{ matrix.python-version }} 15 | - name: Install dependencies 16 | run: | 17 | python3 -m pip install --upgrade pip 18 | pip3 install -r requirements.txt 19 | - name: Test Python code 20 | run: | 21 | python3 -m compileall omada/ 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | *.egg-info/ 3 | *.swp 4 | *.cfg 5 | .venv/ 6 | .vscode/ 7 | sta.py -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Gregory Haberek 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Omada API 2 | 3 | A simple Python wrapper for the [TP-Link Omada Software Controller](https://www.tp-link.com/us/support/download/omada-software-controller/) API. 4 | 5 | [![Test Python versions](https://github.com/ghaberek/omada-api/actions/workflows/versions.yml/badge.svg)](https://github.com/ghaberek/omada-api/actions/workflows/versions.yml) 6 | 7 | ## Resources 8 | 9 | Here are some links which may be helpful when using or extending this library: 10 | 11 | - [Omada SDN Controller V5.9.9 API Document](https://community.tp-link.com/en/business/forum/topic/590430?replyId=1196216) 12 | - [Omada SDN Controller V5.4.6 API Document](https://community.tp-link.com/en/business/forum/topic/590430?replyId=1196214) 13 | - [Omada SDN Controller V5.0.15 API Document](https://community.tp-link.com/en/business/forum/topic/529298?replyId=1044808) 14 | - [Omada SDN Controller V4.1.5 API Document](https://community.tp-link.com/en/business/forum/topic/253944?replyId=565824) 15 | - [Requirements of Establishing an External Portal Server (> 5.0.15)](https://www.tp-link.com/us/support/faq/3231/) 16 | - [Requirements of Establishing an External Portal Server (> 4.1.5)](https://www.tp-link.com/us/support/faq/2907/) 17 | - [Requirements of Establishing an External Portal Server (> 2.6.0)](https://www.tp-link.com/us/support/faq/2274/) 18 | - [Requirements of Establishing an External Portal Server (< 2.5.4)](https://www.tp-link.com/us/support/faq/928/) 19 | 20 | ## Usage 21 | 22 | Currently this is just the bare-minimum required to log in, get site settings, push site settings, and log out. 23 | 24 | ``` 25 | from omada import Omada 26 | 27 | # load our local config file 28 | omada = Omada('omada.cfg') 29 | 30 | # or specify the baseurl and site name directly 31 | #omada = Omada(baseurl='https://...', site='Office') 32 | 33 | # log into the controller (username and password are in omada.cfg) 34 | omada.login() 35 | 36 | # or specify the username and password directly 37 | # omada.login(username='apiuser', password='secretpassword') 38 | 39 | # get the site settings 40 | settings = omada.getSiteSettings() 41 | 42 | # turn the LEDs off 43 | settings['led']['enable'] = False 44 | 45 | # push the settings back 46 | omada.setSiteSettings(settings) 47 | 48 | # log out of the controller 49 | omada.logout() 50 | ``` 51 | 52 | ## Examples 53 | 54 | ### [led.py](led.py) 55 | 56 | I use this in a cron schedule to turn my site LED setting off at night and back on in the morning. 57 | 58 | Turn the LED on: 59 | 60 | ``` 61 | $ python led.py on 62 | led: on 63 | ``` 64 | Turn the LED off: 65 | 66 | ``` 67 | $ python led.py off 68 | led: off 69 | ``` 70 | 71 | ### [clients.py](clients.py), [devices.py](devices.py) 72 | 73 | These are simple apps to display similar output to the "Clients" and "Devices" page on the web interface. 74 | 75 | ``` 76 | $ python clients.py 77 | USERNAME IP ADDRESS STATUS 78 | 00-11-22-33-44-55 192.168.1.123 CONNECTED 79 | ... 80 | ``` 81 | 82 | Make sure you have your [Settings](#Settings) file configured correctly for these to work. 83 | 84 | ## Settings 85 | 86 | You can store your controller settings in a configuration file to avoid hard-coding them in your scripts. 87 | 88 | Currently supported settings: 89 | 90 | - `baseurl` - the base url to the controller 91 | - `site` - the name of the site in the controller (usually `Default`) 92 | - `verify` - set this to `False` to ignore self-signed certificate errors 93 | - `warnings` - set this to `False` to hide urllib3 warnings when `verify=False` 94 | - `verbose` - set this to `True` to force low-level reqeusts to output debugging info 95 | - `username` - the username to log in as 96 | - `password` - the password for the user 97 | 98 | ### Example 99 | 100 | ``` 101 | [omada] 102 | baseurl = https://omadacontroller.local:8043 103 | site = Default 104 | verify = False 105 | username = apiuser 106 | password = secretpassword 107 | ``` 108 | 109 | ## Acknowledgements 110 | 111 | For my wife, who asked that I turn off the device LEDs at night. :heart: 112 | -------------------------------------------------------------------------------- /alerts.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import sys, time, re, collections 4 | from omada import Omada 5 | 6 | FIELDDEF = collections.OrderedDict([ 7 | ('content',('CONTENT',72)), 8 | ('time', ('TIME', 24)), 9 | ]) 10 | 11 | def format_date( date ): 12 | return time.strftime('%b %e %Y %I:%M:%S %p', time.localtime(date // 1000)) 13 | 14 | def print_header(): 15 | 16 | if sys.stdout.isatty(): 17 | sys.stdout.write( '\33[1m' ) 18 | 19 | for key in FIELDDEF: 20 | text,width = FIELDDEF[key] 21 | sys.stdout.write( text.ljust(abs(width)) ) 22 | 23 | if sys.stdout.isatty(): 24 | sys.stdout.write( '\33[0m' ) 25 | 26 | sys.stdout.write( '\n' ) 27 | 28 | def print_alert( alert ): 29 | 30 | for key in alert: 31 | 32 | if key == 'content': 33 | match = re.search( r'\[([a-z]+):([0-9A-F\-]+)\]', alert[key] ) 34 | if match: 35 | tag = str( match.group(1) ) 36 | mac = str( match.group(2) ) 37 | type = 'clientNames' if tag == 'client' else 'deviceNames' 38 | name = '[' + alert[type][mac] + ']' 39 | alert[key] = alert[key].replace( match.group(0), name ) 40 | 41 | elif key == 'time': 42 | alert[key] = format_date( alert[key] ) 43 | 44 | for key in FIELDDEF: 45 | 46 | text = alert[key].strip() if key in alert else '--' 47 | if not isinstance(text,str): text = str(text) 48 | 49 | width = FIELDDEF[key][1] 50 | 51 | if len(text) >= abs(width): 52 | text = text[0:abs(width)-4] + '... ' 53 | elif width > 0: 54 | text = text.ljust(abs(width)) 55 | else: 56 | text = text.rjust(abs(width)-1) 57 | 58 | sys.stdout.write( text ) 59 | 60 | sys.stdout.write( '\n' ) 61 | 62 | def main(): 63 | omada = Omada() 64 | omada.login() 65 | 66 | print_header() 67 | 68 | for alert in omada.getSiteAlerts(): 69 | print_alert( alert ) 70 | 71 | omada.logout() 72 | 73 | if __name__ == '__main__': 74 | main() 75 | -------------------------------------------------------------------------------- /clients.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import sys, collections 4 | from omada import Omada 5 | 6 | FIELDDEF = collections.OrderedDict([ 7 | ('name', ('USERNAME', 20)), 8 | ('ip', ('IP ADDRESS', 16)), 9 | ('active', ('STATUS', 12)), 10 | ('networkName', ('SSID/NETWORK', 16)), 11 | ('port', ('AP/PORT', 16)), 12 | ('activity', ('ACTIVITY', 12)), 13 | ('trafficDown', ('DOWNLOAD', 10)), 14 | ('trafficUp', ('UPLOAD', 10)), 15 | ('uptime', ('UPTIME', 16)), 16 | ]) 17 | 18 | def format_status( active ): 19 | return 'CONNECTED' if active else '--' 20 | 21 | def format_port( switch, port ): 22 | return f'{switch} Port {port}' 23 | 24 | def format_size( size, suffix='B' ): 25 | for unit in ['K','M','G','T','P','E','Z']: 26 | size /= 1000.0 27 | if abs( size ) < 1000.0: 28 | return f'{size:.1f} {unit}{suffix}' 29 | return f'{size:.1f} Y{suffix}' 30 | 31 | def format_time( time ): 32 | d = time // (3600 * 24) 33 | h = time // 3600 % 24 34 | m = time % 3600 // 60 35 | s = time % 3600 % 60 36 | if d > 0: return f'{d}d {h}:{m:02d}:{s:02d}' 37 | if h > 0: return f'{h}:{m:02d}:{s:02d}' 38 | if m > 0: return f'{m:02d}:{s:02d}' 39 | if s > 0: return f'{s:02d}' 40 | return '--' 41 | 42 | def print_header(): 43 | 44 | if sys.stdout.isatty(): 45 | sys.stdout.write( '\33[1m' ) 46 | 47 | for key in FIELDDEF: 48 | text,width = FIELDDEF[key] 49 | sys.stdout.write( text.ljust(abs(width)) ) 50 | 51 | if sys.stdout.isatty(): 52 | sys.stdout.write( '\33[0m' ) 53 | 54 | sys.stdout.write( '\n' ) 55 | 56 | def print_client( client ): 57 | 58 | for key in client: 59 | 60 | if key == 'active': 61 | client[key] = format_status( client['active'] ) 62 | 63 | elif key == 'port': 64 | client[key] = format_port( client['switchName'], client['port'] ) 65 | 66 | elif key == 'activity': 67 | client[key] = format_size( client['activity'], 'B/s' ) 68 | 69 | elif key == 'trafficDown': 70 | client[key] = format_size( client['trafficDown'], 'B' ) 71 | 72 | elif key == 'trafficUp': 73 | client[key] = format_size( client['trafficUp'], 'B' ) 74 | 75 | elif key == 'uptime': 76 | client[key] = format_time( client['uptime'] ) 77 | 78 | if client['connectDevType'] == 'ap': 79 | client['networkName'] = client['ssid'] 80 | client['port'] = client['apName'] 81 | 82 | for key in FIELDDEF: 83 | 84 | text = client[key] if key in client else '--' 85 | if not isinstance(text,str): text = str(text) 86 | 87 | width = FIELDDEF[key][1] 88 | 89 | if len(text) >= abs(width): 90 | text = text[0:abs(width)-4] + '... ' 91 | elif width > 0: 92 | text = text.ljust(abs(width)) 93 | else: 94 | text = text.rjust(abs(width)-1) 95 | 96 | sys.stdout.write( text ) 97 | 98 | sys.stdout.write( '\n' ) 99 | 100 | def main(): 101 | omada = Omada() 102 | omada.login() 103 | 104 | print_header() 105 | 106 | for client in omada.getSiteClients(): 107 | print_client( client ) 108 | 109 | omada.logout() 110 | 111 | if __name__ == '__main__': 112 | main() 113 | -------------------------------------------------------------------------------- /devices.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import sys, collections 4 | from omada import Omada 5 | 6 | FIELDDEF = collections.OrderedDict([ 7 | ('name', ('NAME', 16)), 8 | ('ip', ('IP ADDRESS', 16)), 9 | ('status', ('STATUS', 12)), 10 | ('showModel', ('MODEL', 24)), 11 | ('version', ('VERSION', 12)), 12 | ('uptime', ('UPTIME', 16)), 13 | ]) 14 | 15 | def format_time( time ): 16 | d = time // (3600 * 24) 17 | h = time // 3600 % 24 18 | m = time % 3600 // 60 19 | s = time % 3600 % 60 20 | if d > 0: return f'{d}d {h}:{m:02d}:{s:02d}' 21 | if h > 0: return f'{h}:{m:02d}:{s:02d}' 22 | if m > 0: return f'{m:02d}:{s:02d}' 23 | if s > 0: return f'{s:02d}' 24 | return '--' 25 | 26 | def print_header(): 27 | 28 | if sys.stdout.isatty(): 29 | sys.stdout.write( '\33[1m' ) 30 | 31 | for key in FIELDDEF: 32 | text,width = FIELDDEF[key] 33 | sys.stdout.write( text.ljust(abs(width)) ) 34 | 35 | if sys.stdout.isatty(): 36 | sys.stdout.write( '\33[0m' ) 37 | 38 | sys.stdout.write( '\n' ) 39 | 40 | def print_device( device ): 41 | 42 | for key in device: 43 | 44 | if key == 'status': 45 | device[key] = 'CONNECTED' if device['status'] else '--' 46 | 47 | elif key == 'uptime': 48 | device[key] = format_time( device['uptimeLong'] ) 49 | 50 | for key in FIELDDEF: 51 | 52 | text = device[key] if key in device else '--' 53 | if not isinstance(text,str): text = str(text) 54 | 55 | width = FIELDDEF[key][1] 56 | 57 | if len(text) >= abs(width): 58 | text = text[0:abs(width)-4] + '... ' 59 | elif width > 0: 60 | text = text.ljust(abs(width)) 61 | else: 62 | text = text.rjust(abs(width)-1) 63 | 64 | sys.stdout.write( text ) 65 | 66 | sys.stdout.write( '\n' ) 67 | 68 | def main(): 69 | omada = Omada() 70 | omada.login() 71 | 72 | print_header() 73 | 74 | for device in omada.getSiteDevices(): 75 | print_device( device ) 76 | 77 | omada.logout() 78 | 79 | if __name__ == '__main__': 80 | main() 81 | -------------------------------------------------------------------------------- /events.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import sys, time, re, collections 4 | from omada import Omada 5 | 6 | FIELDDEF = collections.OrderedDict([ 7 | ('content',('CONTENT',92)), 8 | ('time', ('TIME', 24)), 9 | ]) 10 | 11 | def format_date( date ): 12 | return time.strftime('%b %e %Y %I:%M:%S %p', time.localtime(date // 1000)) 13 | 14 | def print_header(): 15 | 16 | if sys.stdout.isatty(): 17 | sys.stdout.write( '\33[1m' ) 18 | 19 | for key in FIELDDEF: 20 | text,width = FIELDDEF[key] 21 | sys.stdout.write( text.ljust(abs(width)) ) 22 | 23 | if sys.stdout.isatty(): 24 | sys.stdout.write( '\33[0m' ) 25 | 26 | sys.stdout.write( '\n' ) 27 | 28 | def print_event( event ): 29 | 30 | for key in event: 31 | 32 | if key == 'content': 33 | match = re.search( r'\[([a-z]+):([0-9A-F\-]+)\]', event[key] ) 34 | while match: 35 | tag = str( match.group(1) ) 36 | mac = str( match.group(2) ) 37 | type = 'clientNames' if tag == 'client' else 'deviceNames' 38 | name = '[' + event[type][mac] + ']' 39 | event[key] = event[key].replace( match.group(0), name ) 40 | match = re.search( r'\[([a-z]+):([0-9A-F\-]+)\]', event[key] ) 41 | 42 | elif key == 'time': 43 | event[key] = format_date( event[key] ) 44 | 45 | for key in FIELDDEF: 46 | 47 | text = event[key].strip() if key in event else '--' 48 | if not isinstance(text,str): text = str(text) 49 | 50 | width = FIELDDEF[key][1] 51 | 52 | if len(text) >= abs(width): 53 | text = text[0:abs(width)-4] + '... ' 54 | elif width > 0: 55 | text = text.ljust(abs(width)) 56 | else: 57 | text = text.rjust(abs(width)-1) 58 | 59 | sys.stdout.write( text ) 60 | 61 | sys.stdout.write( '\n' ) 62 | 63 | def main(): 64 | omada = Omada() 65 | omada.login() 66 | 67 | print_header() 68 | 69 | count = 1 70 | for event in omada.getSiteEvents(): 71 | print_event( event ) 72 | if count == 50: 73 | break 74 | count += 1 75 | 76 | omada.logout() 77 | 78 | if __name__ == '__main__': 79 | main() 80 | -------------------------------------------------------------------------------- /led.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import sys 4 | from omada import Omada 5 | 6 | def main(): 7 | if len(sys.argv) > 1 and sys.argv[1] not in ('on','off'): 8 | print( f"usage: {sys.argv[0]} [on|off]" ) 9 | return 10 | 11 | omada = Omada() 12 | omada.login() 13 | 14 | settings = omada.getSiteSettings() 15 | 16 | if len(sys.argv) > 1: 17 | settings['led']['enable'] = (sys.argv[1] == 'on') 18 | omada.setSiteSettings(settings) 19 | settings = omada.getSiteSettings() 20 | 21 | print( 'led: on' if settings['led']['enable'] else 'led: off' ) 22 | 23 | omada.logout() 24 | 25 | if __name__ == '__main__': 26 | main() 27 | -------------------------------------------------------------------------------- /omada/__init__.py: -------------------------------------------------------------------------------- 1 | from .omada import Omada 2 | -------------------------------------------------------------------------------- /omada/omada.py: -------------------------------------------------------------------------------- 1 | 2 | import os 3 | import json 4 | import requests 5 | import urllib3 6 | import warnings 7 | import http.client 8 | import logging 9 | from configparser import ConfigParser 10 | from datetime import datetime 11 | from enum import Enum 12 | from requests.cookies import RequestsCookieJar 13 | 14 | #define Logger for class-wide usage 15 | logger = logging.getLogger(__name__) 16 | logger.setLevel(logging.INFO) 17 | ch = logging.StreamHandler() 18 | formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s') 19 | ch.setFormatter(formatter) 20 | logger.addHandler(ch) 21 | 22 | ## 23 | ## Omada API calls expect a timestamp in milliseconds. 24 | ## 25 | def timestamp(): 26 | return int( datetime.utcnow().timestamp() * 1000 ) 27 | 28 | ## 29 | ## Display errorCode and optional message returned from Omada API. 30 | ## 31 | class OmadaError(Exception): 32 | 33 | def __init__(self, json): 34 | self.errorCode = 0 35 | self.msg = None 36 | 37 | if json is None: 38 | raise TypeError('json cannot be None') 39 | 40 | if 'errorCode' in json: 41 | self.errorCode = int( json['errorCode'] ) 42 | 43 | if 'msg' in json: 44 | self.msg = '"' + json['msg'] + '"' 45 | 46 | def __str__(self): 47 | return f'errorCode={self.errorCode}, msg={self.msg}' 48 | 49 | ## 50 | ## The main Omada API class. 51 | ## 52 | class Omada: 53 | 54 | ## 55 | ## Default API 56 | ## 57 | ApiPath = '/api/v2' 58 | 59 | ## 60 | ## Group types 61 | ## 62 | class GroupType(Enum): 63 | IPGroup = 0 # "IP Group" 64 | IPPortGroup = 1 # "IP-Port Group" 65 | MACGroup = 2 # "MAC Group" 66 | 67 | ## 68 | ## Alert and event levels 69 | ## 70 | class LevelFilter(Enum): 71 | Error = 0 72 | Warning = 1 73 | Information = 2 74 | 75 | ## 76 | ## Alert and event modules 77 | ## 78 | class ModuleFilter(Enum): 79 | Operation = 0 80 | System = 1 81 | Device = 2 82 | Client = 3 83 | 84 | ## 85 | ## Initialize a new Omada API instance. 86 | ## 87 | def __init__(self, config='omada.cfg', baseurl=None, site='Default', verify=True, warnings=True, verbose=False): 88 | 89 | self.config = None 90 | self.loginResult = None 91 | self.currentPageSize = 10 92 | self.currentUser = {} 93 | self.apiPath = Omada.ApiPath 94 | self.omadacId = '' 95 | 96 | if baseurl is not None: 97 | # use the provided configuration 98 | self.baseurl = baseurl 99 | self.site = site 100 | self.verify = verify 101 | self.warnings = warnings 102 | self.verbose = verbose 103 | elif os.path.isfile( config ): 104 | # read from configuration file 105 | self.config = ConfigParser() 106 | try: 107 | self.config.read( config ) 108 | self.baseurl = self.config['omada'].get('baseurl') 109 | self.site = self.config['omada'].get('site', 'Default') 110 | self.verify = self.config['omada'].getboolean('verify', True) 111 | self.warnings = self.config['omada'].getboolean('warnings', True) 112 | self.verbose = self.config['omada'].getboolean('verbose', False) 113 | except: 114 | raise 115 | else: 116 | # could not find configuration 117 | raise FileNotFoundError(config) 118 | 119 | # set up requests session and cookies 120 | self.session = requests.Session() 121 | self.session.cookies = RequestsCookieJar() 122 | self.session.verify = self.verify 123 | 124 | # hide warnings about insecure SSL requests 125 | if self.verify == False and self.warnings == False: 126 | urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) 127 | 128 | # enable verbose output 129 | if self.verbose: 130 | # set debug level in http.client 131 | http.client.HTTPConnection.debuglevel = 1 132 | # initialize logger 133 | logging.basicConfig() 134 | logging.getLogger().setLevel(logging.DEBUG) 135 | # configure logging for requests 136 | logger.setLevel(logging.DEBUG) 137 | logger.propagate = True 138 | 139 | ## 140 | ## Build a URL for the provided path. 141 | ## 142 | def __buildUrl(self, path): 143 | return self.baseurl + self.omadacId + self.apiPath + path 144 | 145 | ## 146 | ## Look up a site key given the name. 147 | ## 148 | def __findKey(self, name=None): 149 | 150 | # Use the stored site if not provided. 151 | if name is None: name = self.site 152 | 153 | # Look for the site in the privilege list. 154 | for site in self.currentUser['privilege']['sites']: 155 | if site['name'] == name: return site['key'] 156 | 157 | raise PermissionError(f'current user does not have privilege to site "{name}"') 158 | 159 | ## 160 | ## Perform a GET request and return the result. 161 | ## 162 | def __get(self, path, params={}, data=None, json=None): 163 | 164 | if self.loginResult is None: 165 | raise ConnectionError('not logged in') 166 | 167 | if not isinstance(params, dict): 168 | raise TypeError('params must be a dictionary') 169 | 170 | response = self.session.get( self.__buildUrl(path), params=params, data=data, json=json, headers=self.session.headers ) 171 | response.raise_for_status() 172 | 173 | json = response.json() 174 | if json['errorCode'] == 0: 175 | return json['result'] if 'result' in json else None 176 | 177 | raise OmadaError(json) 178 | 179 | ## 180 | ## Perform a POST request and return the result. 181 | ## 182 | def __post(self, path, params={}, data=None, files=None, json=None): 183 | 184 | if self.loginResult is None: 185 | raise ConnectionError('not logged in') 186 | 187 | if not isinstance(params, dict): 188 | raise TypeError('params must be a dictionary') 189 | 190 | params['_'] = timestamp() 191 | params['token'] = self.loginResult['token'] 192 | response = self.session.post( self.__buildUrl(path), params=params, data=data, files=files, json=json ) 193 | response.raise_for_status() 194 | 195 | json = response.json() 196 | if json['errorCode'] == 0: 197 | return json['result'] if 'result' in json else None 198 | 199 | raise OmadaError(json) 200 | 201 | ## 202 | ## Perform a PATCH request and return the result. 203 | ## 204 | def __patch(self, path, params={}, data=None, json=None): 205 | 206 | if self.loginResult is None: 207 | raise ConnectionError('not logged in') 208 | 209 | if not isinstance(params, dict): 210 | raise TypeError('params must be a dictionary') 211 | 212 | params['_'] = timestamp() 213 | params['token'] = self.loginResult['token'] 214 | 215 | response = self.session.patch( self.__buildUrl(path), params=params, data=data, json=json ) 216 | response.raise_for_status() 217 | 218 | json = response.json() 219 | if json['errorCode'] == 0: 220 | return json['result'] if 'result' in json else None 221 | 222 | raise OmadaError(json) 223 | 224 | ## 225 | ## Return True if a result contains data. 226 | ## 227 | def __hasData(self, result): 228 | return (result is not None) and ('data' in result) and (len(result['data']) > 0) 229 | 230 | ## 231 | ## Perform a paged GET request and return the result. 232 | ## 233 | def __getPaged(self, path, params={}, data=None, json=None): 234 | 235 | if self.loginResult is None: 236 | raise ConnectionError('not logged in') 237 | 238 | if not isinstance(params, dict): 239 | raise TypeError('params must be a dictionary') 240 | 241 | params['_'] = timestamp() 242 | params['token'] = self.loginResult['token'] 243 | 244 | if 'currentPage' not in params: 245 | params['currentPage'] = 1 246 | 247 | if 'currentPageSize' not in params: 248 | params['currentPageSize'] = self.currentPageSize 249 | 250 | response = self.session.get( self.__buildUrl(path), params=params, data=data, json=json ) 251 | response.raise_for_status() 252 | 253 | json = response.json() 254 | if json['errorCode'] == 0: 255 | json['result']['path'] = path 256 | json['result']['params'] = params 257 | return json['result'] 258 | 259 | raise OmadaError(json) 260 | 261 | ## 262 | ## Returns the next page of data if more is available. 263 | ## 264 | def __nextPage(self, result): 265 | 266 | if 'path' in result: 267 | path = result['path'] 268 | del result['path'] 269 | else: 270 | return None 271 | 272 | if 'params' in result: 273 | params = result['params'] 274 | del result['params'] 275 | else: 276 | return None 277 | 278 | totalRows = int( result['totalRows'] ) 279 | currentPage = int( result['currentPage'] ) 280 | currentSize = int( result['currentSize'] ) 281 | dataLength = len( result['data'] ) 282 | 283 | if dataLength + (currentPage-1)*currentSize >= totalRows: 284 | return None 285 | 286 | params['currentPage'] = currentPage + 1 287 | return self.__getPaged( path, params ) 288 | 289 | ## 290 | ## Perform a GET request and yield the results. 291 | ## 292 | def __geterator(self, path, params={}, data=None, json=None): 293 | result = self.__getPaged( path, params, data, json ) 294 | while self.__hasData( result ): 295 | for item in result['data']: yield item 296 | result = self.__nextPage( result ) 297 | 298 | ## 299 | ## Issue a warning if warnings are enabled. 300 | ## 301 | def __warn(self, message, category=None, stacklevel=1, source=None): 302 | if self.warnings: warnings.warn( message, category, stacklevel, source ) 303 | 304 | ## 305 | ## Get OmadacId to prefix request. (Required for version 5.) 306 | ## 307 | def getApiInfo(self): 308 | 309 | # This uses a different path, so perform request manually. 310 | response = self.session.get( self.baseurl + '/api/info' ) 311 | response.raise_for_status() 312 | 313 | json = response.json() 314 | if json['errorCode'] == 0: 315 | return json['result'] if 'result' in json else None 316 | 317 | raise OmadaError(json) 318 | 319 | ## 320 | ## Log in with the provided credentials and return the result. 321 | ## 322 | def login(self, username=None, password=None): 323 | 324 | # Only try to log in if we're not already logged in. 325 | if self.loginResult is None: 326 | 327 | # Fetch the API info from the controller. (Does not require login.) 328 | apiInfo = self.getApiInfo() 329 | 330 | # Store the omadacId value. (Required by version 5.) 331 | if 'omadacId' in apiInfo: 332 | self.omadacId = '/' + apiInfo['omadacId'] 333 | 334 | # Get the username and password if not specified. 335 | if username is None and password is None: 336 | if self.config is None: 337 | raise TypeError('username and password cannot be None') 338 | try: 339 | username = self.config['omada'].get('username') 340 | password = self.config['omada'].get('password') 341 | except: 342 | raise 343 | 344 | # Perform the login request manually. 345 | response = self.session.post( self.__buildUrl('/login'), json={'username':username,'password':password} ) 346 | response.raise_for_status() 347 | 348 | # Get the login response. 349 | json = response.json() 350 | if json['errorCode'] != 0: 351 | raise OmadaError(json) 352 | 353 | # Store the login result. 354 | self.loginResult = json['result'] 355 | 356 | # Store CSRF token header. 357 | self.session.headers.update({ 358 | "Csrf-Token": self.loginResult['token'] 359 | }) 360 | 361 | # Get the current user info. 362 | self.currentUser = self.getCurrentUser() 363 | 364 | return self.loginResult 365 | 366 | ## 367 | ## Log out of the current session. Return value is always None. 368 | ## 369 | def logout(self): 370 | 371 | result = None 372 | 373 | # Only try to log out if we're already logged in. 374 | if self.loginResult is not None: 375 | # Send the logout request. 376 | result = self.__post( '/logout' ) 377 | # Clear the stored result. 378 | self.loginResult = None 379 | 380 | return result 381 | 382 | ## 383 | ## Returns the current login status. 384 | ## 385 | def getLoginStatus(self): 386 | return self.__get( '/loginStatus' ) 387 | 388 | ## 389 | ## Returns the current user information. 390 | ## 391 | def getCurrentUser(self): 392 | return self.__get( '/users/current' ) 393 | 394 | ## 395 | ## Returns the list of groups for the given site. 396 | ## 397 | def getSiteGroups(self, site=None, type=None): 398 | return self.__get( f'/sites/{self.__findKey(site)}/setting/profiles/groups' + (f'/{type}' if type else '') ) 399 | 400 | ## 401 | ## Returns the list of portal candidates for the given site. 402 | ## 403 | ## This is the "SSID & Network" list on Settings > Authentication > Portal > Basic Info. 404 | ## 405 | def getPortalCandidates(self, site=None): 406 | return self.__get( f'/sites/{self.__findKey(site)}/setting/portal/candidates' ) 407 | 408 | ## 409 | ## Returns the list of RADIUS profiles for the given site. 410 | ## 411 | def getRadiusProfiles(self, site=None): 412 | return self.__get( f'/sites/{self.__findKey(site)}/setting/radiusProfiles' ) 413 | 414 | ## 415 | ## Returns the list of scenarios. 416 | ## 417 | def getScenarios(self): 418 | return self.__get( '/scenarios' ) 419 | 420 | ## 421 | ## Returns the list of all sites. 422 | ## 423 | def getSites(self): 424 | return self.__geterator( f'/sites' ) 425 | 426 | ## 427 | ## Returns the list of devices for given site. 428 | ## 429 | def getSiteDevices(self, site=None): 430 | return self.__get( f'/sites/{self.__findKey(site)}/devices' ) 431 | 432 | ## 433 | ## Returns the list of active clients for given site. 434 | ## 435 | def getSiteClients(self, site=None): 436 | return self.__geterator( f'/sites/{self.__findKey(site)}/clients', params={'filters.active':'true'} ) 437 | 438 | ## 439 | ## Returns the list of alerts for given site. 440 | ## 441 | def getSiteAlerts(self, site=None, archived=False, level=None, module=None, searchKey=None): 442 | 443 | params = {'filters.archived': 'true' if archived else 'false'} 444 | 445 | if level is not None: 446 | if level not in ValidLevelFilters: 447 | raise TypeError('invalid level filter') 448 | params['filters.level'] = level 449 | 450 | if module is not None: 451 | if level not in ValidModuleFilters: 452 | raise TypeError('invalid module filter') 453 | params['filters.module'] = module 454 | 455 | if searchKey is not None: 456 | params['searchKey'] = searchKey 457 | 458 | return self.__geterator( f'/sites/{self.__findKey(site)}/alerts', params=params ) 459 | 460 | ## 461 | ## Returns the list of events for given site. 462 | ## 463 | def getSiteEvents(self, site=None, level=None, module=None, searchKey=None): 464 | 465 | params = {} 466 | 467 | if level is not None: 468 | if level not in ValidLevelFilters: 469 | raise TypeError('invalid level filter') 470 | params['filters.level'] = level 471 | 472 | if module is not None: 473 | if module not in ValidModuleFilters: 474 | raise TypeError('invalid module filter') 475 | params['filters.module'] = module 476 | 477 | if searchKey is not None: 478 | params['searchKey'] = searchKey 479 | 480 | return self.__geterator( f'/sites/{self.__findKey(site)}/events', params=params ) 481 | 482 | ## 483 | ## Returns the notification settings for given site. 484 | ## 485 | def getSiteNotifications(self, site=None): 486 | return self.__get( f'/sites/{self.__findKey(site)}/notification' ) 487 | 488 | ## 489 | ## Returns the list of settings for the given site. 490 | ## 491 | def getSiteSettings(self, site=None): 492 | return self.__get( f'/sites/{self.__findKey(site)}/setting' ) 493 | 494 | ## 495 | ## Push back the settings for the site. 496 | ## 497 | def setSiteSettings(self, settings, site=None): 498 | return self.__patch( f'/sites/{self.__findKey(site)}/setting', json=settings ) 499 | 500 | ## 501 | ## Returns the list of settings for the controller. 502 | ## 503 | def getControllerSettings(self): 504 | return self.__get( f'/controller/setting' ) 505 | 506 | ## 507 | ## Push back the settings for the controller. 508 | ## 509 | def setControllerSettings(self, settings): 510 | return self.__patch( f'/controller/setting', json=settings ) 511 | 512 | 513 | def setControllerJksCertificate(self, jks_path, password): 514 | return self.__setControllerCertificate(cert_type="JKS", 515 | cert_path=jks_path, 516 | key_password=password) 517 | 518 | 519 | def setControllerPfxCertificate(self, pfx_path, password): 520 | return self.__setControllerCertificate(cert_type="PFX", 521 | cert_path=pfx_path, 522 | key_password=password) 523 | 524 | 525 | def setControllerPemCertificate(self, cert_path, key_path): 526 | return self.__setControllerCertificate(cert_type="PEM", 527 | cert_path=cert_path, 528 | key_path=key_path) 529 | 530 | 531 | def __uploadFile(self, src_path, dest_path, data, content_type="application/octet-stream"): 532 | src_name = os.path.basename(src_path) 533 | with open(src_path, 'rb') as src_file: 534 | result = self.__post(f'/files/{dest_path}', 535 | files={'file': (src_name, 536 | src_file, 537 | content_type), 538 | 'data': (None, json.dumps(data))}) 539 | 540 | ## 541 | ## Set new certificate for the controller 542 | ## 543 | def __setControllerCertificate(self, cert_type, cert_path, key_path=None, key_password=None): 544 | r_cert = self.__uploadFile(cert_path, 545 | 'controller/certificate', 546 | {"cerName": os.path.basename(cert_path)}) 547 | if key_path: 548 | r_key = self.__uploadFile(key_path, 549 | 'controller/key', 550 | {"keyName": os.path.basename(key_path)}) 551 | 552 | # re-upload same settings to force cert file validation 553 | settings = self.getControllerSettings() 554 | cert_settings = settings['certificate'] 555 | cert_settings['cerType'] = cert_type 556 | cert_settings['enable'] = True 557 | if key_password: 558 | cert_settings['keyPassword'] = key_password 559 | else: 560 | cert_settings.pop('keyPassword', None) 561 | if not key_path: 562 | # Delete PEM key file details if they exist 563 | cert_settings.pop('keyId', None) 564 | cert_settings.pop('keyName', None) 565 | return self.setControllerSettings(settings) 566 | 567 | 568 | def reboot(self): 569 | return self.__post('/cmd/reboot') 570 | 571 | 572 | ## 573 | ## Returns the list of timerange profiles for the given site. 574 | ## 575 | def getTimeRanges(self, site=None): 576 | return self.__get( f'/sites/{self.__findKey(site)}/setting/profiles/timeranges' ) 577 | 578 | ## 579 | ## Returns the list of wireless network groups. 580 | ## 581 | ## This is the "WLAN Group" list on Settings > Wireless Networks. 582 | ## 583 | def getWirelessGroups(self, site=None): 584 | return self.__get( f'/sites/{self.__findKey(site)}/setting/wlans' ) 585 | 586 | ## 587 | ## Returns the list of wireless networks for the given group. 588 | ## 589 | ## This is the main SSID list on Settings > Wireless Networks. 590 | ## 591 | def getWirelessNetworks(self, group, site=None): 592 | return self.__get( f'/sites/{self.__findKey(site)}/setting/wlans/{group}/ssids' ) 593 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | requests>=2.28.0 2 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | 2 | from setuptools import setup, find_packages 3 | 4 | with open( 'README.md', 'r', encoding='utf-8' ) as fh: 5 | README = fh.read() 6 | 7 | setuptools.setup( 8 | name='omada-api', 9 | version='5.7.4', 10 | description='A simple Python wrapper for the TP-Link Omada Software Controller API', 11 | long_description=README, 12 | long_description_content_type='text/markdown', 13 | url='https://ghaberek.github.io/omada-api', 14 | author='Gregory Haberek', 15 | author_email='ghaberek@gmail.com', 16 | license='MIT', 17 | classifiers=[ 18 | 'Development Status :: 4 - Beta', 19 | 'Intended Audience :: Developers', 20 | 'License :: OSI Approved :: MIT License', 21 | 'Operating System :: OS Independent', 22 | 'Programming Language :: Python :: 3.7', 23 | 'Topic :: Software Development :: Libraries', 24 | ], 25 | keywords='tplink omada wrapper', 26 | project_urls={ 27 | 'Source': 'https://github.com/ghaberek/omada-api', 28 | 'Issues': 'https://github.com/ghaberek/omada-api/issues', 29 | 'Wiki': 'https://github.com/ghaberek/omada-api/wiki', 30 | }, 31 | packages=find_packages(), 32 | install_requires=[ 33 | 'requests>=2.28.0' 34 | ], 35 | python_requires='>=3.7', 36 | ) 37 | -------------------------------------------------------------------------------- /upload_cert.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import sys 4 | from omada import Omada 5 | 6 | def main(): 7 | if len(sys.argv) > 4 or len(sys.argv) < 4: 8 | print( f"usage:" ) 9 | print( f" {sys.argv[0]} JKS jks_path storepass" ) 10 | print( f" {sys.argv[0]} PFX pfx_path storepass" ) 11 | print( f" {sys.argv[0]} PEM cert_path key_path" ) 12 | return 13 | 14 | omada = Omada() 15 | omada.login() 16 | if sys.argv[1] == "JKS": 17 | omada.setControllerJksCertificate(jks_path=sys.argv[2], password=sys.argv[3]) 18 | elif sys.argv[1] == "PFX": 19 | omada.setControllerPfxCertificate(pfx_path=sys.argv[2], password=sys.argv[3]) 20 | elif sys.argv[1] == "PEM": 21 | omada.setControllerPemCertificate(cert_path=sys.argv[2], key_path=sys.argv[3]) 22 | else: 23 | raise Exception("Unsupported cert type: " + sys.argv[1]) 24 | 25 | omada.reboot() 26 | 27 | if __name__ == '__main__': 28 | main() 29 | --------------------------------------------------------------------------------