python-unifi-client 1.2.7__tar.gz → 1.2.8__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {python_unifi_client-1.2.7 → python_unifi_client-1.2.8}/PKG-INFO +1 -1
- {python_unifi_client-1.2.7 → python_unifi_client-1.2.8}/python_unifi_client/client.py +545 -139
- {python_unifi_client-1.2.7 → python_unifi_client-1.2.8}/python_unifi_client.egg-info/PKG-INFO +1 -1
- {python_unifi_client-1.2.7 → python_unifi_client-1.2.8}/setup.py +1 -1
- {python_unifi_client-1.2.7 → python_unifi_client-1.2.8}/README.md +0 -0
- {python_unifi_client-1.2.7 → python_unifi_client-1.2.8}/python_unifi_client/__init__.py +0 -0
- {python_unifi_client-1.2.7 → python_unifi_client-1.2.8}/python_unifi_client.egg-info/SOURCES.txt +0 -0
- {python_unifi_client-1.2.7 → python_unifi_client-1.2.8}/python_unifi_client.egg-info/dependency_links.txt +0 -0
- {python_unifi_client-1.2.7 → python_unifi_client-1.2.8}/python_unifi_client.egg-info/top_level.txt +0 -0
- {python_unifi_client-1.2.7 → python_unifi_client-1.2.8}/setup.cfg +0 -0
|
@@ -77,7 +77,7 @@ class Client:
|
|
|
77
77
|
CURL_METHODS_ALLOWED = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH']
|
|
78
78
|
DEFAULT_CURL_METHOD = 'GET'
|
|
79
79
|
|
|
80
|
-
def __init__(self, user, password, baseurl='https://
|
|
80
|
+
def __init__(self, user, password, baseurl='https://127.0.0.1', site='default', version='8.0.28', ssl_verify=False, unificookie_name='unificookie', debug=False):
|
|
81
81
|
# Initial setup mirrors PHP constructor
|
|
82
82
|
self._check_curl()
|
|
83
83
|
self._check_base_url(baseurl)
|
|
@@ -106,6 +106,29 @@ class Client:
|
|
|
106
106
|
self._cookies_created_at = 0
|
|
107
107
|
self._exec_retries = 0
|
|
108
108
|
self.debug = debug
|
|
109
|
+
self._firmware_version = None # None = unknown, 'v1' or 'v2' (auto-detected)
|
|
110
|
+
|
|
111
|
+
self.default_site_stats_attribs = [
|
|
112
|
+
'bytes',
|
|
113
|
+
'wan-tx_bytes',
|
|
114
|
+
'wan-rx_bytes',
|
|
115
|
+
'wlan_bytes',
|
|
116
|
+
'num_sta',
|
|
117
|
+
'lan-num_sta',
|
|
118
|
+
'wlan-num_sta',
|
|
119
|
+
'time',
|
|
120
|
+
]
|
|
121
|
+
|
|
122
|
+
self.default_ap_stats_attribs = [
|
|
123
|
+
'bytes',
|
|
124
|
+
'wan-tx_bytes',
|
|
125
|
+
'wan-rx_bytes',
|
|
126
|
+
'wlan_bytes',
|
|
127
|
+
'num_sta',
|
|
128
|
+
'lan-num_sta',
|
|
129
|
+
'wlan-num_sta',
|
|
130
|
+
'time',
|
|
131
|
+
]
|
|
109
132
|
|
|
110
133
|
if self.debug:
|
|
111
134
|
logging.basicConfig(level=logging.DEBUG)
|
|
@@ -144,7 +167,7 @@ class Client:
|
|
|
144
167
|
return True
|
|
145
168
|
return False
|
|
146
169
|
|
|
147
|
-
def login(self) -> bool:
|
|
170
|
+
def login(self, return_token:bool=False) -> bool:
|
|
148
171
|
# Skip if already logged in via valid cookie
|
|
149
172
|
self._verify_ssl_cert = False
|
|
150
173
|
|
|
@@ -214,6 +237,8 @@ class Client:
|
|
|
214
237
|
self._session.headers.update({'x-csrf-token': token,
|
|
215
238
|
'Referer': f"{self._baseurl}/login"})
|
|
216
239
|
self._is_loggedin = True
|
|
240
|
+
if return_token:
|
|
241
|
+
return True, token
|
|
217
242
|
return True
|
|
218
243
|
|
|
219
244
|
raise LoginFailedException(f"HTTP response: {resp2.status_code}")
|
|
@@ -258,10 +283,10 @@ class Client:
|
|
|
258
283
|
# The following methods are directly converted from the PHP class to Python, preserving functionality and parameters.
|
|
259
284
|
|
|
260
285
|
def get_site_stats(self):
|
|
261
|
-
return self.fetch_results(f"/api/s/{self._site}/stat/sysinfo")
|
|
286
|
+
return self.fetch_results(f"/api/s/{self._site}/stat/sysinfo", proxy_type='network')
|
|
262
287
|
|
|
263
288
|
def get_clients(self):
|
|
264
|
-
return self.fetch_results(f"/api/s/{self._site}/stat/sta",
|
|
289
|
+
return self.fetch_results(f"/api/s/{self._site}/stat/sta", proxy_type='network')
|
|
265
290
|
|
|
266
291
|
def block_client(self, mac):
|
|
267
292
|
return self.fetch_results(f"/api/s/{self._site}/cmd/stamgr", method='POST', payload={'cmd': 'block-sta', 'mac': mac})
|
|
@@ -361,14 +386,15 @@ class Client:
|
|
|
361
386
|
|
|
362
387
|
return self.fetch_results(
|
|
363
388
|
f"/api/s/{self._site}/rest/user",
|
|
364
|
-
payload=payload
|
|
389
|
+
payload=payload,
|
|
390
|
+
proxy_type='network'
|
|
365
391
|
)
|
|
366
392
|
|
|
367
393
|
def delete_user(self, user_id: str):
|
|
368
394
|
"""
|
|
369
395
|
Remove a user by ID
|
|
370
396
|
"""
|
|
371
|
-
return self.fetch_results(f"/api/s/{self._site}/rest/user/{user_id}",)
|
|
397
|
+
return self.fetch_results(f"/api/s/{self._site}/rest/user/{user_id}", proxy_type='network')
|
|
372
398
|
|
|
373
399
|
def get_user(self, user_id: str):
|
|
374
400
|
"""
|
|
@@ -377,7 +403,7 @@ class Client:
|
|
|
377
403
|
return self.fetch_results(f"/api/s/{self._site}/rest/user/{user_id}")
|
|
378
404
|
|
|
379
405
|
def get_client_by_mac(self, mac: str):
|
|
380
|
-
return self.fetch_results(f"/api/s/{self._site}/stat/user/{mac}",
|
|
406
|
+
return self.fetch_results(f"/api/s/{self._site}/stat/user/{mac}", proxy_type='network')
|
|
381
407
|
|
|
382
408
|
def update_user(self, user_id: str, name: str = None, note: str = None, is_guest: bool = None, is_wired: bool = None):
|
|
383
409
|
"""
|
|
@@ -396,7 +422,8 @@ class Client:
|
|
|
396
422
|
|
|
397
423
|
return self.fetch_results(
|
|
398
424
|
f"/api/s/{self._site}/rest/user/{user_id}",
|
|
399
|
-
payload=payload
|
|
425
|
+
payload=payload,
|
|
426
|
+
proxy_type='network'
|
|
400
427
|
)
|
|
401
428
|
|
|
402
429
|
def forget_user(self, mac: str):
|
|
@@ -461,7 +488,7 @@ class Client:
|
|
|
461
488
|
|
|
462
489
|
|
|
463
490
|
def list_sites(self):
|
|
464
|
-
return self.fetch_results("/api/self/sites")
|
|
491
|
+
return self.fetch_results("/api/self/sites", proxy_type='network')
|
|
465
492
|
|
|
466
493
|
def list_apgroups(self):
|
|
467
494
|
"""
|
|
@@ -498,11 +525,11 @@ class Client:
|
|
|
498
525
|
def list_dashboard(self, five_minutes: bool = False):
|
|
499
526
|
"""Fetch dashboard metrics, 5-minute scale if requested."""
|
|
500
527
|
suffix = '?scale=5minutes' if five_minutes else ''
|
|
501
|
-
return self.fetch_results(f"/api/s/{self._site}/stat/dashboard{suffix}")
|
|
528
|
+
return self.fetch_results(f"/api/s/{self._site}/stat/dashboard{suffix}", proxy_type='network')
|
|
502
529
|
|
|
503
530
|
def list_users(self):
|
|
504
531
|
"""Fetch known client devices."""
|
|
505
|
-
return self.fetch_results(f"/api/s/{self._site}/list/user",
|
|
532
|
+
return self.fetch_results(f"/api/s/{self._site}/list/user", proxy_type='network')
|
|
506
533
|
|
|
507
534
|
def list_tags(self):
|
|
508
535
|
"""Fetch all device tags (REST)."""
|
|
@@ -532,7 +559,7 @@ class Client:
|
|
|
532
559
|
return self.fetch_results(f"/api/s/{self._site}/rest/rogueknown")
|
|
533
560
|
|
|
534
561
|
def get_site(self, site_id) -> str:
|
|
535
|
-
return self.fetch_results(f"/api/s/{site_id}/self")
|
|
562
|
+
return self.fetch_results(f"/api/s/{site_id}/self", proxy_type='network')
|
|
536
563
|
|
|
537
564
|
def create_site(self, description: str):
|
|
538
565
|
"""
|
|
@@ -626,12 +653,12 @@ class Client:
|
|
|
626
653
|
def get_admins(self):
|
|
627
654
|
"""Fetch administrators for current site"""
|
|
628
655
|
payload = {'cmd': 'get-admins'}
|
|
629
|
-
return self.fetch_results(f"/api/s/{self._site}/cmd/sitemgr", payload=payload,
|
|
656
|
+
return self.fetch_results(f"/api/s/{self._site}/cmd/sitemgr", payload=payload, proxy_type='network')
|
|
630
657
|
|
|
631
658
|
def list_all_admins(self):
|
|
632
659
|
"""Fetch all administrators"""
|
|
633
660
|
payload = {'cmd': 'get-all-admins'}
|
|
634
|
-
return self.fetch_results(f"/api/s/{self._site}/cmd/sitemgr", payload=payload)
|
|
661
|
+
return self.fetch_results(f"/api/s/{self._site}/cmd/sitemgr", payload=payload, proxy_type='network')
|
|
635
662
|
|
|
636
663
|
def invite_admin(self, name: str, email: str, enable_sso: bool = True, readonly: bool = False,
|
|
637
664
|
device_adopt: bool = False, device_restart: bool = False) -> bool:
|
|
@@ -651,7 +678,7 @@ class Client:
|
|
|
651
678
|
payload['permissions'].append('API_DEVICE_ADOPT')
|
|
652
679
|
if device_restart:
|
|
653
680
|
payload['permissions'].append('API_DEVICE_RESTART')
|
|
654
|
-
return self.fetch_results_boolean(f"/api/s/{self._site}/cmd/sitemgr", payload=payload)
|
|
681
|
+
return self.fetch_results_boolean(f"/api/s/{self._site}/cmd/sitemgr", payload=payload, proxy_type='network')
|
|
655
682
|
|
|
656
683
|
def assign_existing_admin(self, admin_id: str, readonly: bool = False,
|
|
657
684
|
device_adopt: bool = False, device_restart: bool = False) -> bool:
|
|
@@ -683,12 +710,12 @@ class Client:
|
|
|
683
710
|
def revoke_admin(self, admin_id: str) -> bool:
|
|
684
711
|
"""Revoke an admin's access to this site"""
|
|
685
712
|
payload = {'cmd': 'revoke-admin', 'admin': admin_id.strip()}
|
|
686
|
-
return self.fetch_results_boolean(f"/api/s/{self._site}/cmd/sitemgr", payload=payload)
|
|
713
|
+
return self.fetch_results_boolean(f"/api/s/{self._site}/cmd/sitemgr", payload=payload, proxy_type='network')
|
|
687
714
|
|
|
688
715
|
def delete_admin(self, admin_id: str) -> bool:
|
|
689
716
|
"""Delete an admin user"""
|
|
690
717
|
payload = {'cmd': 'delete-admin', 'admin': admin_id.strip()}
|
|
691
|
-
return self.fetch_results_boolean(f"/api/s/{self._site}/cmd/sitemgr", payload=payload)
|
|
718
|
+
return self.fetch_results_boolean(f"/api/s/{self._site}/cmd/sitemgr", payload=payload,proxy_type='network')
|
|
692
719
|
|
|
693
720
|
def list_wlan_groups(self):
|
|
694
721
|
"""Fetch WLAN groups"""
|
|
@@ -703,7 +730,8 @@ class Client:
|
|
|
703
730
|
return self.fetch_results_boolean(
|
|
704
731
|
'/status',
|
|
705
732
|
payload=None,
|
|
706
|
-
login_required=self._unifi_os
|
|
733
|
+
login_required=self._unifi_os,
|
|
734
|
+
proxy_type='network'
|
|
707
735
|
)
|
|
708
736
|
|
|
709
737
|
def stat_full_status(self):
|
|
@@ -712,7 +740,8 @@ class Client:
|
|
|
712
740
|
self.fetch_results_boolean(
|
|
713
741
|
'/status',
|
|
714
742
|
payload=None,
|
|
715
|
-
login_required=self._unifi_os
|
|
743
|
+
login_required=self._unifi_os,
|
|
744
|
+
proxy_type='network'
|
|
716
745
|
)
|
|
717
746
|
# Decode and return the last raw JSON results
|
|
718
747
|
raw = self.get_last_results_raw(return_json=False)
|
|
@@ -741,11 +770,222 @@ class Client:
|
|
|
741
770
|
return self.fetch_results('/api/self')
|
|
742
771
|
|
|
743
772
|
def get_devices(self):
|
|
744
|
-
return self.fetch_results(f"/api/s/{self._site}/stat/device", login_required=True,
|
|
773
|
+
return self.fetch_results(f"/api/s/{self._site}/stat/device", login_required=True, proxy_type='network')
|
|
745
774
|
|
|
746
775
|
def get_device(self, device_id):
|
|
747
|
-
return self.fetch_results(f"/api/s/{self._site}/stat/device/{device_id}",
|
|
776
|
+
return self.fetch_results(f"/api/s/{self._site}/stat/device/{device_id}", proxy_type='network')
|
|
777
|
+
|
|
778
|
+
# === Health & Status Methods ===
|
|
779
|
+
|
|
780
|
+
def list_health(self):
|
|
781
|
+
"""Get site health status including WAN, LAN, WLAN subsystems.
|
|
782
|
+
|
|
783
|
+
Returns:
|
|
784
|
+
list: Health data for each subsystem with status and details
|
|
785
|
+
"""
|
|
786
|
+
return self.fetch_results(f"/api/s/{self._site}/stat/health", proxy_type='network')
|
|
748
787
|
|
|
788
|
+
def get_device_history(self, mac: str, is_client:bool, limit: int = 50):
|
|
789
|
+
"""Fetch device activity/event history from system logs.
|
|
790
|
+
|
|
791
|
+
Args:
|
|
792
|
+
mac: Device MAC address
|
|
793
|
+
is_client: Determines the scope, 'CLIENTS or DEVICE'
|
|
794
|
+
limit: Maximum number of entries to return
|
|
795
|
+
|
|
796
|
+
Returns:
|
|
797
|
+
list: Activity log entries for the device
|
|
798
|
+
"""
|
|
799
|
+
scope = 'CLIENTS' if is_client else 'DEVICE'
|
|
800
|
+
|
|
801
|
+
self._curl_method = 'GET'
|
|
802
|
+
return self.fetch_results(
|
|
803
|
+
f"/v2/api/site/default/system-log/device/{mac}?mac={mac}&scope={scope}&limit={limit}",
|
|
804
|
+
proxy_type='network'
|
|
805
|
+
)
|
|
806
|
+
|
|
807
|
+
# === Firewall Methods ===
|
|
808
|
+
|
|
809
|
+
def list_firewall_rules(self):
|
|
810
|
+
"""List all firewall rules for the site.
|
|
811
|
+
|
|
812
|
+
Returns:
|
|
813
|
+
list: Firewall rule configurations
|
|
814
|
+
"""
|
|
815
|
+
return self.fetch_results(f"/api/s/{self._site}/rest/firewallrule", proxy_type='network')
|
|
816
|
+
|
|
817
|
+
def create_firewall_rule(self, payload: dict):
|
|
818
|
+
"""Create a new firewall rule.
|
|
819
|
+
|
|
820
|
+
Args:
|
|
821
|
+
payload: Firewall rule configuration dict
|
|
822
|
+
|
|
823
|
+
Returns:
|
|
824
|
+
list: Created firewall rule data
|
|
825
|
+
"""
|
|
826
|
+
self._curl_method = 'POST'
|
|
827
|
+
return self.fetch_results(f"/api/s/{self._site}/rest/firewallrule", payload=payload, proxy_type='network')
|
|
828
|
+
|
|
829
|
+
def delete_firewall_rule(self, rule_id: str):
|
|
830
|
+
"""Delete a firewall rule by ID.
|
|
831
|
+
|
|
832
|
+
Args:
|
|
833
|
+
rule_id: The _id of the firewall rule to delete
|
|
834
|
+
|
|
835
|
+
Returns:
|
|
836
|
+
bool: True if successful
|
|
837
|
+
"""
|
|
838
|
+
self._curl_method = 'DELETE'
|
|
839
|
+
return self.fetch_results_boolean(f"/api/s/{self._site}/rest/firewallrule/{rule_id}", proxy_type='network')
|
|
840
|
+
|
|
841
|
+
# === UniFi OS System Methods (no prefix_path - direct /api/ endpoints) ===
|
|
842
|
+
|
|
843
|
+
def get_system_config(self):
|
|
844
|
+
"""Get UniFi OS system configuration.
|
|
845
|
+
|
|
846
|
+
Returns:
|
|
847
|
+
list: System configuration including SSH, update settings
|
|
848
|
+
"""
|
|
849
|
+
return self.fetch_results("/api/system", proxy_type=None)
|
|
850
|
+
|
|
851
|
+
def set_system_config(self, payload: dict):
|
|
852
|
+
"""Update UniFi OS system configuration.
|
|
853
|
+
|
|
854
|
+
Args:
|
|
855
|
+
payload: System config updates (e.g., {'ssh': {'enabled': True}})
|
|
856
|
+
|
|
857
|
+
Returns:
|
|
858
|
+
list: Updated system configuration
|
|
859
|
+
"""
|
|
860
|
+
self._curl_method = 'PATCH'
|
|
861
|
+
return self.fetch_results("/api/system", payload=payload, proxy_type=None)
|
|
862
|
+
|
|
863
|
+
def set_ssh_password(self, password: str):
|
|
864
|
+
"""Set the SSH password for UniFi OS.
|
|
865
|
+
|
|
866
|
+
Args:
|
|
867
|
+
password: New SSH password
|
|
868
|
+
|
|
869
|
+
Returns:
|
|
870
|
+
list: Response from password set operation
|
|
871
|
+
"""
|
|
872
|
+
self._curl_method = 'POST'
|
|
873
|
+
return self.fetch_results("/api/system/ssh/setpassword", payload={'password': password}, proxy_type=None)
|
|
874
|
+
|
|
875
|
+
def get_notification_settings(self):
|
|
876
|
+
"""Get UniFi OS notification settings.
|
|
877
|
+
|
|
878
|
+
Returns:
|
|
879
|
+
list: Notification configuration
|
|
880
|
+
"""
|
|
881
|
+
return self.fetch_results("/api/notifications/settings", proxy_type=None)
|
|
882
|
+
|
|
883
|
+
def set_notification_settings(self, payload: dict):
|
|
884
|
+
"""Update UniFi OS notification settings.
|
|
885
|
+
|
|
886
|
+
Args:
|
|
887
|
+
payload: Notification settings to update
|
|
888
|
+
|
|
889
|
+
Returns:
|
|
890
|
+
list: Updated notification settings
|
|
891
|
+
"""
|
|
892
|
+
self._curl_method = 'PUT'
|
|
893
|
+
return self.fetch_results("/api/notifications/settings", payload=payload, proxy_type=None)
|
|
894
|
+
|
|
895
|
+
def download_backup(self, filename: str = None):
|
|
896
|
+
"""Download UniFi OS system backup.
|
|
897
|
+
|
|
898
|
+
Args:
|
|
899
|
+
filename: Optional filename filter
|
|
900
|
+
|
|
901
|
+
Returns:
|
|
902
|
+
bytes: Backup file content (requires special handling for binary data)
|
|
903
|
+
"""
|
|
904
|
+
params = {}
|
|
905
|
+
if filename:
|
|
906
|
+
params['filename'] = filename
|
|
907
|
+
self._curl_method = 'GET'
|
|
908
|
+
# Note: This returns raw bytes, not JSON - may need special handling
|
|
909
|
+
return self.exec_curl("/api/backup/download", payload=params if params else None, proxy_type=None)
|
|
910
|
+
|
|
911
|
+
# === User Management Methods (always /proxy/users/ - no v1/v2 distinction) ===
|
|
912
|
+
|
|
913
|
+
def list_site_users(self):
|
|
914
|
+
"""Get all users/admins for the site.
|
|
915
|
+
|
|
916
|
+
Returns:
|
|
917
|
+
list: User accounts with roles and permissions
|
|
918
|
+
"""
|
|
919
|
+
return self.fetch_results("/api/v2/users", proxy_type='users')
|
|
920
|
+
|
|
921
|
+
def invite_user(self, email: str, role: str = 'admin', permissions: dict = None):
|
|
922
|
+
"""Invite a user to the UniFi site.
|
|
923
|
+
|
|
924
|
+
Args:
|
|
925
|
+
email: User's email address
|
|
926
|
+
role: User role (e.g., 'admin', 'viewer')
|
|
927
|
+
permissions: Optional permission overrides
|
|
928
|
+
|
|
929
|
+
Returns:
|
|
930
|
+
list: Invitation result
|
|
931
|
+
"""
|
|
932
|
+
payload = {
|
|
933
|
+
'email': email,
|
|
934
|
+
'role': role
|
|
935
|
+
}
|
|
936
|
+
if permissions:
|
|
937
|
+
payload['permissions'] = permissions
|
|
938
|
+
self._curl_method = 'POST'
|
|
939
|
+
return self.fetch_results("/api/v2/users/invite_add", payload=payload, proxy_type='users')
|
|
940
|
+
|
|
941
|
+
def deactivate_user(self, user_id: str):
|
|
942
|
+
"""Deactivate a user account.
|
|
943
|
+
|
|
944
|
+
Args:
|
|
945
|
+
user_id: User's unique identifier
|
|
946
|
+
|
|
947
|
+
Returns:
|
|
948
|
+
list: Deactivation result
|
|
949
|
+
"""
|
|
950
|
+
self._curl_method = 'PUT'
|
|
951
|
+
return self.fetch_results(f"/api/v2/user/{user_id}/deactivate?isULP=1", proxy_type='users')
|
|
952
|
+
|
|
953
|
+
def activate_user(self, user_id: str):
|
|
954
|
+
"""Activate a deactivated user account.
|
|
955
|
+
|
|
956
|
+
Args:
|
|
957
|
+
user_id: User's unique identifier
|
|
958
|
+
|
|
959
|
+
Returns:
|
|
960
|
+
list: Activation result
|
|
961
|
+
"""
|
|
962
|
+
self._curl_method = 'PUT'
|
|
963
|
+
return self.fetch_results(f"/api/v2/user/{user_id}/active?isULP=1", proxy_type='users')
|
|
964
|
+
|
|
965
|
+
def remove_user(self, user_id: str):
|
|
966
|
+
"""Permanently remove a user account.
|
|
967
|
+
|
|
968
|
+
Args:
|
|
969
|
+
user_id: User's unique identifier
|
|
970
|
+
|
|
971
|
+
Returns:
|
|
972
|
+
list: Removal result
|
|
973
|
+
"""
|
|
974
|
+
self._curl_method = 'DELETE'
|
|
975
|
+
return self.fetch_results(f"/api/v2/user/{user_id}", proxy_type='users')
|
|
976
|
+
|
|
977
|
+
def create_custom_role(self, payload: dict):
|
|
978
|
+
"""Create a custom permission role.
|
|
979
|
+
|
|
980
|
+
Args:
|
|
981
|
+
payload: Role configuration with permissions
|
|
982
|
+
|
|
983
|
+
Returns:
|
|
984
|
+
list: Created role data
|
|
985
|
+
"""
|
|
986
|
+
self._curl_method = 'POST'
|
|
987
|
+
return self.fetch_results("/api/v2/custom_role", payload=payload, proxy_type='users')
|
|
988
|
+
|
|
749
989
|
def get_wlan(self, wlan_id: str):
|
|
750
990
|
"""
|
|
751
991
|
Retrieve WLAN configuration by ID
|
|
@@ -911,7 +1151,7 @@ class Client:
|
|
|
911
1151
|
def list_clients(self, mac: str = None):
|
|
912
1152
|
"""Fetch online client devices, or single device if MAC provided."""
|
|
913
1153
|
path_mac = mac.lower().strip() if isinstance(mac, str) else ''
|
|
914
|
-
return self.fetch_results(f"/api/s/{self._site}/stat/sta/{path_mac}",
|
|
1154
|
+
return self.fetch_results(f"/api/s/{self._site}/stat/sta/{path_mac}", proxy_type='network')
|
|
915
1155
|
|
|
916
1156
|
def list_active_clients(self, include_traffic_usage: bool = True, include_unifi_devices: bool = True):
|
|
917
1157
|
"""Fetch active client devices, with optional traffic and UniFi device inclusion."""
|
|
@@ -919,7 +1159,7 @@ class Client:
|
|
|
919
1159
|
'include_traffic_usage': include_traffic_usage,
|
|
920
1160
|
'include_unifi_devices': include_unifi_devices,
|
|
921
1161
|
})
|
|
922
|
-
return self.fetch_results(f"/v2/api/site/{self._site}/clients/active?{query}",
|
|
1162
|
+
return self.fetch_results(f"/v2/api/site/{self._site}/clients/active?{query}", proxy_type='network')
|
|
923
1163
|
|
|
924
1164
|
def list_clients_history(self, only_non_blocked: bool = True, include_unifi_devices: bool = True, within_hours: int = 0):
|
|
925
1165
|
"""Fetch offline client device history."""
|
|
@@ -1003,7 +1243,7 @@ class Client:
|
|
|
1003
1243
|
"""
|
|
1004
1244
|
List all client sessions (stat/session) for the site
|
|
1005
1245
|
"""
|
|
1006
|
-
return self.fetch_results(f"/api/s/{self._site}/stat/session")
|
|
1246
|
+
return self.fetch_results(f"/api/s/{self._site}/stat/session", proxy_type='network')
|
|
1007
1247
|
|
|
1008
1248
|
def stat_hourly_site(self, start: int = None, end: int = None, attribs: list = None):
|
|
1009
1249
|
"""
|
|
@@ -1035,7 +1275,7 @@ class Client:
|
|
|
1035
1275
|
start = start or end - 12 * 3600 * 1000
|
|
1036
1276
|
attrs = attribs or self.default_site_stats_attribs
|
|
1037
1277
|
payload = {'attrs': ['time'] + attrs, 'start': start, 'end': end}
|
|
1038
|
-
return self.fetch_results(f"/api/s/{self._site}/stat/report/5minutes.site", payload=payload)
|
|
1278
|
+
return self.fetch_results(f"/api/s/{self._site}/stat/report/5minutes.site", payload=payload, proxy_type='network')
|
|
1039
1279
|
|
|
1040
1280
|
def stat_5minutes_aps(self, start: int = None, end: int = None, mac: str = None, attribs: list = None):
|
|
1041
1281
|
"""Fetch 5-minute AP stats (defaults to past 12 hours)."""
|
|
@@ -1045,7 +1285,7 @@ class Client:
|
|
|
1045
1285
|
payload = {'attrs': ['time'] + attrs, 'start': start, 'end': end}
|
|
1046
1286
|
if mac:
|
|
1047
1287
|
payload['mac'] = mac.lower()
|
|
1048
|
-
return self.fetch_results(f"/api/s/{self._site}/stat/report/5minutes.ap", payload=payload)
|
|
1288
|
+
return self.fetch_results(f"/api/s/{self._site}/stat/report/5minutes.ap", payload=payload, proxy_type='network')
|
|
1049
1289
|
|
|
1050
1290
|
def stat_hourly_aps(self, start: int = None, end: int = None, mac: str = None, attribs: list = None):
|
|
1051
1291
|
"""Fetch hourly AP stats (defaults to past 7 days)."""
|
|
@@ -1126,7 +1366,7 @@ class Client:
|
|
|
1126
1366
|
start = start or end - 12 * 3600 * 1000
|
|
1127
1367
|
attrs = attribs or ['time', 'mem', 'cpu', 'loadavg_5']
|
|
1128
1368
|
payload = {'attrs': ['time'] + attrs, 'start': start, 'end': end}
|
|
1129
|
-
return self.fetch_results(f"/api/s/{self._site}/stat/report/5minutes.gw", payload=payload)
|
|
1369
|
+
return self.fetch_results(f"/api/s/{self._site}/stat/report/5minutes.gw", payload=payload, proxy_type='network')
|
|
1130
1370
|
|
|
1131
1371
|
def stat_hourly_gateway(self, start: int = None, end: int = None, attribs: list = None):
|
|
1132
1372
|
"""Fetch hourly gateway stats (defaults to past 7 days)."""
|
|
@@ -1163,7 +1403,7 @@ class Client:
|
|
|
1163
1403
|
def list_backups(self):
|
|
1164
1404
|
payload = {'cmd': 'list-backups'}
|
|
1165
1405
|
|
|
1166
|
-
return self.fetch_results(f"/api/s/{self._site}/cmd/backup", payload=payload,
|
|
1406
|
+
return self.fetch_results(f"/api/s/{self._site}/cmd/backup", payload=payload, proxy_type='network')
|
|
1167
1407
|
|
|
1168
1408
|
def get_network(self, network_id: str):
|
|
1169
1409
|
"""
|
|
@@ -1201,7 +1441,7 @@ class Client:
|
|
|
1201
1441
|
"""
|
|
1202
1442
|
List all port configurations on the site.
|
|
1203
1443
|
"""
|
|
1204
|
-
return self.fetch_results(f"/api/s/{self._site}/list/portconf",
|
|
1444
|
+
return self.fetch_results(f"/api/s/{self._site}/list/portconf", proxy_type='network')
|
|
1205
1445
|
|
|
1206
1446
|
def get_portconf(self, portconf_id: str):
|
|
1207
1447
|
"""
|
|
@@ -1265,7 +1505,7 @@ class Client:
|
|
|
1265
1505
|
end = end if end is not None else int(time.time() * 1000)
|
|
1266
1506
|
start = start if start is not None else end - (7 * 24 * 3600 * 1000)
|
|
1267
1507
|
payload = {'mac': mac, 'start': start, 'end': end}
|
|
1268
|
-
return self.fetch_results(f"/api/s/{self._site}/stat/
|
|
1508
|
+
return self.fetch_results(f"/api/s/{self._site}/stat/", payload=payload, proxy_type='network')
|
|
1269
1509
|
|
|
1270
1510
|
def stat_all_user(self, start: int = None, end: int = None):
|
|
1271
1511
|
"""
|
|
@@ -1455,7 +1695,7 @@ class Client:
|
|
|
1455
1695
|
"""
|
|
1456
1696
|
List DPI stats.
|
|
1457
1697
|
"""
|
|
1458
|
-
return self.fetch_results(f"/api/s/{self._site}/stat/dpi")
|
|
1698
|
+
return self.fetch_results(f"/api/s/{self._site}/stat/dpi", proxy_type='network')
|
|
1459
1699
|
|
|
1460
1700
|
def list_dpi_stats_filtered(self, type: str = 'by_cat', cat_filter: list = None):
|
|
1461
1701
|
"""
|
|
@@ -1473,7 +1713,7 @@ class Client:
|
|
|
1473
1713
|
|
|
1474
1714
|
def list_dpi_app_categories(self):
|
|
1475
1715
|
"""Fetch DPI application categories."""
|
|
1476
|
-
return self.fetch_results(f"/api/s/{self._site}/list/dpi/application/categories")
|
|
1716
|
+
return self.fetch_results(f"/api/s/{self._site}/list/dpi/application/categories", proxy_type='network')
|
|
1477
1717
|
|
|
1478
1718
|
def list_dpi_app(self):
|
|
1479
1719
|
"""Fetch DPI applications."""
|
|
@@ -1562,7 +1802,7 @@ class Client:
|
|
|
1562
1802
|
|
|
1563
1803
|
path = f"/v2/api/site/{self._site}/system-log/{class_}"
|
|
1564
1804
|
|
|
1565
|
-
return self.fetch_results(path, payload=payload,
|
|
1805
|
+
return self.fetch_results(path, payload=payload, proxy_type='network')
|
|
1566
1806
|
|
|
1567
1807
|
def delete_radius_account(self, account_id: str) -> bool:
|
|
1568
1808
|
"""Delete a Radius user account"""
|
|
@@ -1608,14 +1848,11 @@ class Client:
|
|
|
1608
1848
|
}
|
|
1609
1849
|
return self.fetch_results(f"/api/s/{self._site}/stat/report/archive.speedtest", payload=payload)
|
|
1610
1850
|
|
|
1611
|
-
def list_health(self):
|
|
1612
|
-
return self.fetch_results(f"/api/s/{self._site}/stat/heatlh")
|
|
1613
|
-
|
|
1614
1851
|
def stat_user_devices(self):
|
|
1615
1852
|
"""
|
|
1616
1853
|
Retrieve statistics on user devices.
|
|
1617
1854
|
"""
|
|
1618
|
-
return self.fetch_results(f"/api/s/{self._site}/stat/user/devices")
|
|
1855
|
+
return self.fetch_results(f"/api/s/{self._site}/stat/user/devices", proxy_type='network')
|
|
1619
1856
|
|
|
1620
1857
|
def stat_sites(self):
|
|
1621
1858
|
"""
|
|
@@ -1628,10 +1865,10 @@ class Client:
|
|
|
1628
1865
|
Fetch UniFi devices, optionally filtered by MAC addresses.
|
|
1629
1866
|
"""
|
|
1630
1867
|
payload = {'macs': [mac.lower() for mac in macs]}
|
|
1631
|
-
return self.fetch_results(f"/api/s/{self._site}/stat/device", payload=payload,
|
|
1868
|
+
return self.fetch_results(f"/api/s/{self._site}/stat/device", payload=payload, proxy_type='network')
|
|
1632
1869
|
|
|
1633
1870
|
def list_devices_basic(self):
|
|
1634
|
-
return self.fetch_results(f"/api/s/{self._site}/stat/device-basic",
|
|
1871
|
+
return self.fetch_results(f"/api/s/{self._site}/stat/device-basic", proxy_type='network')
|
|
1635
1872
|
|
|
1636
1873
|
def check_controller_update(self):
|
|
1637
1874
|
"""
|
|
@@ -1654,8 +1891,8 @@ class Client:
|
|
|
1654
1891
|
if not self._unifi_os:
|
|
1655
1892
|
raise NotAUnifiOsConsoleException()
|
|
1656
1893
|
payload = {'persistFullData': True}
|
|
1657
|
-
self.fetch_results(f"/api/s/{self._site}/cmd/firmware/update", payload=payload)
|
|
1658
|
-
return
|
|
1894
|
+
results = self.fetch_results(f"/api/s/{self._site}/cmd/firmware/update", payload=payload, proxy_type='network')
|
|
1895
|
+
return results
|
|
1659
1896
|
|
|
1660
1897
|
|
|
1661
1898
|
## ---------- Deprecated Methods ---------
|
|
@@ -1749,6 +1986,24 @@ class Client:
|
|
|
1749
1986
|
self._unifi_os = is_unifi_os
|
|
1750
1987
|
return True
|
|
1751
1988
|
|
|
1989
|
+
def get_firmware_version(self) -> str:
|
|
1990
|
+
"""Return detected firmware version ('v1', 'v2', or None if unknown)"""
|
|
1991
|
+
return self._firmware_version
|
|
1992
|
+
|
|
1993
|
+
def set_firmware_version(self, version: str) -> bool:
|
|
1994
|
+
"""Manually set firmware version (e.g., from cached Odoo record)
|
|
1995
|
+
|
|
1996
|
+
Args:
|
|
1997
|
+
version: 'v1' for legacy controllers, 'v2' for UniFi OS, or None to reset
|
|
1998
|
+
|
|
1999
|
+
Returns:
|
|
2000
|
+
True if version was set successfully, False if invalid version
|
|
2001
|
+
"""
|
|
2002
|
+
if version in ('v1', 'v2', None):
|
|
2003
|
+
self._firmware_version = version
|
|
2004
|
+
return True
|
|
2005
|
+
return False
|
|
2006
|
+
|
|
1752
2007
|
def set_connection_timeout(self, timeout: int) -> bool:
|
|
1753
2008
|
"""Set connection timeout in seconds"""
|
|
1754
2009
|
self._connection_timeout = timeout
|
|
@@ -1778,8 +2033,8 @@ class Client:
|
|
|
1778
2033
|
|
|
1779
2034
|
# ----- Protected Helper Methods -----
|
|
1780
2035
|
|
|
1781
|
-
def fetch_results_boolean(self, path: str, payload=None, login_required: bool = True, prefix_path: bool = True) -> bool:
|
|
1782
|
-
return self.fetch_results(path, payload, boolean=True, login_required=login_required, prefix_path=prefix_path)
|
|
2036
|
+
def fetch_results_boolean(self, path: str, payload=None, login_required: bool = True, prefix_path: bool = True, auto_fallback: bool = True) -> bool:
|
|
2037
|
+
return self.fetch_results(path, payload, boolean=True, login_required=login_required, prefix_path=prefix_path, auto_fallback=auto_fallback)
|
|
1783
2038
|
|
|
1784
2039
|
def _get_json_last_error(self) -> bool:
|
|
1785
2040
|
try:
|
|
@@ -1814,7 +2069,7 @@ class Client:
|
|
|
1814
2069
|
def generate_backup(self, days: int = -1):
|
|
1815
2070
|
"""Generate a controller backup for the past N days."""
|
|
1816
2071
|
payload = {'cmd': 'backup', 'days': days}
|
|
1817
|
-
result = self.fetch_results(f"/
|
|
2072
|
+
result = self.fetch_results(f"/api/s/{self._site}/cmd/backup", payload=payload, proxy_type='network', boolean=False)
|
|
1818
2073
|
print(f"RAW RESULT FROM FETCH_RESULTS(): {result}") if self.debug else None
|
|
1819
2074
|
return result
|
|
1820
2075
|
|
|
@@ -1832,7 +2087,7 @@ class Client:
|
|
|
1832
2087
|
'cmd': 'restore-backup',
|
|
1833
2088
|
'file': filename
|
|
1834
2089
|
}
|
|
1835
|
-
return self.fetch_results(f"/api/s/{self._site}/cmd/backup", payload=payload,
|
|
2090
|
+
return self.fetch_results(f"/api/s/{self._site}/cmd/backup", payload=payload, proxy_type='network')
|
|
1836
2091
|
|
|
1837
2092
|
def generate_backup_site(self):
|
|
1838
2093
|
"""Generate a site export backup."""
|
|
@@ -2027,54 +2282,9 @@ class Client:
|
|
|
2027
2282
|
|
|
2028
2283
|
def get_cookies_created_at(self) -> int:
|
|
2029
2284
|
return self._cookies_created_at
|
|
2030
|
-
|
|
2031
|
-
def __construct(
|
|
2032
|
-
self,
|
|
2033
|
-
user: str,
|
|
2034
|
-
password: str,
|
|
2035
|
-
baseurl: str = 'https://127.0.0.1:8443',
|
|
2036
|
-
site: str = 'default',
|
|
2037
|
-
version: str = '8.0.28',
|
|
2038
|
-
ssl_verify: bool = False,
|
|
2039
|
-
unificookie_name: str = 'unificookie'
|
|
2040
|
-
):
|
|
2041
|
-
"""
|
|
2042
|
-
PHP constructor conversion: checks and initial assignments.
|
|
2043
|
-
"""
|
|
2044
|
-
# Ensure HTTP client available
|
|
2045
|
-
self._check_curl()
|
|
2046
|
-
# Validate base URL and site
|
|
2047
|
-
self._check_base_url(baseurl)
|
|
2048
|
-
self._check_site(site)
|
|
2049
|
-
|
|
2050
|
-
# Assign core properties
|
|
2051
|
-
self._baseurl = baseurl.strip()
|
|
2052
|
-
self._site = site.strip().lower()
|
|
2053
|
-
self._user = user.strip()
|
|
2054
|
-
self._password = password.strip()
|
|
2055
|
-
self._version = version.strip()
|
|
2056
|
-
self._unificookie_name = unificookie_name.strip()
|
|
2057
|
-
|
|
2058
|
-
# SSL verification settings
|
|
2059
|
-
self._verify_ssl_cert = ssl_verify
|
|
2060
|
-
|
|
2061
|
-
def __destruct(self):
|
|
2062
|
-
"""
|
|
2063
|
-
PHP destructor: if the unificookie is set in session, skip logout; otherwise logout if logged in.
|
|
2064
|
-
"""
|
|
2065
|
-
# If the unificookie is present in session cookies, skip logout
|
|
2066
|
-
if self._unificookie_name in self._session.cookies.get_dict():
|
|
2067
|
-
return
|
|
2068
|
-
|
|
2069
|
-
# Logout if still logged in
|
|
2070
|
-
if getattr(self, '_is_loggedin', False):
|
|
2071
|
-
try:
|
|
2072
|
-
self.logout()
|
|
2073
|
-
except Exception:
|
|
2074
|
-
pass
|
|
2075
2285
|
|
|
2076
2286
|
def list_firewallgroups(self, group_id: str = ''):
|
|
2077
|
-
return self.fetch_results(f"/api/s/{self._site}/rest/firewallgroup/{group_id.strip()}",
|
|
2287
|
+
return self.fetch_results(f"/api/s/{self._site}/rest/firewallgroup/{group_id.strip()}", proxy_type='network')
|
|
2078
2288
|
|
|
2079
2289
|
def create_firewallgroup(self, group_name: str, group_type: str, group_members: list = []) -> bool:
|
|
2080
2290
|
"""Create a new firewall group"""
|
|
@@ -2086,7 +2296,7 @@ class Client:
|
|
|
2086
2296
|
'group_type': group_type,
|
|
2087
2297
|
'group_members': group_members
|
|
2088
2298
|
}
|
|
2089
|
-
return self.fetch_results(f"/api/s/{self._site}/rest/firewallgroup", payload=payload,
|
|
2299
|
+
return self.fetch_results(f"/api/s/{self._site}/rest/firewallgroup", payload=payload, proxy_type='network')
|
|
2090
2300
|
|
|
2091
2301
|
def edit_firewallgroup(self, group_id: str, site_id: str, group_name: str, group_type: str, group_members: list = []) -> bool:
|
|
2092
2302
|
"""Edit an existing firewall group"""
|
|
@@ -2105,7 +2315,7 @@ class Client:
|
|
|
2105
2315
|
return self.fetch_results(
|
|
2106
2316
|
f"/api/s/{self._site}/rest/firewallgroup/{group_id.strip()}",
|
|
2107
2317
|
payload=payload,
|
|
2108
|
-
|
|
2318
|
+
proxy_type='network'
|
|
2109
2319
|
)
|
|
2110
2320
|
|
|
2111
2321
|
def delete_firewallgroup(self, group_id: str) -> bool:
|
|
@@ -2114,7 +2324,7 @@ class Client:
|
|
|
2114
2324
|
self._curl_method = "GET"
|
|
2115
2325
|
|
|
2116
2326
|
def list_firewallrules(self):
|
|
2117
|
-
return self.fetch_results(f"/api/s/{self._site}/rest/firewallrule")
|
|
2327
|
+
return self.fetch_results(f"/api/s/{self._site}/rest/firewallrule", proxy_type='network')
|
|
2118
2328
|
|
|
2119
2329
|
def list_routing(self, route_id: str = ''):
|
|
2120
2330
|
return self.fetch_results(f"/api/s/{self._site}/rest/routing/{route_id.strip()}")
|
|
@@ -2297,62 +2507,122 @@ class Client:
|
|
|
2297
2507
|
payload = None,
|
|
2298
2508
|
boolean: bool = False,
|
|
2299
2509
|
login_required: bool = False,
|
|
2300
|
-
|
|
2510
|
+
proxy_type: str = 'network',
|
|
2511
|
+
auto_fallback: bool = True
|
|
2301
2512
|
):
|
|
2513
|
+
"""Fetch results from UniFi API with automatic v1/v2 firmware detection.
|
|
2514
|
+
|
|
2515
|
+
Args:
|
|
2516
|
+
path: API endpoint path
|
|
2517
|
+
payload: Request payload (optional)
|
|
2518
|
+
boolean: If True, return boolean instead of data
|
|
2519
|
+
login_required: If True, raise if not logged in
|
|
2520
|
+
proxy_type: Proxy category for UniFi OS devices:
|
|
2521
|
+
- 'network': /proxy/network prefix (v1/v2 fallback supported)
|
|
2522
|
+
- 'users': /proxy/users prefix (v2 only)
|
|
2523
|
+
- 'protect': /proxy/protect prefix (v2 only)
|
|
2524
|
+
- None: No prefix, direct /api/ path (v2 only)
|
|
2525
|
+
auto_fallback: If True, automatically fallback from v2 to v1 on JSON decode error
|
|
2526
|
+
(only applies when proxy_type='network')
|
|
2527
|
+
|
|
2528
|
+
Returns:
|
|
2529
|
+
API response data, or boolean if boolean=True
|
|
2530
|
+
"""
|
|
2302
2531
|
if login_required and not self._is_loggedin:
|
|
2303
2532
|
raise LoginRequiredException()
|
|
2304
2533
|
|
|
2305
|
-
|
|
2306
|
-
|
|
2534
|
+
# Use cached firmware version if known (only affects network endpoints)
|
|
2535
|
+
if proxy_type == 'network':
|
|
2536
|
+
if self._firmware_version == 'v1':
|
|
2537
|
+
proxy_type = None # v1 doesn't use /proxy/network prefix
|
|
2538
|
+
# v2 keeps proxy_type='network'
|
|
2539
|
+
|
|
2540
|
+
# Track the proxy_type we're using for caching
|
|
2541
|
+
used_proxy_type = proxy_type
|
|
2307
2542
|
|
|
2308
|
-
|
|
2309
|
-
|
|
2543
|
+
try:
|
|
2544
|
+
self._last_results_raw = self.exec_curl(path, payload, proxy_type)
|
|
2545
|
+
self._last_results_raw = self._last_results_raw.decode()
|
|
2546
|
+
|
|
2547
|
+
if isinstance(self._last_results_raw, str):
|
|
2310
2548
|
response = json.loads(self._last_results_raw)
|
|
2311
2549
|
print("Fetched response:", response) if self.debug else None
|
|
2312
|
-
|
|
2313
|
-
|
|
2314
|
-
|
|
2315
|
-
|
|
2316
|
-
|
|
2317
|
-
|
|
2318
|
-
|
|
2319
|
-
|
|
2320
|
-
|
|
2321
|
-
|
|
2322
|
-
|
|
2550
|
+
|
|
2551
|
+
# Cache successful firmware version (only if not already cached)
|
|
2552
|
+
# If any proxy path works, we're on v2 (UniFi OS)
|
|
2553
|
+
if self._firmware_version is None and used_proxy_type is not None:
|
|
2554
|
+
self._firmware_version = 'v2'
|
|
2555
|
+
if self.debug:
|
|
2556
|
+
print(f"Detected firmware version: {self._firmware_version}")
|
|
2557
|
+
|
|
2558
|
+
if isinstance(response, dict) and 'meta' in response:
|
|
2559
|
+
if response['meta'].get('rc') == 'ok':
|
|
2560
|
+
self._last_error_message = ''
|
|
2561
|
+
if 'data' in response:
|
|
2562
|
+
if isinstance(response['data'], list):
|
|
2563
|
+
print("Response inside fetch_results():", response) if self.debug else None
|
|
2564
|
+
print("Data:", response.get("data")) if self.debug else None
|
|
2565
|
+
return response['data'] if not boolean else True
|
|
2566
|
+
return [response['data']] if not boolean else True
|
|
2567
|
+
return True
|
|
2568
|
+
elif response['meta'].get('rc') == 'error':
|
|
2569
|
+
self._last_error_message = 'An unknown error was returned by the controller'
|
|
2570
|
+
raise Exception(f"Error message: {self._last_error_message} - {response}")
|
|
2571
|
+
|
|
2572
|
+
if path.startswith('/v2/api/'):
|
|
2573
|
+
if 'errorCode' in response:
|
|
2574
|
+
self._last_error_message = 'An unknown error was returned by an API v2 endpoint'
|
|
2575
|
+
raise Exception(f"Error code: {response['errorCode']}, message: {self._last_error_message}")
|
|
2576
|
+
return response
|
|
2577
|
+
|
|
2578
|
+
# Proxy API response (users, protect, etc.)
|
|
2579
|
+
# These have code: 1 (positive) for success, negative codes for errors
|
|
2580
|
+
if used_proxy_type is not None:
|
|
2581
|
+
if 'code' in response:
|
|
2582
|
+
if response['code'] < 0:
|
|
2583
|
+
# Negative code is an error
|
|
2584
|
+
self._last_error_message = response.get('msg', response.get('message', 'API error'))
|
|
2585
|
+
raise Exception(f"Error code: {response['code']}, message: {self._last_error_message}")
|
|
2586
|
+
# Positive code (1) means success - return data
|
|
2587
|
+
if 'data' in response:
|
|
2323
2588
|
return response['data'] if not boolean else True
|
|
2324
|
-
return
|
|
2589
|
+
return response if not boolean else True
|
|
2590
|
+
return response if not boolean else True
|
|
2591
|
+
|
|
2592
|
+
# UniFi OS system response (paths like /api/system, /api/backup, etc.)
|
|
2593
|
+
if path.startswith('/api/') and used_proxy_type is None:
|
|
2594
|
+
if 'code' in response:
|
|
2595
|
+
self._last_error_message = response.get('message', 'An unknown error was returned by a UniFi OS endpoint.')
|
|
2596
|
+
raise Exception(f"Error code: {response['code']}, message: {self._last_error_message}")
|
|
2597
|
+
if not boolean:
|
|
2598
|
+
return [response]
|
|
2325
2599
|
return True
|
|
2326
|
-
|
|
2327
|
-
|
|
2328
|
-
|
|
2329
|
-
|
|
2330
|
-
|
|
2331
|
-
|
|
2332
|
-
|
|
2333
|
-
|
|
2334
|
-
|
|
2335
|
-
|
|
2336
|
-
|
|
2337
|
-
|
|
2338
|
-
|
|
2339
|
-
|
|
2340
|
-
|
|
2341
|
-
raise Exception(f"Error code: {response['code']}, message: {self._last_error_message}")
|
|
2342
|
-
if not boolean:
|
|
2343
|
-
return [response]
|
|
2344
|
-
return True
|
|
2345
|
-
return False
|
|
2600
|
+
return False
|
|
2601
|
+
|
|
2602
|
+
except json.JSONDecodeError as e:
|
|
2603
|
+
# If proxy path failed with JSON decode error and auto_fallback enabled, try without proxy prefix
|
|
2604
|
+
if auto_fallback and used_proxy_type is not None and self._firmware_version is None:
|
|
2605
|
+
if self.debug:
|
|
2606
|
+
print(f"JSON decode error on v2 path, attempting v1 fallback...")
|
|
2607
|
+
self._firmware_version = 'v1' # Cache for future calls
|
|
2608
|
+
return self.fetch_results(
|
|
2609
|
+
path, payload, boolean, login_required,
|
|
2610
|
+
proxy_type=None, # v1 uses no proxy prefix
|
|
2611
|
+
auto_fallback=False # Don't recurse again
|
|
2612
|
+
)
|
|
2613
|
+
# Re-raise if fallback not applicable or already tried
|
|
2614
|
+
raise JsonDecodeException(f"Invalid JSON response from controller: {e}")
|
|
2346
2615
|
|
|
2347
2616
|
def fetch_results_boolean(
|
|
2348
2617
|
self,
|
|
2349
2618
|
path: str,
|
|
2350
2619
|
payload=None,
|
|
2351
2620
|
login_required: bool = True,
|
|
2352
|
-
|
|
2621
|
+
proxy_type: str = 'network',
|
|
2622
|
+
auto_fallback: bool = True
|
|
2353
2623
|
) -> bool:
|
|
2354
2624
|
"""Call fetch_results and always return a boolean"""
|
|
2355
|
-
return self.fetch_results(path, payload, True, login_required,
|
|
2625
|
+
return self.fetch_results(path, payload, True, login_required, proxy_type, auto_fallback)
|
|
2356
2626
|
|
|
2357
2627
|
def get_json_last_error(self) -> bool:
|
|
2358
2628
|
"""Raise exception with message matching last JSON decoding error."""
|
|
@@ -2382,15 +2652,26 @@ class Client:
|
|
|
2382
2652
|
|
|
2383
2653
|
return True
|
|
2384
2654
|
|
|
2385
|
-
def exec_curl(self, path: str, payload=None,
|
|
2655
|
+
def exec_curl(self, path: str, payload=None, proxy_type: str = None):
|
|
2656
|
+
"""Execute HTTP request to UniFi controller.
|
|
2657
|
+
|
|
2658
|
+
Args:
|
|
2659
|
+
path: API endpoint path (e.g., /api/s/{site}/stat/health)
|
|
2660
|
+
payload: Request payload (optional)
|
|
2661
|
+
proxy_type: Proxy category for UniFi OS devices:
|
|
2662
|
+
- 'network': /proxy/network prefix (for network management APIs)
|
|
2663
|
+
- 'users': /proxy/users prefix (for user management APIs)
|
|
2664
|
+
- 'protect': /proxy/protect prefix (for UniFi Protect APIs)
|
|
2665
|
+
- None: No prefix, direct /api/ path (for UniFi OS system APIs)
|
|
2666
|
+
"""
|
|
2386
2667
|
# 1) method sanity check
|
|
2387
2668
|
if self._curl_method not in self.CURL_METHODS_ALLOWED:
|
|
2388
2669
|
raise InvalidCurlMethodException()
|
|
2389
2670
|
|
|
2390
2671
|
# 2) build URL
|
|
2391
2672
|
url = f"{self._baseurl}{path}"
|
|
2392
|
-
if self._unifi_os and
|
|
2393
|
-
url = f"{self._baseurl}/proxy/
|
|
2673
|
+
if self._unifi_os and proxy_type:
|
|
2674
|
+
url = f"{self._baseurl}/proxy/{proxy_type}{path}"
|
|
2394
2675
|
|
|
2395
2676
|
# 3) build curl_options dict
|
|
2396
2677
|
"""
|
|
@@ -2467,7 +2748,7 @@ class Client:
|
|
|
2467
2748
|
if self.login():
|
|
2468
2749
|
if self.debug:
|
|
2469
2750
|
print("exec_curl: re-login succeeded, retrying exec_curl")
|
|
2470
|
-
return self.exec_curl(path, payload,
|
|
2751
|
+
return self.exec_curl(path, payload, proxy_type)
|
|
2471
2752
|
raise LoginFailedException('Cookie/Token expired and re-login failed.')
|
|
2472
2753
|
raise LoginFailedException('Login failed, check credentials.')
|
|
2473
2754
|
|
|
@@ -2479,6 +2760,7 @@ class Client:
|
|
|
2479
2760
|
print("Payload:", json_payload or '<empty>')
|
|
2480
2761
|
print("Response Code:", http_code)
|
|
2481
2762
|
print("Response Body:", resp.text)
|
|
2763
|
+
print(self._curl_method)
|
|
2482
2764
|
print("-----------------------------\n")
|
|
2483
2765
|
|
|
2484
2766
|
# 11) reset method and retry count
|
|
@@ -2581,7 +2863,131 @@ class Client:
|
|
|
2581
2863
|
for header in self.curl_headers:
|
|
2582
2864
|
if header.lower().find('x-csrf-token:') == -1:
|
|
2583
2865
|
filtered.append(header)
|
|
2584
|
-
self.
|
|
2866
|
+
self._curl_headers = filtered
|
|
2585
2867
|
|
|
2586
2868
|
# 8) Append the new header, using only ASCII
|
|
2587
|
-
self.
|
|
2869
|
+
self._curl_headers.append('x-csrf-token: %s' % csrf_token)
|
|
2870
|
+
|
|
2871
|
+
# =========================================================================
|
|
2872
|
+
# SSH Management Methods
|
|
2873
|
+
# =========================================================================
|
|
2874
|
+
|
|
2875
|
+
def set_ssh_password(self, password: str):
|
|
2876
|
+
"""Set the SSH password for UniFi OS.
|
|
2877
|
+
|
|
2878
|
+
Args:
|
|
2879
|
+
password: The SSH password to set.
|
|
2880
|
+
|
|
2881
|
+
Returns:
|
|
2882
|
+
True on success (200/204), False on failure.
|
|
2883
|
+
"""
|
|
2884
|
+
payload = {'password': password}
|
|
2885
|
+
try:
|
|
2886
|
+
self.fetch_results("/api/system/ssh/setpassword", payload=payload, proxy_type=None)
|
|
2887
|
+
return True
|
|
2888
|
+
except JsonDecodeException:
|
|
2889
|
+
# 204 No Content returns empty body - that's success
|
|
2890
|
+
return True
|
|
2891
|
+
except Exception:
|
|
2892
|
+
return False
|
|
2893
|
+
|
|
2894
|
+
def turn_on_ssh(self, password: str):
|
|
2895
|
+
"""Enable SSH on the UniFi controller.
|
|
2896
|
+
|
|
2897
|
+
Args:
|
|
2898
|
+
password: SSH password to set before enabling.
|
|
2899
|
+
|
|
2900
|
+
Returns:
|
|
2901
|
+
API response on success, error string on failure.
|
|
2902
|
+
"""
|
|
2903
|
+
result = self.set_ssh_password(password)
|
|
2904
|
+
if not result:
|
|
2905
|
+
return 'Failed to set password, try again later.'
|
|
2906
|
+
|
|
2907
|
+
self._curl_method = 'PATCH'
|
|
2908
|
+
return self.fetch_results("/api/system", payload={'ssh': {'enabled': True}}, proxy_type=None)
|
|
2909
|
+
|
|
2910
|
+
def turn_off_ssh(self):
|
|
2911
|
+
"""Disable SSH on the UniFi controller.
|
|
2912
|
+
|
|
2913
|
+
Returns:
|
|
2914
|
+
API response data.
|
|
2915
|
+
"""
|
|
2916
|
+
self._curl_method = 'PATCH'
|
|
2917
|
+
return self.fetch_results("/api/system", payload={'ssh': {'enabled': False}}, proxy_type=None)
|
|
2918
|
+
|
|
2919
|
+
# =========================================================================
|
|
2920
|
+
# Utility Methods
|
|
2921
|
+
# =========================================================================
|
|
2922
|
+
|
|
2923
|
+
def get_protect_devices(self):
|
|
2924
|
+
"""Get UniFi Protect device stats.
|
|
2925
|
+
|
|
2926
|
+
Returns:
|
|
2927
|
+
List of protect device stats, or empty list if Protect not installed.
|
|
2928
|
+
"""
|
|
2929
|
+
try:
|
|
2930
|
+
return self.fetch_results("/api/devices/stat/", proxy_type='protect')
|
|
2931
|
+
except JsonDecodeException:
|
|
2932
|
+
# UniFi Protect not installed returns empty/invalid response
|
|
2933
|
+
return []
|
|
2934
|
+
|
|
2935
|
+
def validate_email(self, email: str) -> bool:
|
|
2936
|
+
"""Validate email address format.
|
|
2937
|
+
|
|
2938
|
+
Args:
|
|
2939
|
+
email: Email address to validate.
|
|
2940
|
+
|
|
2941
|
+
Returns:
|
|
2942
|
+
True if valid, False otherwise.
|
|
2943
|
+
"""
|
|
2944
|
+
pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
|
|
2945
|
+
return bool(re.match(pattern, email))
|
|
2946
|
+
|
|
2947
|
+
# =========================================================================
|
|
2948
|
+
# Additional User Management Helpers
|
|
2949
|
+
# =========================================================================
|
|
2950
|
+
|
|
2951
|
+
def get_custom_role(self, network_permission: str, user_permission: str):
|
|
2952
|
+
"""Create a custom role for admin user creation.
|
|
2953
|
+
|
|
2954
|
+
This is a convenience method that builds the role payload from permission strings.
|
|
2955
|
+
Use create_custom_role() if you need to pass a raw payload.
|
|
2956
|
+
|
|
2957
|
+
Args:
|
|
2958
|
+
network_permission: One of 'admin', 'hotspotoperator', 'readonly', 'none'
|
|
2959
|
+
user_permission: One of 'admin', 'readonly', 'none'
|
|
2960
|
+
|
|
2961
|
+
Returns:
|
|
2962
|
+
API response with role data including unique_id.
|
|
2963
|
+
"""
|
|
2964
|
+
import random
|
|
2965
|
+
payload = {
|
|
2966
|
+
'is_private': True,
|
|
2967
|
+
'name': f'role_private_{random.randint(10000000, 99999999)}',
|
|
2968
|
+
'permission_resources': [],
|
|
2969
|
+
'permissions': []
|
|
2970
|
+
}
|
|
2971
|
+
if network_permission != 'none':
|
|
2972
|
+
payload['permissions'].append({'manifest_unique_key': 'network.management', 'formulas': [network_permission]})
|
|
2973
|
+
if user_permission != 'none':
|
|
2974
|
+
payload['permissions'].append({'manifest_unique_key': 'system.management.user', 'formulas': [user_permission]})
|
|
2975
|
+
|
|
2976
|
+
self._curl_method = 'POST'
|
|
2977
|
+
return self.fetch_results("/api/v2/custom_role", payload=payload, proxy_type='users')
|
|
2978
|
+
|
|
2979
|
+
def resend_invite(self, email: str):
|
|
2980
|
+
"""Resend invitation to a user.
|
|
2981
|
+
|
|
2982
|
+
Args:
|
|
2983
|
+
email: Email address to send invite to.
|
|
2984
|
+
|
|
2985
|
+
Returns:
|
|
2986
|
+
API response data, or error string iwf invalid email.
|
|
2987
|
+
"""
|
|
2988
|
+
if not self.validate_email(email):
|
|
2989
|
+
return "Please enter a valid email"
|
|
2990
|
+
|
|
2991
|
+
payload = {'email': email, 'is_resend': True}
|
|
2992
|
+
self._curl_method = 'POST'
|
|
2993
|
+
return self.fetch_results("/api/v2/users/invite_add", payload=payload, proxy_type='users')
|
|
@@ -5,7 +5,7 @@ with open("README.md", "r") as fh:
|
|
|
5
5
|
|
|
6
6
|
setuptools.setup(
|
|
7
7
|
name="python_unifi_client",
|
|
8
|
-
version="1.2.
|
|
8
|
+
version="1.2.8",
|
|
9
9
|
author="Michael Lapinski",
|
|
10
10
|
author_email="michaellapinski787@gmail.com",
|
|
11
11
|
descripton="A python version of a github Art-of-Wifi/Unifi-API-Client",
|
|
File without changes
|
|
File without changes
|
{python_unifi_client-1.2.7 → python_unifi_client-1.2.8}/python_unifi_client.egg-info/SOURCES.txt
RENAMED
|
File without changes
|
|
File without changes
|
{python_unifi_client-1.2.7 → python_unifi_client-1.2.8}/python_unifi_client.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|