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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python_unifi_client
3
- Version: 1.2.7
3
+ Version: 1.2.8
4
4
  Home-page: https://github.com/compdat-llc/unifi-client-python
5
5
  Author: Michael Lapinski
6
6
  Author-email: michaellapinski787@gmail.com
@@ -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://32.218.193.188', site='default', version='8.0.28', ssl_verify=False, unificookie_name='unificookie', debug=False):
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", prefix_path=True)
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}", prefix_path=True)
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", prefix_path=True)
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, prefix_path=True)
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, prefix_path=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}", prefix_path=True)
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}", prefix_path=True)
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}", prefix_path=True)
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, prefix_path=True)
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", prefix_path=True)
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/user", payload=payload)
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, prefix_path=True)
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, prefix_path=True)
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", prefix_path=True)
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 self._last_response_code == 200
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"/proxy/network/api/s/{self._site}/cmd/backup", payload=payload, prefix_path=False, boolean=False)
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, prefix_path=True)
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()}", prefix_path=True)
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, prefix_path=True)
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
- prefix_path=True
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
- prefix_path: bool = False
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
- self._last_results_raw = self.exec_curl(path, payload, prefix_path)
2306
- self._last_results_raw = self._last_results_raw.decode()
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
- if isinstance(self._last_results_raw, str):
2309
- try:
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
- except:
2313
- self._get_json_last_error()
2314
- raise
2315
-
2316
- if isinstance(response, dict) and 'meta' in response:
2317
- if response['meta'].get('rc') == 'ok':
2318
- self._last_error_message = ''
2319
- if 'data' in response:
2320
- if isinstance(response['data'], list):
2321
- print("Response inside fetch_results():", response) if self.debug else None
2322
- print("Data:", response.get("data")) if self.debug else None
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 [response['data']] if not boolean else True
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
- elif response['meta'].get('rc') == 'error':
2327
- self._last_error_message = 'An unknown error was returned by the controller'
2328
-
2329
- raise Exception(f"Error message: {self._last_error_message}")
2330
-
2331
- if path.startswith('/v2/api/'):
2332
- if 'errorCode' in response:
2333
- self._last_error_message = 'AN unkown error was returned by an API v2 endpoind'
2334
- raise Exception(f"Error code: {response['errorCode']}, message: {self.last_error_message}")
2335
- return response
2336
-
2337
- # UniFi OS response
2338
- if path.startswith('/api/'):
2339
- if 'code' in response:
2340
- self._last_error_message = response.get('message', 'An unknown error was returned by a UniFi OS endpoint.')
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
- prefix_path: bool = True
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, prefix_path)
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, prefix_path: bool = True):
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 prefix_path:
2393
- url = f"{self._baseurl}/proxy/network{path}"
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, prefix_path)
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.curl_headers = filtered
2866
+ self._curl_headers = filtered
2585
2867
 
2586
2868
  # 8) Append the new header, using only ASCII
2587
- self.curl_headers.append('x-csrf-token: %s' % csrf_token)
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')
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python_unifi_client
3
- Version: 1.2.7
3
+ Version: 1.2.8
4
4
  Home-page: https://github.com/compdat-llc/unifi-client-python
5
5
  Author: Michael Lapinski
6
6
  Author-email: michaellapinski787@gmail.com
@@ -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.7",
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",