├── .gitattributes ├── .gitignore ├── .travis.yml ├── CHANGELOG.md ├── LICENSE ├── MANIFEST.in ├── README.md ├── pyunifi ├── __init__.py └── controller.py ├── requirements.txt ├── setup.py ├── test_pyunifi.py ├── tox.ini ├── unifi-copy-radius ├── unifi-create-voucher ├── unifi-disconnect-client ├── unifi-log-roaming ├── unifi-low-snr-reconnect ├── unifi-ls-clients ├── unifi-ls-radius ├── unifi-save-radius └── unifi-save-statistics /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | *.{cmd,[cC][mM][dD]} text eol=crlf 3 | *.{bat,[bB][aA][tT]} text eol=crlf -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | MANIFEST 2 | build 3 | dist 4 | Scripts/ 5 | .pypirc 6 | *.pyc 7 | pyunifi.egg-info 8 | pyvenv.cfg 9 | pip-selfcheck.json 10 | Lib/ 11 | .tox/ 12 | .vscode/ 13 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - '3.7' 4 | install: 5 | - pip install -r requirements.txt 6 | - pip install tox 7 | script: 8 | - pytest 9 | - tox 10 | deploy: 11 | provider: pypi 12 | user: "finish.06" 13 | password: 14 | secure: k8+4bG/k5z65J0D1FKpjC1TV70B0hoVPk+H0157sUGQ24B66rswNM+3piRZ8/IELDxNF35O5wvOaLkyhhQTg+og5J1Nl2AsTW5TkEkumF2Dm3H35iQgvuGdLS6TtIBN6g8s+ZAjueeiHJ2K3Zal/ks3IgSHWHWdgPzUayGuCqNv5dhyQhez74OL8UmE9uql8oNV+e5JiUxUTVhTbC08IWMX2wKexg3yjEuxEjulCoHrZWBKTwGW2/Dtb8zWIYPAfE1oV+s9f5gXffnCL5Uz2DG28gDWnCKNJeIGNQFGJ+cJMFc7uKt9z2UrGlbitvyss6y0AplMTpQ0jD7Lz3haZ0Sq7PXkB5KhwuqHpgD7Q56/LzhdLgZrr/8hoDtic2rbDHvz9++HaWNa1041PjQD8zgsA3f0XS09xJySqdI3W+Y6dfWiYHOoYhOWhmI3IVHDvGmZOmg3kDxp8vb+OYf0q32lhtruOAsByxkDHbIiUeuHFtQvpzxzGVcnSC4rPletkvGmsBXAztEIDbTbVRmUWV58bFuZ4Rl4li/zaZuEO98YXogafyCGoVy6XL7XS0GtSYEPs2rfna0TblpJ3h+1jdn9yhrK2IEZLoceFc8HwzS6gFFwiv63Yg+XZZqGexycnrm3n4vOuNyjRzbzEdw2o7r8VXxtWtTx4MdyLx1DJMPw= 15 | on: 16 | branch: master 17 | tags: true 18 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 5 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 6 | 7 | ## [2.21] - 2021-04-18 8 | ### Added 9 | - Support for UDM & UDMP 10 | ### Fixed 11 | - Support for CSRF 12 | 13 | ## [2.20.1] - 2020-03-30 14 | ### Fixed 15 | - Lint failures in controller.py 16 | 17 | ## [2.20.0] - 2020-03-30 18 | ### Added 19 | - CHANGELOG 20 | - Added support for UnifiOS: `version = 'unifiOS'` 21 | 22 | ## [2.19.0] - 2019-10-28 23 | ### Added 24 | - CHANGELOG 25 | - `get_device_stat()` method to get current state & configuration of device 26 | - `get_switch_port_overrides()` method to retrieve the overrides applied to the given Switch 27 | - `switch_port_power_off()` to turn off PoE for a specific Switch Port 28 | - `switch_port_power_on()` to turn on PoE for a specific Switch Port 29 | - Namespaced logger support 30 | ### Fixed 31 | - Pinned version of `requests` to better protect against future dependency issues -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (C) 2012 Jakob Borg 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 7 | of the Software, and to permit persons to whom the Software is furnished to do 8 | so, subject to the following conditions: 9 | 10 | - The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md 2 | include LICENSE 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![PyPI version](https://badge.fury.io/py/pyunifi.svg)](https://badge.fury.io/py/pyunifi) 2 | [![Build Status](https://travis-ci.org/finish06/pyunifi.svg?branch=master)](https://travis-ci.org/finish06/pyunifi) 3 | 4 | 5 | PyUnifi 6 | ========= 7 | 8 | --- 9 | A rewrite of https://github.com/unifi-hackers/unifi-lab in cleaner Python. 10 | Forked from https://github.com/calmh/unifi-api due to unmaintained status and rewritten to use the Requests module. 11 | 12 | Development & Pull Request 13 | -------------------------- 14 | Perform all pull requests against the development branch. Pull requests against the master branch will not be merged, but closed. 15 | 16 | Install 17 | ------- 18 | 19 | sudo pip install -U pyunifi 20 | 21 | API Example 22 | ----------- 23 | 24 | ```python 25 | from pyunifi.controller import Controller 26 | c = Controller('192.168.1.99', 'admin', 'p4ssw0rd') 27 | for ap in c.get_aps(): 28 | print('AP named %s with MAC %s' % (ap.get('name'), ap['mac'])) 29 | ``` 30 | 31 | See also the scripts `unifi-ls-clients` and `unifi-low-rssi-reconnect` for more 32 | examples of how to use the API. 33 | 34 | API 35 | --- 36 | 37 | ### `class Controller` 38 | 39 | Interact with a UniFi controller. 40 | 41 | Uses the JSON interface on port 8443 (HTTPS) to communicate with a UniFi 42 | controller. Operations will raise unifi.controller.APIError on obvious 43 | problems (such as login failure), but many errors (such as disconnecting a 44 | nonexistant client) will go unreported. 45 | 46 | ### `__init__(self, host, username, password)` 47 | 48 | Create a Controller object. 49 | 50 | - `host` -- the address of the controller host; IP or name 51 | - `username` -- the username to log in with 52 | - `password` -- the password to log in with 53 | - `port` -- the port of the controller host 54 | - `version` -- the base version of the controller API [v4|v5|unifiOS|UDMP-unifiOS] 55 | - `site_id` -- the site ID to access 56 | - `ssl_verify` -- Verify the controllers SSL certificate, default=True, can also be False or "path/to/custom_cert.pem" 57 | 58 | ### `block_client(self, mac)` 59 | 60 | Add a client to the block list. 61 | 62 | - `mac` -- the MAC address of the client to block. 63 | 64 | ### `disconnect_client(self, mac)` 65 | 66 | Disconnects a client, forcing them to reassociate. Useful when the 67 | connection is of bad quality to force a rescan. 68 | 69 | - `mac` -- the MAC address of the client to disconnect. 70 | 71 | ### `get_alerts(self)` 72 | 73 | Return a list of Alerts. 74 | 75 | ### `get_alerts_unarchived(self)` 76 | 77 | Return a list of unarchived Alerts. 78 | 79 | ### `get_events(self)` 80 | 81 | Return a list of Events. 82 | 83 | ### `get_aps(self)` 84 | 85 | Return a list of all AP:s, with significant information about each. 86 | 87 | ### `get_clients(self)` 88 | 89 | Return a list of all active clients, with significant information about each. 90 | 91 | ### `get_statistics_last_24h(self)` 92 | 93 | Return statistical data of the last 24h 94 | 95 | ### `get_statistics_24h(self, endtime)` 96 | 97 | Return statistical data last 24h from endtime 98 | 99 | - `endtime` -- the last time of statistics. 100 | 101 | ### `get_users(self)` 102 | 103 | Return a list of all known clients, with significant information about each. 104 | 105 | ### `get_user_groups(self)` 106 | 107 | Return a list of user groups with its rate limiting settings. 108 | 109 | ### `update_user_group(self, group_id, down_kbps=-1, up_kbps=-1)` 110 | 111 | Update user group bandwidth settings. 112 | 113 | - `group_id` -- Group ID to modify. 114 | - `down_kbps` -- New bandwidth in KBPS for download. 115 | - `up_kbps` -- New bandwidth in KBPS for upload. 116 | 117 | ### `get_healthinfo(self)` 118 | 119 | Return high level health information on status of the setup 120 | 121 | ### `get_wlan_conf(self)` 122 | 123 | Return a list of configured WLANs with their configuration parameters. 124 | 125 | ### `restart_ap(self, mac)` 126 | 127 | Restart an access point (by MAC). 128 | 129 | - `mac` -- the MAC address of the AP to restart. 130 | 131 | ### `restart_ap_name(self, name)` 132 | 133 | Restart an access point (by name). 134 | 135 | - `name` -- the name address of the AP to restart. 136 | 137 | ### `unblock_client(self, mac)` 138 | 139 | Remove a client from the block list. 140 | 141 | - `mac` -- the MAC address of the client to unblock. 142 | 143 | ### `archive_all_alerts(self)` 144 | 145 | Archive all alerts of site. 146 | 147 | ### `create_backup(self)` 148 | 149 | Tells the controller to create a backup archive that can be downloaded with download_backup() and 150 | then be used to restore a controller on another machine. 151 | 152 | Remember that this puts significant load on a controller for some time (depending on the amount of users and managed APs). 153 | 154 | ### `get_backup(self, targetfile)` 155 | 156 | Tells the controller to create a backup archive and downloads it to a file. It should have a .unf extension for later restore. 157 | 158 | - `targetfile` -- the target file name, you can also use a full path. Default creates unifi-backup.unf in the current directoy. 159 | 160 | ### `authorize_guest(self, guest_mac, minutes, up_bandwidth=None, down_bandwidth=None, byte_quota=None, ap_mac=None)` 161 | 162 | Authorize a guest based on his MAC address. 163 | 164 | - `guest_mac` -- the guest MAC address : aa:bb:cc:dd:ee:ff 165 | - `minutes` -- duration of the authorization in minutes 166 | - `up_bandwith` -- up speed allowed in kbps (optional) 167 | - `down_bandwith` -- down speed allowed in kbps (optional) 168 | - `byte_quota` -- quantity of bytes allowed in MB (optional) 169 | - `ap_mac` -- access point MAC address (UniFi >= 3.x) (optional) 170 | 171 | ### `unauthorize_guest(self, guest_mac)` 172 | Unauthorize a guest based on his MAC address. 173 | 174 | - `guest_mac` -- the guest MAC address : aa:bb:cc:dd:ee:ff 175 | 176 | ### `set_client_alias(self, mac, alias)` 177 | Set client alias. Use "" to reset to the default. 178 | - mac: The target MAC: aa:bb:cc:dd:ee:ff 179 | - alias: The alias to set 180 | 181 | ### `create_voucher(self, number, quota, expire, up_bandwidth=None, down_bandwidth=None, byte_quota=None, note=None)` 182 | Create voucher for guests. Return list of new vouchers. 183 | 184 | - `number` -- number of vouchers 185 | - `quota` -- maximal number of using; 0 = unlimited 186 | - `expire` -- expiration of vouchers in minutes 187 | - `up_bandwidth` -- up speed allowed in kbps (optional) 188 | - `down_bandwidth` -- down speed allowed in kbps (optional) 189 | - `byte_quota` -- quantity of bytes allowed in MB (optional) 190 | - `note` -- description of vouchers (optional) 191 | 192 | ### `list_vouchers(self, **filter)` 193 | Get list of vouchers. 194 | 195 | - `filter` -- Voucher filter (create_time, code, quota, used, note, status_expires, status, ...) 196 | 197 | ``` 198 | c.list_vouchers(code='12345-67890') 199 | ``` 200 | 201 | ### `delete_voucher(self, id)` 202 | Delete / revoke voucher. 203 | 204 | - `id` -- voucher id 205 | 206 | ### `get_device_stat(self, target_mac)` 207 | Gets the current state & configuration of the given device based on its MAC Address. 208 | 209 | - `target_mac` -- MAC address of the device 210 | 211 | ### `get_radius_users(self)` 212 | Returns a list of all RADIUS users, name, password, 24 digit user id, and 24 digit site id. 213 | 214 | ### `add_radius_user(self, name, password)` 215 | Add a new RADIUS user with this username and password. 216 | 217 | - `name` -- the new user's username 218 | - `password` -- the new user's password 219 | 220 | ### `update_radius_user(self, name, password, id)` 221 | Update a RADIUS user to this new username and password. 222 | Requires the user's 24 digit user id, which can be gotten from `get_radius_users(self)`. 223 | 224 | - `name` -- the user's new username 225 | - `password` -- the user's new password 226 | - `id` -- the user's 24 digit user id. 227 | 228 | ### `delete_radius_user(self, id)` 229 | Delete a RADIUS user. 230 | Requires the user's 24 digit user id, which can be gotten from `get_radius_users(self)`. 231 | 232 | - `id` -- the user's 24 digit user id. 233 | 234 | ### `get_switch_port_overrides(self, target_mac)` 235 | Gets a list of port overrides, in dictionary format, for the given target MAC address. The dictionary contains the port_idx, portconf_id, poe_mode, & name. 236 | 237 | - `target_mac` -- MAC address of the device 238 | 239 | ### `switch_port_power_off(self, target_mac, port_idx)` 240 | Powers Off the given port on the Switch identified by the given MAC Address. 241 | 242 | - `target_mac` -- MAC address of the device 243 | - `port_idx` -- Port ID to power off 244 | 245 | ### `switch_port_power_on(self, target_mac, port_idx)` 246 | Powers On the given port on the Switch identified by the given MAC Address. 247 | 248 | - `target_mac` -- MAC address of the device 249 | - `port_idx` -- Port ID to power on 250 | 251 | Utilities 252 | --------- 253 | 254 | The following small utilities are bundled with the API: 255 | 256 | ### unifi-ls-clients 257 | 258 | Lists the currently active clients on the networks. Can take the following parameters: 259 | |Parameters |Description |Default | 260 | | ------------- |---------------------------------------| -------| 261 | | -c | controller address |unifi | 262 | | -u | controller username |admin | 263 | | -p | controller password | | 264 | | -b | controller port |8443 | 265 | | -v | controller base version |v5 | 266 | | -s | site ID, UniFi >=3.x only |default | 267 | | -V | ignore SSL certificates | | 268 | | -C | verify with ssl certificate pem file | | 269 | 270 | ``` 271 | jb@unifi:~ % unifi-ls-clients -c localhost -u admin -p p4ssw0rd -v v3 -s default 272 | NAME MAC AP CHAN RSSI RX TX 273 | client-kitchen 00:24:36:9a:0d:ab Study 100 51 300 216 274 | jborg-mbp 28:cf:da:d6:46:20 Study 100 45 300 300 275 | jb-iphone 48:60:bc:44:36:a4 Living Room 1 45 65 65 276 | jb-ipad 1c:ab:a7:af:05:65 Living Room 1 22 52 65 277 | ``` 278 | 279 | ### unifi-low-snr-reconnect 280 | 281 | Periodically checks all clients for low SNR values, and disconnects those who 282 | fall below the limit. The point being that these clients will then try to 283 | reassociate, hopefully finding a closer AP. Take the same parameters as above, 284 | plus settings for intervals and SNR threshold. Use `unifi-low-snr-reconnect -h` 285 | for an option summary. 286 | 287 | A good source of understanding for RSSI/SNR values is [this 288 | article](http://www.wireless-nets.com/resources/tutorials/define_SNR_values.html). 289 | According to that, an SNR of 15 dB seems like a good cutoff, and that's also 290 | the default value in the script. You can set a higher value for testing: 291 | 292 | ``` 293 | jb@unifi:~ % unifi-low-snr-reconnect -c localhost -u admin -p p4ssw0rd -v v3 -s default --minsnr 30 294 | 2012-11-15 11:23:01 INFO unifi-low-snr-reconnect: Disconnecting jb-ipad/1c:ab:a7:af:05:65@Study (SNR 22 dB < 30 dB) 295 | 2012-11-15 11:23:01 INFO unifi-low-snr-reconnect: Disconnecting Annas-Iphone/74:e2:f5:97:da:7e@Living Room (SNR 29 dB < 30 dB) 296 | ``` 297 | 298 | For production use, launching the script into the background is recommended... 299 | 300 | ### unifi-save-statistics 301 | 302 | Get a csv file with statistics 303 | 304 | ``` 305 | unifi-save-statistics -c localhost -u admin -p p4ssw0rd -v v3 -s default -f filename.csv 306 | ``` 307 | 308 | 309 | License 310 | ------- 311 | 312 | MIT 313 | -------------------------------------------------------------------------------- /pyunifi/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Python __init__ to interact with UniFi Controller 3 | """ 4 | import urllib3 5 | 6 | 7 | def http_debug_log_stderr(): 8 | """Dump requests urllib3 debug messages to stderr""" 9 | urllib3.add_stderr_logger() 10 | -------------------------------------------------------------------------------- /pyunifi/controller.py: -------------------------------------------------------------------------------- 1 | """ 2 | Python package to interact with UniFi Controller 3 | """ 4 | import shutil 5 | import time 6 | import warnings 7 | import json 8 | import logging 9 | 10 | import requests 11 | from urllib3.exceptions import InsecureRequestWarning 12 | 13 | 14 | """For testing purposes: 15 | logging.basicConfig(filename='pyunifi.log', level=logging.WARN, 16 | format='%(asctime)s %(message)s') 17 | """ # pylint: disable=W0105 18 | CONS_LOG = logging.getLogger(__name__) 19 | 20 | 21 | class APIError(Exception): 22 | """API Error exceptions""" 23 | 24 | 25 | def retry_login(func, *args, **kwargs): # pylint: disable=w0613 26 | """To reattempt login if requests exception(s) occur at time of call""" 27 | 28 | def wrapper(*args, **kwargs): 29 | try: 30 | try: 31 | return func(*args, **kwargs) 32 | except (requests.exceptions.RequestException, APIError) as err: 33 | CONS_LOG.warning("Failed to perform %s due to %s", func, err) 34 | controller = args[0] 35 | controller._login() # pylint: disable=w0212 36 | return func(*args, **kwargs) 37 | except Exception as err: 38 | raise APIError(err) 39 | 40 | return wrapper 41 | 42 | 43 | class Controller: # pylint: disable=R0902,R0904 44 | 45 | """Interact with a UniFi controller. 46 | 47 | Uses the JSON interface on port 8443 (HTTPS) to communicate with a UniFi 48 | controller. Operations will raise unifi.controller.APIError on obvious 49 | problems (such as login failure), but many errors (such as disconnecting a 50 | nonexistant client) will go unreported. 51 | 52 | >>> from unifi.controller import Controller 53 | >>> c = Controller('192.168.1.99', 'admin', 'p4ssw0rd') 54 | >>> for ap in c.get_aps(): 55 | ... print 'AP named %s with MAC %s' % (ap.get('name'), ap['mac']) 56 | ... 57 | AP named Study with MAC dc:9f:db:1a:59:07 58 | AP named Living Room with MAC dc:9f:db:1a:59:08 59 | AP named Garage with MAC dc:9f:db:1a:59:0b 60 | 61 | """ 62 | 63 | def __init__( # pylint: disable=r0913 64 | self, 65 | host, 66 | username, 67 | password, 68 | port=8443, 69 | version="v5", 70 | site_id="default", 71 | ssl_verify=True, 72 | ): 73 | """ 74 | :param host: the address of the controller host; IP or name 75 | :param username: the username to log in with 76 | :param password: the password to log in with 77 | :param port: the port of the controller host 78 | :param version: the base version of the controller API [v4|v5] 79 | :param site_id: the site ID to connect to 80 | :param ssl_verify: Verify the controllers SSL certificate, 81 | can also be "path/to/custom_cert.pem" 82 | """ 83 | 84 | self.log = logging.getLogger(__name__ + ".Controller") 85 | 86 | self.host = host 87 | self.headers = None 88 | self.version = version 89 | self.port = port 90 | self.username = username 91 | self.password = password 92 | self.site_id = site_id 93 | self.ssl_verify = ssl_verify 94 | 95 | if version == "unifiOS": 96 | self.url = "https://" + host + "/proxy/network/" 97 | self.auth_url = self.url + "api/login" 98 | elif version == "UDMP-unifiOS": 99 | self.auth_url = "https://" + host + "/api/auth/login" 100 | self.url = "https://" + host + "/proxy/network/" 101 | elif version[:1] == "v": 102 | if float(version[1:]) < 4: 103 | raise APIError("%s controllers no longer supported" % version) 104 | self.url = "https://" + host + ":" + str(port) + "/" 105 | self.auth_url = self.url + "api/login" 106 | else: 107 | raise APIError("%s controllers no longer supported" % version) 108 | 109 | if ssl_verify is False: 110 | warnings.simplefilter("default", category=InsecureRequestWarning) 111 | 112 | self.log.debug("Controller for %s", self.url) 113 | self._login() 114 | 115 | @staticmethod 116 | def _jsondec(data): 117 | obj = json.loads(data) 118 | if "meta" in obj: 119 | if obj["meta"]["rc"] != "ok": 120 | raise APIError(obj["meta"]["msg"]) 121 | if "data" in obj: 122 | result = obj["data"] 123 | else: 124 | result = obj 125 | 126 | return result 127 | 128 | def _api_url(self): 129 | return self.url + "api/s/" + self.site_id + "/" 130 | 131 | @retry_login 132 | def _read(self, url, params=None): 133 | # Try block to handle the unifi server being offline. 134 | response = self.session.get(url, params=params, headers=self.headers) 135 | 136 | if response.headers.get("X-CSRF-Token"): 137 | self.headers = {"X-CSRF-Token": response.headers["X-CSRF-Token"]} 138 | 139 | return self._jsondec(response.text) 140 | 141 | def _api_read(self, url, params=None): 142 | return self._read(self._api_url() + url, params) 143 | 144 | @retry_login 145 | def _write(self, url, params=None): 146 | response = self.session.post(url, json=params, headers=self.headers) 147 | 148 | if response.headers.get("X-CSRF-Token"): 149 | self.headers = {"X-CSRF-Token": response.headers["X-CSRF-Token"]} 150 | 151 | return self._jsondec(response.text) 152 | 153 | def _api_write(self, url, params=None): 154 | return self._write(self._api_url() + url, params) 155 | 156 | @retry_login 157 | def _update(self, url, params=None): 158 | response = self.session.put(url, json=params, headers=self.headers) 159 | 160 | if response.headers.get("X-CSRF-Token"): 161 | self.headers = {"X-CSRF-Token": response.headers["X-CSRF-Token"]} 162 | 163 | return self._jsondec(response.text) 164 | 165 | def _api_update(self, url, params=None): 166 | return self._update(self._api_url() + url, params) 167 | 168 | @retry_login 169 | def _delete(self, url, params=None): 170 | response = self.session.delete(url, json=params, headers=self.headers) 171 | 172 | if response.headers.get("X-CSRF-Token"): 173 | self.headers = {"X-CSRF-Token": response.headers["X-CSRF-Token"]} 174 | 175 | return self._jsondec(response.text) 176 | 177 | def _api_delete(self, url, params=None): 178 | return self._delete(self._api_url() + url, params) 179 | 180 | def _login(self): 181 | self.log.debug("login() as %s", self.username) 182 | self.session = requests.Session() 183 | self.session.verify = self.ssl_verify 184 | 185 | response = self.session.post( 186 | self.auth_url, 187 | json={"username": self.username, "password": self.password}, 188 | headers=self.headers, 189 | ) 190 | 191 | if response.headers.get("X-CSRF-Token"): 192 | self.headers = {"X-CSRF-Token": response.headers["X-CSRF-Token"]} 193 | 194 | if response.status_code != 200: 195 | raise APIError( 196 | "Login failed - status code: %i" % response.status_code 197 | ) 198 | 199 | def _logout(self): 200 | self.log.debug("logout()") 201 | self._api_write("logout") 202 | self.session.close() 203 | 204 | def switch_site(self, name): 205 | """ 206 | Switch to another site 207 | 208 | :param name: Site Name 209 | :return: True or APIError 210 | """ 211 | 212 | # TODO: Not currently supported on UDMP as site support doesn't exist. 213 | if self.version == "UDMP-unifiOS": 214 | raise APIError( 215 | "Controller version not supported: %s" % self.version 216 | ) 217 | 218 | for site in self.get_sites(): 219 | if site["desc"] == name: 220 | self.site_id = site["name"] 221 | return True 222 | raise APIError("No site %s found" % name) 223 | 224 | def get_alerts(self): 225 | """Return a list of all Alerts.""" 226 | return self._api_write("stat/alarm") 227 | 228 | def get_alerts_unarchived(self): 229 | """Return a list of Alerts unarchived.""" 230 | params = {"archived": False} 231 | return self._api_write("stat/alarm", params=params) 232 | 233 | def get_statistics_last_24h(self): 234 | """Returns statistical data of the last 24h""" 235 | return self.get_statistics_24h(time.time()) 236 | 237 | def get_statistics_24h(self, endtime): 238 | """Return statistical data last 24h from time""" 239 | params = { 240 | "attrs": ["bytes", "num_sta", "time"], 241 | "start": int(endtime - 86400) * 1000, 242 | "end": int(endtime - 3600) * 1000, 243 | } 244 | return self._api_write("stat/report/hourly.site", params) 245 | 246 | def get_events(self): 247 | """Return a list of all Events.""" 248 | return self._api_read("stat/event") 249 | 250 | def get_aps(self): 251 | """Return a list of all APs, 252 | with significant information about each. 253 | """ 254 | # Set test to 0 instead of NULL 255 | params = {"_depth": 2, "test": 0} 256 | return self._api_read("stat/device", params) 257 | 258 | def get_client(self, mac): 259 | """Get details about a specific client""" 260 | 261 | # stat/user/ works better than stat/sta/ 262 | # stat/sta seems to be only active clients 263 | # stat/user includes known but offline clients 264 | return self._api_read("stat/user/" + mac)[0] 265 | 266 | def get_clients(self): 267 | """Return a list of all active clients, 268 | with significant information about each. 269 | """ 270 | return self._api_read("stat/sta") 271 | 272 | def get_users(self): 273 | """Return a list of all known clients, 274 | with significant information about each. 275 | """ 276 | return self._api_read("list/user") 277 | 278 | def get_user_groups(self): 279 | """Return a list of user groups with its rate limiting settings.""" 280 | return self._api_read("list/usergroup") 281 | 282 | def get_sysinfo(self): 283 | """Return basic system informations.""" 284 | return self._api_read("stat/sysinfo") 285 | 286 | def get_healthinfo(self): 287 | """Return health information.""" 288 | return self._api_read("stat/health") 289 | 290 | def get_sites(self): 291 | """Return a list of all sites, 292 | with their UID and description""" 293 | return self._read(self.url + "api/self/sites") 294 | 295 | def get_wlan_conf(self): 296 | """Return a list of configured WLANs 297 | with their configuration parameters. 298 | """ 299 | return self._api_read("list/wlanconf") 300 | 301 | def _run_command(self, command, params=None, mgr="stamgr"): 302 | if params is None: 303 | params = {} 304 | self.log.debug("_run_command(%s)", command) 305 | params.update({"cmd": command}) 306 | return self._api_write("cmd/" + mgr, params=params) 307 | 308 | def _mac_cmd(self, target_mac, command, mgr="stamgr", params=None): 309 | if params is None: 310 | params = {} 311 | self.log.debug("_mac_cmd(%s, %s)", target_mac, command) 312 | params["mac"] = target_mac 313 | return self._run_command(command, params, mgr) 314 | 315 | def get_device_stat(self, target_mac): 316 | """Gets the current state & configuration of 317 | the given device based on its MAC Address. 318 | :param target_mac: MAC address of the device. 319 | :type target_mac: str 320 | :returns: Dictionary containing metadata, state, 321 | capabilities and configuration of the device 322 | :rtype: dict() 323 | """ 324 | self.log.debug("get_device_stat(%s)", target_mac) 325 | params = {"macs": [target_mac]} 326 | return self._api_read("stat/device/" + target_mac, params)[0] 327 | 328 | def get_radius_users(self): 329 | """Return a list of all users, with their 330 | name, password, 24 digit user id, and 24 digit site id 331 | """ 332 | return self._api_read('rest/account') 333 | 334 | def add_radius_user(self, name, password): 335 | """Add a new user with this username and password 336 | :param name: new user's username 337 | :param password: new user's password 338 | :returns: user's name, password, 24 digit user id, and 24 digit site id 339 | """ 340 | params = {'name': name, 'x_password': password} 341 | return self._api_write('rest/account/', params) 342 | 343 | def update_radius_user(self, name, password, user_id): 344 | """Update a user to this new username and password 345 | :param name: user's new username 346 | :param password: user's new password 347 | :param id: the user's 24 digit user id, from get_radius_users() 348 | or add_radius_user() 349 | :returns: user's name, password, 24 digit user id, and 24 digit site id 350 | :returns: [] if no change was made 351 | """ 352 | params = {'name': name, '_id': user_id, 'x_password': password} 353 | return self._api_update('rest/account/' + user_id, params) 354 | 355 | def delete_radius_user(self, user_id): 356 | """Delete user 357 | :param id: the user's 24 digit user id, from get_radius_users() 358 | or add_radius_user() 359 | :returns: [] if successful 360 | """ 361 | return self._api_delete('rest/account/' + user_id) 362 | 363 | def get_switch_port_overrides(self, target_mac): 364 | """Gets a list of port overrides, in dictionary 365 | format, for the given target MAC address. The 366 | dictionary contains the port_idx, portconf_id, 367 | poe_mode, & name. 368 | 369 | :param target_mac: MAC address of the device. 370 | :type target_mac: str 371 | :returns: [ { 'port_idx': int(), 'portconf': str, 372 | 'poe_mode': str, 'name': str } ] 373 | :rtype: list( dict() ) 374 | """ 375 | self.log.debug("get_switch_port_overrides(%s)", target_mac) 376 | return self.get_device_stat(target_mac)["port_overrides"] 377 | 378 | def _switch_port_power(self, target_mac, port_idx, mode): 379 | """Helper method to set the given PoE mode the port/switch. 380 | 381 | :param target_mac: MAC address of the Switch. 382 | :type target_mac: str 383 | :param port_idx: Port ID to target 384 | :type port_idx: int 385 | :param mode: PoE mode to set. ie. auto, on, off. 386 | :type mode: str 387 | :returns: { 'port_overrides': [ { 'port_idx': int(), 388 | 'portconf': str, 'poe_mode': str, 'name': str } ] } 389 | :rtype: dict( list( dict() ) ) 390 | """ 391 | # TODO: Switch operations should most likely happen in a 392 | # different Class, Switch. 393 | self.log.debug( 394 | "_switch_port_power(%s, %s, %s)", target_mac, port_idx, mode 395 | ) 396 | device_stat = self.get_device_stat(target_mac) 397 | device_id = device_stat.get("_id") 398 | overrides = device_stat.get("port_overrides") 399 | found = False 400 | if overrides: 401 | for i in overrides: 402 | if overrides[i]["port_idx"] == port_idx: 403 | # Override already exists, update.. 404 | overrides[i]["poe_mode"] = mode 405 | found = True 406 | break 407 | if not found: 408 | # Retrieve portconf 409 | portconf_id = None 410 | for port in device_stat["port_table"]: 411 | if port["port_idx"] == port_idx: 412 | portconf_id = port["portconf_id"] 413 | break 414 | if portconf_id is None: 415 | raise APIError( 416 | "Port ID %s not found in port_table" % str(port_idx) 417 | ) 418 | overrides.append( 419 | { 420 | "port_idx": port_idx, 421 | "portconf_id": portconf_id, 422 | "poe_mode": mode 423 | } 424 | ) 425 | # We return the device_id as it's needed by the parent method 426 | return {"port_overrides": overrides, "device_id": device_id} 427 | 428 | def switch_port_power_off(self, target_mac, port_idx): 429 | """Powers Off the given port on the Switch identified 430 | by the given MAC Address. 431 | 432 | :param target_mac: MAC address of the Switch. 433 | :type target_mac: str 434 | :param port_idx: Port ID to power off 435 | :type port_idx: int 436 | :returns: API Response which is the resulting complete port overrides 437 | :rtype: list( dict() ) 438 | """ 439 | self.log.debug("switch_port_power_off(%s, %s)", target_mac, port_idx) 440 | params = self._switch_port_power(target_mac, port_idx, "off") 441 | device_id = params["device_id"] 442 | del params["device_id"] 443 | return self._api_update("rest/device/" + device_id, params) 444 | 445 | def switch_port_power_on(self, target_mac, port_idx): 446 | """Powers On the given port on the Switch identified 447 | by the given MAC Address. 448 | 449 | :param target_mac: MAC address of the Switch. 450 | :type target_mac: str 451 | :param port_idx: Port ID to power on 452 | :type port_idx: int 453 | :returns: API Response which is the resulting complete port overrides 454 | :rtype: list( dict() ) 455 | """ 456 | self.log.debug("switch_port_power_on(%s, %s)", target_mac, port_idx) 457 | params = self._switch_port_power(target_mac, port_idx, "auto") 458 | device_id = params["device_id"] 459 | del params["device_id"] 460 | return self._api_update("rest/device/" + device_id, params) 461 | 462 | def create_site(self, desc="desc"): 463 | """Create a new site. 464 | 465 | :param desc: Name of the site to be created. 466 | """ 467 | 468 | # TODO: Not currently supported on UDMP as site support doesn't exist. 469 | if self.version == "UDMP-unifiOS": 470 | raise APIError( 471 | "Controller version not supported: %s" % self.version 472 | ) 473 | 474 | return self._run_command( 475 | "add-site", 476 | params={"desc": desc}, 477 | mgr="sitemgr" 478 | ) 479 | 480 | def block_client(self, mac): 481 | """Add a client to the block list. 482 | 483 | :param mac: the MAC address of the client to block. 484 | """ 485 | return self._mac_cmd(mac, "block-sta") 486 | 487 | def unblock_client(self, mac): 488 | """Remove a client from the block list. 489 | 490 | :param mac: the MAC address of the client to unblock. 491 | """ 492 | return self._mac_cmd(mac, "unblock-sta") 493 | 494 | def disconnect_client(self, mac): 495 | """Disconnect a client. 496 | 497 | Disconnects a client, forcing them to reassociate. Useful when the 498 | connection is of bad quality to force a rescan. 499 | 500 | :param mac: the MAC address of the client to disconnect. 501 | """ 502 | return self._mac_cmd(mac, "kick-sta") 503 | 504 | def restart_ap(self, mac): 505 | """Restart an access point (by MAC). 506 | 507 | :param mac: the MAC address of the AP to restart. 508 | """ 509 | return self._mac_cmd(mac, "restart", "devmgr") 510 | 511 | def restart_ap_name(self, name): 512 | """Restart an access point (by name). 513 | 514 | :param name: the name address of the AP to restart. 515 | """ 516 | if not name: 517 | raise APIError("%s is not a valid name" % str(name)) 518 | for access_point in self.get_aps(): 519 | if ( 520 | access_point.get("state", 0) == 1 521 | and access_point.get("name", None) == name 522 | ): 523 | result = self.restart_ap(access_point["mac"]) 524 | return result 525 | 526 | def archive_all_alerts(self): 527 | """Archive all Alerts""" 528 | return self._run_command("archive-all-alarms", mgr="evtmgr") 529 | 530 | # TODO: Not currently supported on UDMP as it now utilizes async-backups. 531 | def create_backup(self, days="0"): 532 | """Ask controller to create a backup archive file 533 | 534 | ..warning: 535 | This process puts significant load on the controller 536 | and may render it partially unresponsive for other requests. 537 | 538 | :param days: metrics of the last x days will be added to the backup. 539 | '-1' backup all metrics. '0' backup only the configuration. 540 | :return: URL path to backup file 541 | """ 542 | if self.version == "UDMP-unifiOS": 543 | raise APIError( 544 | "Controller version not supported: %s" % self.version 545 | ) 546 | 547 | res = self._run_command( 548 | "backup", 549 | mgr="system", 550 | params={"days": days} 551 | ) 552 | return res[0]["url"] 553 | 554 | # TODO: Not currently supported on UDMP as it now utilizes async-backups. 555 | def get_backup(self, download_path=None, target_file="unifi-backup.unf"): 556 | """ 557 | :param download_path: path to backup; if None is given 558 | one will be created 559 | :param target_file: Filename or full path to download the 560 | backup archive to, should have .unf extension for restore. 561 | """ 562 | if self.version == "UDMP-unifiOS": 563 | raise APIError( 564 | "Controller version not supported: %s" % self.version 565 | ) 566 | 567 | if not download_path: 568 | download_path = self.create_backup() 569 | 570 | response = self.session.get(self.url + download_path, stream=True) 571 | 572 | if response != 200: 573 | raise APIError("API backup failed: %i" % response.status_code) 574 | 575 | with open(target_file, "wb") as _backfh: 576 | return shutil.copyfileobj(response.raw, _backfh) 577 | 578 | def authorize_guest( # pylint: disable=R0913 579 | self, 580 | guest_mac, 581 | minutes, 582 | up_bandwidth=None, 583 | down_bandwidth=None, 584 | byte_quota=None, 585 | ap_mac=None, 586 | ): 587 | """ 588 | Authorize a guest based on his MAC address. 589 | 590 | :param guest_mac: the guest MAC address: 'aa:bb:cc:dd:ee:ff' 591 | :param minutes: duration of the authorization in minutes 592 | :param up_bandwidth: up speed allowed in kbps 593 | :param down_bandwidth: down speed allowed in kbps 594 | :param byte_quota: quantity of bytes allowed in MB 595 | :param ap_mac: access point MAC address 596 | """ 597 | cmd = "authorize-guest" 598 | params = {"mac": guest_mac, "minutes": minutes} 599 | 600 | if up_bandwidth: 601 | params["up"] = up_bandwidth 602 | if down_bandwidth: 603 | params["down"] = down_bandwidth 604 | if byte_quota: 605 | params["bytes"] = byte_quota 606 | if ap_mac: 607 | params["ap_mac"] = ap_mac 608 | return self._run_command(cmd, params=params) 609 | 610 | def unauthorize_guest(self, guest_mac): 611 | """ 612 | Unauthorize a guest based on his MAC address. 613 | 614 | :param guest_mac: the guest MAC address: 'aa:bb:cc:dd:ee:ff' 615 | """ 616 | cmd = "unauthorize-guest" 617 | params = {"mac": guest_mac} 618 | return self._run_command( 619 | cmd, 620 | params=params 621 | ) 622 | 623 | def get_firmware( 624 | self, 625 | cached=True, 626 | available=True, 627 | known=False, 628 | site=False 629 | ): 630 | 631 | """ 632 | Return a list of available/cached firmware versions 633 | 634 | :param cached: Return cached firmwares 635 | :param available: Return available (and not cached) firmwares 636 | :param known: Return only firmwares for known devices 637 | :param site: Return only firmwares for on-site devices 638 | :return: List of firmware dicts 639 | """ 640 | res = [] 641 | if cached: 642 | res.extend(self._run_command("list-cached", mgr="firmware")) 643 | if available: 644 | res.extend(self._run_command("list-available", mgr="firmware")) 645 | 646 | if known: 647 | res = [fw for fw in res if fw["knownDevice"]] 648 | if site: 649 | res = [fw for fw in res if fw["siteDevice"]] 650 | return res 651 | 652 | def cache_firmware(self, version, device): 653 | """ 654 | Cache the firmware on the UniFi Controller 655 | 656 | .. warning:: Caching one device might very well cache others, 657 | as they're on shared platforms 658 | 659 | :param version: version to cache 660 | :param device: device model to cache (e.g. BZ2) 661 | :return: True/False 662 | """ 663 | return self._run_command( 664 | "download", 665 | mgr="firmware", 666 | params={ 667 | "device": device, 668 | "version": version 669 | } 670 | )[0]["result"] 671 | 672 | def remove_firmware(self, version, device): 673 | """ 674 | Remove cached firmware from the UniFi Controller 675 | 676 | .. warning:: Removing one device's firmware might very well remove 677 | others, as they're on shared platforms 678 | 679 | :param version: version to cache 680 | :param device: device model to cache (e.g. BZ2) 681 | :return: True/false 682 | """ 683 | return self._run_command( 684 | "remove", 685 | mgr="firmware", 686 | params={ 687 | "device": device, 688 | "version": version 689 | } 690 | )[0]["result"] 691 | 692 | def get_tag(self): 693 | """Get all tags and their member MACs""" 694 | return self._api_read("rest/tag") 695 | 696 | def upgrade_device(self, mac, version): 697 | """ 698 | Upgrade a device's firmware to verion 699 | :param mac: MAC of dev 700 | :param version: version to upgrade to 701 | """ 702 | self._mac_cmd( 703 | mac, 704 | "upgrade", 705 | mgr="devmgr", 706 | params={ 707 | "upgrade_to_firmware": version 708 | } 709 | ) 710 | 711 | def provision(self, mac): 712 | """ 713 | Force provisioning of a device 714 | :param mac: MAC of device 715 | """ 716 | self._mac_cmd(mac, "force-provision", mgr="devmgr") 717 | 718 | def get_setting(self, section=None, cs_settings=False): 719 | """ 720 | Return settings for this site or controller 721 | 722 | :param cs_settings: Return only controller-wide settings 723 | :param section: Only return this/these section(s) 724 | :return: {section:settings} 725 | """ 726 | res = {} 727 | all_settings = self._api_read("get/setting") 728 | if section and not isinstance(section, (list, tuple)): 729 | section = [section] 730 | 731 | for setting in all_settings: 732 | s_sect = setting["key"] 733 | if ( 734 | (cs_settings and "site_id" in setting) 735 | or (not cs_settings and "site_id" not in setting) 736 | or (section and s_sect not in section) 737 | ): 738 | continue 739 | for k in ("_id", "site_id", "key"): 740 | setting.pop(k, None) 741 | res[s_sect] = setting 742 | return res 743 | 744 | def update_setting(self, settings): 745 | """ 746 | Update settings 747 | 748 | :param settings: {section:{settings}} 749 | :return: resulting settings 750 | """ 751 | res = [] 752 | for sect, setting in settings.items(): 753 | res.extend(self._api_write("set/setting/" + sect, setting)) 754 | return res 755 | 756 | def update_user_group(self, group_id, down_kbps=-1, up_kbps=-1): 757 | """ 758 | Update user group bandwidth settings 759 | 760 | :param group_id: Group ID to modify 761 | :param down_kbps: New bandwidth in KBPS for download 762 | :param up_kbps: New bandwidth in KBPS for upload 763 | """ 764 | 765 | res = None 766 | groups = self.get_user_groups() 767 | 768 | for group in groups: 769 | if group["_id"] == group_id: 770 | # Apply setting change 771 | res = self._api_update( 772 | "rest/usergroup/{0}".format(group_id), 773 | { 774 | "qos_rate_max_down": down_kbps, 775 | "qos_rate_max_up": up_kbps, 776 | "name": group["name"], 777 | "_id": group_id, 778 | "site_id": self.site_id, 779 | }, 780 | ) 781 | return res 782 | 783 | raise ValueError("Group ID {0} is not valid.".format(group_id)) 784 | 785 | def set_client_alias(self, mac, alias): 786 | """ 787 | Set the client alias. Set to "" to reset to default 788 | :param mac: The MAC of the client to rename 789 | :param alias: The alias to set 790 | """ 791 | client = self.get_client(mac)["_id"] 792 | return self._api_update("rest/user/" + client, {"name": alias}) 793 | 794 | def create_voucher( # pylint: disable=R0913 795 | self, 796 | number, 797 | quota, 798 | expire, 799 | up_bandwidth=None, 800 | down_bandwidth=None, 801 | byte_quota=None, 802 | note=None, 803 | ): 804 | """ 805 | Create voucher for guests. 806 | 807 | :param number: number of vouchers 808 | :param quota: number of using; 0 = unlimited 809 | :param expire: expiration of voucher in minutes 810 | :param up_bandwidth: up speed allowed in kbps 811 | :param down_bandwidth: down speed allowed in kbps 812 | :param byte_quota: quantity of bytes allowed in MB 813 | :param note: description 814 | """ 815 | cmd = "create-voucher" 816 | params = { 817 | "n": number, 818 | "quota": quota, 819 | "expire": "custom", 820 | "expire_number": expire, 821 | "expire_unit": 1, 822 | } 823 | 824 | if up_bandwidth: 825 | params["up"] = up_bandwidth 826 | if down_bandwidth: 827 | params["down"] = down_bandwidth 828 | if byte_quota: 829 | params["bytes"] = byte_quota 830 | if note: 831 | params["note"] = note 832 | res = self._run_command(cmd, mgr="hotspot", params=params) 833 | return self.list_vouchers(create_time=res[0]["create_time"]) 834 | 835 | def list_vouchers(self, **filter_voucher): 836 | """ 837 | Get list of vouchers 838 | 839 | :param filter_voucher: Filter vouchers by create_time, code, quota, 840 | used, note, status_expires, status, ... 841 | 842 | """ 843 | if "code" in filter_voucher: 844 | filter_voucher["code"] = filter_voucher["code"].replace("-", "") 845 | 846 | vouchers = [] 847 | for voucher in self._api_read("stat/voucher"): 848 | voucher_match = True 849 | for key, val in filter_voucher.items(): 850 | voucher_match &= voucher.get(key) == val 851 | if voucher_match: 852 | vouchers.append(voucher) 853 | return vouchers 854 | 855 | def delete_voucher(self, voucher_id): 856 | """ 857 | Delete / revoke voucher 858 | 859 | :param id: id of voucher 860 | """ 861 | cmd = "delete-voucher" 862 | params = {"_id": voucher_id} 863 | self._run_command(cmd, mgr="hotspot", params=params) 864 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | requests~=2.22 2 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from setuptools import setup 4 | 5 | setup(name='pyunifi', 6 | version='2.21', 7 | description='API for Ubiquity Networks UniFi controller', 8 | author='Caleb Dunn', 9 | author_email='finish.06@gmail.com', 10 | url='https://github.com/finish06/unifi-api', 11 | packages=['pyunifi'], 12 | scripts=['unifi-create-voucher', 'unifi-ls-clients'], 13 | classifiers=[], 14 | install_requires=['requests'], 15 | ) 16 | -------------------------------------------------------------------------------- /test_pyunifi.py: -------------------------------------------------------------------------------- 1 | from unittest import mock, TestCase, main 2 | from pyunifi.controller import APIError, Controller 3 | 4 | 5 | class testPyUnifi(TestCase): 6 | def test_controller_args(self): 7 | # Test for controller versions 8 | self.assertRaises(APIError, Controller, 'host', 9 | 'username', 'password', version='v3') 10 | 11 | # Test for missing arguments 12 | self.assertRaises(TypeError, Controller, 'username', 'password') 13 | 14 | @mock.patch('pyunifi.controller.Controller') 15 | def test_pyunifi_switch_sites(self, MockPyUnifi): 16 | controller = MockPyUnifi() 17 | 18 | # Test function to switch sites 19 | controller.switch_site.return_value = [True] 20 | response = controller.switch_site('test1') 21 | self.assertIsNotNone(response) 22 | self.assertIsInstance(True, bool) 23 | 24 | @mock.patch('pyunifi.controller.Controller') 25 | def test_pyunifi_get_aps(self, MockPyUnifi): 26 | controller = MockPyUnifi() 27 | 28 | controller.get_aps.return_value = [ 29 | { 30 | '_id': '11111', 31 | '_uptime': '30506', 32 | 'adopted': True, 33 | 'ip': '192.168.1.5' 34 | } 35 | ] 36 | response = controller.get_aps() 37 | self.assertIsNotNone(response) 38 | self.assertIsInstance(response[0], dict) 39 | 40 | 41 | if __name__ == '__main__': 42 | main() 43 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = lint 3 | skip_missing_interpreters = True 4 | 5 | [testenv] 6 | setenv = 7 | PYTHONPATH = {toxinidir}:{toxinidir}/pyunifi 8 | whitelist_externals = /usr/bin/env 9 | install_command = /usr/bin/env LANG=C.UTF-8 pip install {opts} {packages} 10 | commands = 11 | py.test --timeout=30 --duration=10 --cov --cov-report= {posargs} 12 | 13 | [testenv:lint] 14 | basepython = python3 15 | ignore_errors = True 16 | commands = 17 | flake8 18 | deps = flake8 19 | 20 | [flake8] 21 | exclude = .venv,.git,.tox,dist,doc,*lib/python*,*egg,build -------------------------------------------------------------------------------- /unifi-copy-radius: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import argparse 4 | import json 5 | 6 | from pyunifi.controller import Controller 7 | 8 | parser = argparse.ArgumentParser() 9 | parser.add_argument('-c', '--controller', default='unifi', help='the controller address (default "unifi")') 10 | parser.add_argument('-u', '--username', default='admin', help='the controller username (default("admin")') 11 | parser.add_argument('-p', '--password', default='', help='the controller password') 12 | parser.add_argument('-b', '--port', default='8443', help='the controller port (default "8443")') 13 | parser.add_argument('-v', '--version', default='v5', help='the controller base version (default "v5")') 14 | parser.add_argument('-s', '--siteid', default='default', help='the source site ID, (default "default")') 15 | parser.add_argument('-S', '--siteid2', default='', help='the destination site ID, to copy to') 16 | parser.add_argument('-V', '--no-ssl-verify', default=False, action='store_true', help='Don\'t verify ssl certificates') 17 | parser.add_argument('-C', '--certificate', default='', help='verify with ssl certificate pem file') 18 | args = parser.parse_args() 19 | 20 | ssl_verify = (not args.no_ssl_verify) 21 | 22 | if ssl_verify and len(args.certificate) > 0: 23 | ssl_verify = args.certificate 24 | 25 | controller_source = Controller(args.controller, args.username, args.password, args.port, args.version, args.siteid, ssl_verify=ssl_verify) 26 | controller_dest = Controller(args.controller, args.username, args.password, args.port, args.version, args.siteid2, ssl_verify=ssl_verify) 27 | 28 | source_users = controller_source.get_radius_users() 29 | dest_users = controller_dest.get_radius_users() 30 | 31 | for user in source_users: 32 | # remove irrelevent fields 33 | user.pop("site_id", None) 34 | user.pop("vlan", None) 35 | user.pop("tunnel_type", None) 36 | user.pop("tunnel_medium_type", None) 37 | # add status field to keep track of which 38 | # users should be added or deleted or modified 39 | user["status"] = "None" 40 | for user in dest_users: 41 | # remove irrelevent fields 42 | user.pop("site_id", None) 43 | user.pop("vlan", None) 44 | user.pop("tunnel_type", None) 45 | user.pop("tunnel_medium_type", None) 46 | # add status field to keep track of which 47 | # users should be added or deleted or modified 48 | user["status"] = "None" 49 | 50 | source_users.sort(key=lambda x: x['name']) 51 | print("source_users\n", json.dumps(source_users, indent=2, sort_keys=False), "\n") 52 | dest_users.sort(key=lambda x: x['name']) 53 | print("dest_users\n", json.dumps(dest_users, indent=2, sort_keys=False), "\n") 54 | 55 | unchanged_users = [] 56 | modified_users = [] 57 | temp_user = {} 58 | 59 | # Compare source and destination usernames and passwords 60 | # to decide which users have been unchanged or modified 61 | # 62 | for source_user in source_users: 63 | for dest_user in dest_users: 64 | if source_user['name'] == dest_user['name']: 65 | # usernames are the same 66 | if source_user['x_password'] == dest_user['x_password']: 67 | # username and password are the same 68 | dest_user["status"] = "unchanged" 69 | source_user["status"] = "unchanged" 70 | unchanged_users.append (source_user) 71 | else: 72 | # username is the same but password has changed 73 | dest_user["status"] = "modified" 74 | source_user["status"] = "modified" 75 | # Strange problem solved by temp_user. 76 | # We need the username/password of source_user 77 | temp_user['name'] = source_user['name'] 78 | temp_user['x_password'] = source_user['x_password'] 79 | # but: we need the id of the destination user to modify it 80 | temp_user['_id'] = dest_user['_id'] 81 | modified_users.append (temp_user) 82 | 83 | unchanged_users.sort(key=lambda x: x['name']) 84 | print("unchanged_users\n", json.dumps(unchanged_users, indent=2, sort_keys=False), "\n") 85 | modified_users.sort(key=lambda x: x['name']) 86 | print("modified_users\n", json.dumps(modified_users, indent=2, sort_keys=False), "\n") 87 | 88 | added_users = [] 89 | deleted_users = [] 90 | 91 | # Any users who are not unchanged or modified 92 | # are unique to either the source or destination 93 | # 94 | # Unique users on the source will be added to the destination 95 | for source_user in source_users: 96 | if source_user['status'] == 'None': 97 | source_user['status'] == 'added' 98 | added_users.append(source_user) 99 | # 100 | # Unique users on the destination will be deleted from the destination 101 | for dest_user in dest_users: 102 | if dest_user['status'] == 'None': 103 | dest_user['status'] == 'deleted' 104 | deleted_users.append(dest_user) 105 | 106 | added_users.sort(key=lambda x: x['name']) 107 | print("added_users\n", json.dumps(added_users, indent=2, sort_keys=False), "\n") 108 | deleted_users.sort(key=lambda x: x['name']) 109 | print("deleted_users\n", json.dumps(deleted_users, indent=2, sort_keys=False), "\n") 110 | 111 | print () 112 | if (len(added_users) == 0) and (len(modified_users) == 0) and (len(deleted_users) == 0): 113 | print ("No users to add, modify, or delete") 114 | else: 115 | for user in added_users: 116 | print ("adding user:", user['name']) 117 | controller_dest.add_radius_user(user['name'], user['x_password']) 118 | 119 | for user in modified_users: 120 | print ("updating user:", user['name']) 121 | controller_dest.update_radius_user(user['name'], user['x_password'], user['_id']) 122 | 123 | for user in deleted_users: 124 | print ("deleting user:", user['name']) 125 | controller_dest.delete_radius_user(user['_id']) 126 | -------------------------------------------------------------------------------- /unifi-create-voucher: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import argparse 4 | 5 | from pyunifi.controller import Controller 6 | 7 | parser = argparse.ArgumentParser() 8 | parser.add_argument('-c', '--controller', default='unifi', help='the controller address (default "unifi")') 9 | parser.add_argument('-u', '--username', default='admin', help='the controller username (default("admin")') 10 | parser.add_argument('-p', '--password', default='', help='the controller password') 11 | parser.add_argument('-b', '--port', default='8443', help='the controller port (default "8443")') 12 | parser.add_argument('-v', '--version', default='v5', help='the controller base version (default "v5")') 13 | parser.add_argument('-s', '--siteid', default='default', help='the site ID, UniFi >=3.x only (default "default")') 14 | parser.add_argument('-V', '--no-ssl-verify', default=False, action='store_true', help='Don\'t verify ssl certificates') 15 | parser.add_argument('-C', '--certificate', default='', help='verify with ssl certificate pem file') 16 | args = parser.parse_args() 17 | 18 | ssl_verify = (not args.no_ssl_verify) 19 | 20 | if ssl_verify and len(args.certificate) > 0: 21 | ssl_verify = args.certificate 22 | 23 | c = Controller(args.controller, args.username, args.password, args.port, args.version, args.siteid, ssl_verify=ssl_verify) 24 | 25 | voucher = c.create_voucher(1, 1, 60, up_bandwidth=1024, down_bandwidth=1024, byte_quota=1000, note="unifi-create-voucher") 26 | 27 | code = voucher[0].get('code') 28 | 29 | def format_code(string): 30 | length_string = len(string) 31 | first_length = round(length_string / 2) 32 | first_half = string[0:first_length].lower() 33 | second_half = string[first_length:].upper() 34 | return first_half + '-' + second_half 35 | 36 | voucher_code = format_code(code) 37 | 38 | print(voucher_code) -------------------------------------------------------------------------------- /unifi-disconnect-client: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import argparse 4 | 5 | from pyunifi.controller import Controller 6 | 7 | parser = argparse.ArgumentParser() 8 | parser.add_argument('-c', '--controller', default='unifi', help='the controller address (default "unifi")') 9 | parser.add_argument('-u', '--username', default='admin', help='the controller username (default("admin")') 10 | parser.add_argument('-p', '--password', default='', help='the controller password') 11 | parser.add_argument('-b', '--port', default='8443', help='the controller port (default "8443")') 12 | parser.add_argument('-v', '--version', default='v5', help='the controller base version (default "v5")') 13 | parser.add_argument('-s', '--siteid', default='default', help='the site ID, UniFi >=3.x only (default "default")') 14 | parser.add_argument('-V', '--no-ssl-verify', default=False, action='store_true', help='Don\'t verify ssl certificates') 15 | parser.add_argument('-C', '--certificate', default='', help='verify with ssl certificate pem file') 16 | parser.add_argument('-d', '--debug', default=False, help='enable debug output', action='store_true') 17 | parser.add_argument('macs', metavar='MAC', nargs='+', help='Client MAC address(es)') 18 | args = parser.parse_args() 19 | 20 | if args.debug: 21 | import logging 22 | logging.basicConfig(level=logging.DEBUG) 23 | 24 | c = Controller(args.controller, args.username, args.password, args.port, args.version, args.siteid, ssl_verify=ssl_verify) 25 | 26 | for mac in args.macs: 27 | c.disconnect_client(mac) 28 | -------------------------------------------------------------------------------- /unifi-log-roaming: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from __future__ import print_function 4 | 5 | import argparse 6 | import time 7 | 8 | from pyunifi.controller import Controller 9 | 10 | parser = argparse.ArgumentParser() 11 | parser.add_argument('-c', '--controller', default='unifi', help='the controller address (default "unifi")') 12 | parser.add_argument('-u', '--username', default='admin', help='the controller username (default "admin")') 13 | parser.add_argument('-p', '--password', default='', help='the controller password') 14 | parser.add_argument('-b', '--port', default='8443', help='the controller port (default "8443")') 15 | parser.add_argument('-v', '--version', default='v5', help='the controller base version (default "v5")') 16 | parser.add_argument('-s', '--siteid', default='default', help='the site ID, UniFi >=3.x only (default "default")') 17 | args = parser.parse_args() 18 | 19 | c = Controller(args.controller, args.username, args.password, args.port, args.version, args.siteid) 20 | 21 | aps = c.get_aps() 22 | ap_names = dict([(ap['mac'], ap.get('name')) for ap in aps]) 23 | 24 | client_aps = {} 25 | while True: 26 | clients = c.get_clients() 27 | for client in clients: 28 | ap = ap_names[client['ap_mac']] 29 | mac = client['mac'] 30 | name = client['hostname'] or client['ip'] or client['mac'] 31 | channel = client['channel'] 32 | rssi = client['rssi'] 33 | key = '%s/%d' % (ap, channel) 34 | 35 | if mac in client_aps: 36 | if client_aps[mac] != key: 37 | print('%s roamed %s -> %s' % (name, client_aps[mac], key)) 38 | client_aps[mac] = key 39 | time.sleep(10) 40 | -------------------------------------------------------------------------------- /unifi-low-snr-reconnect: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from __future__ import print_function 4 | 5 | import argparse 6 | import time 7 | from collections import defaultdict 8 | 9 | from pyunifi.controller import Controller 10 | 11 | parser = argparse.ArgumentParser() 12 | parser.add_argument('-c', '--controller', default='unifi', help='the controller address (default "unifi")') 13 | parser.add_argument('-u', '--username', default='admin', help='the controller username (default "admin")') 14 | parser.add_argument('-p', '--password', default='', help='the controller password') 15 | parser.add_argument('-b', '--port', default='8443', help='the controller port (default "8443")') 16 | parser.add_argument('-v', '--version', default='v5', help='the controller base version (default "v5")') 17 | parser.add_argument('-s', '--siteid', default='default', help='the site ID, UniFi >=3.x only (default "default")') 18 | parser.add_argument('-m', '--minsnr', default=15, type=int, help='(dB) minimum required client SNR (default 15)') 19 | parser.add_argument('-i', '--checkintv', default=5, type=int, help='(s) check interval (default 5)') 20 | parser.add_argument('-o', '--holdintv', default=300, type=int, help='(s) holddown interval (default 300)') 21 | parser.add_argument('-d', '--debug', help='enable debug output', action='store_true') 22 | args = parser.parse_args() 23 | 24 | c = Controller(args.controller, args.username, args.password, args.port, args.version, args.siteid) 25 | 26 | all_aps = c.get_aps() 27 | ap_names = dict([(ap['mac'], ap.get('name')) for ap in all_aps]) 28 | 29 | kicks = defaultdict(int) 30 | held = defaultdict(bool) 31 | while True: 32 | res = c.get_clients() 33 | now = time.time() 34 | 35 | if args.debug: 36 | print('Got %d clients to check' % len(res)) 37 | for sta in res: 38 | name = sta.get('hostname','') 39 | snr = sta['rssi'] 40 | mac = sta['mac'] 41 | ap_mac = sta['ap_mac'] 42 | ap_name = ap_names[ap_mac] 43 | essid = sta['essid'] 44 | if args.debug: 45 | print('%s/%s@%s %d' % (name, mac, ap_name, snr)) 46 | if snr < args.minsnr: 47 | if now - kicks[name] > args.holdintv: 48 | print('Disconnecting %s/%s@%s (SNR %d dB < %d dB) on %s' % (name, mac, ap_name, snr, args.minsnr,essid)) 49 | c.disconnect_client(mac) 50 | kicks[name] = now 51 | held[name] = False 52 | elif not held[name]: 53 | held[name] = True 54 | if args.debug: 55 | print('Ignoring %s/%s@%s (SNR %d dB < %d dB) (holddown)' % (name, mac, ap_name, snr, args.minsnr)) 56 | 57 | time.sleep(args.checkintv) 58 | 59 | -------------------------------------------------------------------------------- /unifi-ls-clients: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import argparse 4 | 5 | from pyunifi.controller import Controller 6 | 7 | parser = argparse.ArgumentParser() 8 | parser.add_argument('-c', '--controller', default='unifi', help='the controller address (default "unifi")') 9 | parser.add_argument('-u', '--username', default='admin', help='the controller username (default("admin")') 10 | parser.add_argument('-p', '--password', default='', help='the controller password') 11 | parser.add_argument('-b', '--port', default='8443', help='the controller port (default "8443")') 12 | parser.add_argument('-v', '--version', default='v5', help='the controller base version (default "v5")') 13 | parser.add_argument('-s', '--siteid', default='default', help='the site ID, UniFi >=3.x only (default "default")') 14 | parser.add_argument('-V', '--no-ssl-verify', default=False, action='store_true', help='Don\'t verify ssl certificates') 15 | parser.add_argument('-C', '--certificate', default='', help='verify with ssl certificate pem file') 16 | args = parser.parse_args() 17 | 18 | ssl_verify = (not args.no_ssl_verify) 19 | 20 | if ssl_verify and len(args.certificate) > 0: 21 | ssl_verify = args.certificate 22 | 23 | c = Controller(args.controller, args.username, args.password, args.port, args.version, args.siteid, ssl_verify=ssl_verify) 24 | 25 | aps = c.get_aps() 26 | ap_names = dict([(ap['mac'], ap.get('name', '????')) for ap in aps]) 27 | clients = c.get_clients() 28 | clients.sort(key=lambda x: -x.get('rssi', 100)) 29 | 30 | FORMAT = '%-16s %18s %-12s %4s %4s %3s %3s' 31 | print(FORMAT % ('NAME', 'MAC', 'AP', 'CHAN', 'RSSI', 'RX', 'TX')) 32 | for client in clients: 33 | ap_name = ap_names.get(client.get('ap_mac', '????'), '????') 34 | name = client.get('hostname') or client.get('ip', '????') 35 | rssi = client.get('rssi', '????') 36 | mac = client.get('mac', '????') 37 | rx = int(client.get('rx_rate', 0) / 1000) 38 | tx = int(client.get('tx_rate', 0) / 1000) 39 | channel = client.get('channel','????') 40 | 41 | print(FORMAT % (name, mac, ap_name, channel, rssi, rx, tx)) 42 | -------------------------------------------------------------------------------- /unifi-ls-radius: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import argparse 4 | 5 | from pyunifi.controller import Controller 6 | 7 | parser = argparse.ArgumentParser() 8 | parser.add_argument('-c', '--controller', default='unifi', help='the controller address (default "unifi")') 9 | parser.add_argument('-u', '--username', default='admin', help='the controller username (default("admin")') 10 | parser.add_argument('-p', '--password', default='', help='the controller password') 11 | parser.add_argument('-b', '--port', default='8443', help='the controller port (default "8443")') 12 | parser.add_argument('-v', '--version', default='v5', help='the controller base version (default "v5")') 13 | parser.add_argument('-s', '--siteid', default='default', help='the site ID, UniFi >=3.x only (default "default")') 14 | parser.add_argument('-V', '--no-ssl-verify', default=False, action='store_true', help='Don\'t verify ssl certificates') 15 | parser.add_argument('-C', '--certificate', default='', help='verify with ssl certificate pem file') 16 | args = parser.parse_args() 17 | 18 | ssl_verify = (not args.no_ssl_verify) 19 | 20 | if ssl_verify and len(args.certificate) > 0: 21 | ssl_verify = args.certificate 22 | 23 | c = Controller(args.controller, args.username, args.password, args.port, args.version, args.siteid, ssl_verify=ssl_verify) 24 | 25 | users = c.get_radius_users() 26 | users.sort(key=lambda x: x['name']) 27 | 28 | FORMAT = '%-26s %-16s %-26s %-26s' 29 | print(FORMAT % ('USERNAME', 'PASSWORD', 'ID', 'SITE ID')) 30 | for user in users: 31 | name = user["name"] 32 | password = user["x_password"] 33 | id = user["_id"] 34 | site_id = user["site_id"] 35 | 36 | print(FORMAT % (name, password, id, site_id)) 37 | -------------------------------------------------------------------------------- /unifi-save-radius: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import argparse 4 | 5 | from pyunifi.controller import Controller 6 | 7 | parser = argparse.ArgumentParser() 8 | parser.add_argument('-c', '--controller', default='unifi', help='the controller address (default "unifi")') 9 | parser.add_argument('-u', '--username', default='admin', help='the controller username (default("admin")') 10 | parser.add_argument('-p', '--password', default='', help='the controller password') 11 | parser.add_argument('-b', '--port', default='8443', help='the controller port (default "8443")') 12 | parser.add_argument('-v', '--version', default='v5', help='the controller base version (default "v5")') 13 | parser.add_argument('-s', '--siteid', default='default', help='the site ID, UniFi >=3.x only (default "default")') 14 | parser.add_argument('-V', '--no-ssl-verify', default=False, action='store_true', help='Don\'t verify ssl certificates') 15 | parser.add_argument('-C', '--certificate', default='', help='verify with ssl certificate pem file') 16 | parser.add_argument('-f', '--file', default='radius-unifi.csv', help='the filename of write statistics') 17 | args = parser.parse_args() 18 | 19 | ssl_verify = (not args.no_ssl_verify) 20 | 21 | if ssl_verify and len(args.certificate) > 0: 22 | ssl_verify = args.certificate 23 | 24 | c = Controller(args.controller, args.username, args.password, args.port, args.version, args.siteid, ssl_verify=ssl_verify) 25 | 26 | users = c.get_radius_users() 27 | users.sort(key=lambda x: x['name']) 28 | 29 | #open file 30 | fo = open(args.file, "wb") 31 | 32 | FORMAT_CSV = '%s, %s, %s, %s\n' 33 | fo.write(FORMAT_CSV % ('USERNAME', 'PASSWORD', 'ID', 'SITE ID')) 34 | for user in users: 35 | name = user["name"] 36 | password = user["x_password"] 37 | id = user["_id"] 38 | site_id = user["site_id"] 39 | 40 | fo.write(FORMAT_CSV % (name, password, id, site_id)) 41 | 42 | # Close file 43 | fo.close() 44 | 45 | # Print result of file 46 | print(open(args.file,"rb").read()) 47 | -------------------------------------------------------------------------------- /unifi-save-statistics: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import argparse 4 | import time 5 | 6 | from pyunifi.controller import Controller 7 | 8 | parser = argparse.ArgumentParser() 9 | parser.add_argument('-c', '--controller', default='unifi', help='the controller address (default "unifi")') 10 | parser.add_argument('-u', '--username', default='admin', help='the controller username (default("admin")') 11 | parser.add_argument('-p', '--password', default='', help='the controller password') 12 | parser.add_argument('-b', '--port', default='8443', help='the controller port (default "8443")') 13 | parser.add_argument('-v', '--version', default='v5', help='the controller base version (default "v5")') 14 | parser.add_argument('-s', '--siteid', default='default', help='the site ID, UniFi >=3.x only (default "default")') 15 | parser.add_argument('-f', '--file', default='statistics-unifi.csv', help='the filename of write statistics') 16 | args = parser.parse_args() 17 | 18 | c = Controller(args.controller, args.username, args.password, args.port, args.version, args.siteid) 19 | 20 | statistics = c.get_statistics_last_24h() 21 | 22 | #open file 23 | fo = open(args.file, "wb") 24 | 25 | FORMAT_CSV = '%15s;%10s;%25s;%10s\n' 26 | fo.write(FORMAT_CSV % ('Bytes', 'num_sta', 'time', 'time int')); 27 | for stat in statistics: 28 | bytes = stat['bytes'] 29 | num_sta = stat['num_sta'] 30 | timeint = stat['time']/1000 31 | timestap = time.ctime(int(stat['time'])/1000) 32 | fo.write(FORMAT_CSV % (bytes, num_sta, timestap, timeint)); 33 | 34 | # Close file 35 | fo.close() 36 | 37 | # Print result of file 38 | print(open(args.file,"rb").read()) 39 | --------------------------------------------------------------------------------