uiprotect 7.18.1__tar.gz → 7.20.0__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.

Potentially problematic release.


This version of uiprotect might be problematic. Click here for more details.

Files changed (38) hide show
  1. {uiprotect-7.18.1 → uiprotect-7.20.0}/PKG-INFO +1 -1
  2. {uiprotect-7.18.1 → uiprotect-7.20.0}/pyproject.toml +7 -2
  3. {uiprotect-7.18.1 → uiprotect-7.20.0}/src/uiprotect/api.py +137 -3
  4. {uiprotect-7.18.1 → uiprotect-7.20.0}/src/uiprotect/cli/__init__.py +3 -5
  5. {uiprotect-7.18.1 → uiprotect-7.20.0}/src/uiprotect/cli/cameras.py +115 -0
  6. {uiprotect-7.18.1 → uiprotect-7.20.0}/src/uiprotect/data/bootstrap.py +2 -2
  7. {uiprotect-7.18.1 → uiprotect-7.20.0}/src/uiprotect/data/devices.py +31 -6
  8. {uiprotect-7.18.1 → uiprotect-7.20.0}/src/uiprotect/websocket.py +1 -1
  9. {uiprotect-7.18.1 → uiprotect-7.20.0}/LICENSE +0 -0
  10. {uiprotect-7.18.1 → uiprotect-7.20.0}/README.md +0 -0
  11. {uiprotect-7.18.1 → uiprotect-7.20.0}/src/uiprotect/__init__.py +0 -0
  12. {uiprotect-7.18.1 → uiprotect-7.20.0}/src/uiprotect/__main__.py +0 -0
  13. {uiprotect-7.18.1 → uiprotect-7.20.0}/src/uiprotect/_compat.py +0 -0
  14. {uiprotect-7.18.1 → uiprotect-7.20.0}/src/uiprotect/cli/aiports.py +0 -0
  15. {uiprotect-7.18.1 → uiprotect-7.20.0}/src/uiprotect/cli/backup.py +0 -0
  16. {uiprotect-7.18.1 → uiprotect-7.20.0}/src/uiprotect/cli/base.py +0 -0
  17. {uiprotect-7.18.1 → uiprotect-7.20.0}/src/uiprotect/cli/chimes.py +0 -0
  18. {uiprotect-7.18.1 → uiprotect-7.20.0}/src/uiprotect/cli/doorlocks.py +0 -0
  19. {uiprotect-7.18.1 → uiprotect-7.20.0}/src/uiprotect/cli/events.py +0 -0
  20. {uiprotect-7.18.1 → uiprotect-7.20.0}/src/uiprotect/cli/lights.py +0 -0
  21. {uiprotect-7.18.1 → uiprotect-7.20.0}/src/uiprotect/cli/liveviews.py +0 -0
  22. {uiprotect-7.18.1 → uiprotect-7.20.0}/src/uiprotect/cli/nvr.py +0 -0
  23. {uiprotect-7.18.1 → uiprotect-7.20.0}/src/uiprotect/cli/sensors.py +0 -0
  24. {uiprotect-7.18.1 → uiprotect-7.20.0}/src/uiprotect/cli/viewers.py +0 -0
  25. {uiprotect-7.18.1 → uiprotect-7.20.0}/src/uiprotect/data/__init__.py +0 -0
  26. {uiprotect-7.18.1 → uiprotect-7.20.0}/src/uiprotect/data/base.py +0 -0
  27. {uiprotect-7.18.1 → uiprotect-7.20.0}/src/uiprotect/data/convert.py +0 -0
  28. {uiprotect-7.18.1 → uiprotect-7.20.0}/src/uiprotect/data/nvr.py +0 -0
  29. {uiprotect-7.18.1 → uiprotect-7.20.0}/src/uiprotect/data/types.py +0 -0
  30. {uiprotect-7.18.1 → uiprotect-7.20.0}/src/uiprotect/data/user.py +0 -0
  31. {uiprotect-7.18.1 → uiprotect-7.20.0}/src/uiprotect/data/websocket.py +0 -0
  32. {uiprotect-7.18.1 → uiprotect-7.20.0}/src/uiprotect/exceptions.py +0 -0
  33. {uiprotect-7.18.1 → uiprotect-7.20.0}/src/uiprotect/py.typed +0 -0
  34. {uiprotect-7.18.1 → uiprotect-7.20.0}/src/uiprotect/release_cache.json +0 -0
  35. {uiprotect-7.18.1 → uiprotect-7.20.0}/src/uiprotect/stream.py +0 -0
  36. {uiprotect-7.18.1 → uiprotect-7.20.0}/src/uiprotect/test_util/__init__.py +0 -0
  37. {uiprotect-7.18.1 → uiprotect-7.20.0}/src/uiprotect/test_util/anonymize.py +0 -0
  38. {uiprotect-7.18.1 → uiprotect-7.20.0}/src/uiprotect/utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: uiprotect
3
- Version: 7.18.1
3
+ Version: 7.20.0
4
4
  Summary: Python API for Unifi Protect (Unofficial)
5
5
  License: MIT
6
6
  Author: UI Protect Maintainers
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "uiprotect"
3
- version = "7.18.1"
3
+ version = "7.20.0"
4
4
  license = "MIT"
5
5
  description = "Python API for Unifi Protect (Unofficial)"
6
6
  authors = [{ name = "UI Protect Maintainers", email = "ui@koston.org" }]
@@ -55,7 +55,7 @@ pytest = ">=7,<9"
55
55
  pytest-cov = ">=3,<7"
56
56
  aiosqlite = ">=0.20.0"
57
57
  asttokens = ">=2.4.1,<4.0.0"
58
- pytest-asyncio = ">=0.23.7,<1.1.0"
58
+ pytest-asyncio = ">=0.23.7,<1.2.0"
59
59
  pytest-benchmark = ">=4,<6"
60
60
  pytest-sugar = "^1.0.0"
61
61
  pytest-timeout = "^2.4.0"
@@ -150,6 +150,7 @@ ignore = [
150
150
  "UP007", # typer needs Optional syntax
151
151
  "UP038", # Use `X | Y` in `isinstance` is slower
152
152
  "S603", # check for execution of untrusted input
153
+ "PERF203", # too many to fix right now
153
154
  ]
154
155
  select = [
155
156
  "B", # flake8-bugbear
@@ -157,11 +158,15 @@ select = [
157
158
  "C4", # flake8-comprehensions
158
159
  "S", # flake8-bandit
159
160
  "F", # pyflake
161
+ "FURB", # refurb rules
160
162
  "E", # pycodestyle
161
163
  "W", # pycodestyle
162
164
  "UP", # pyupgrade
163
165
  "I", # isort
166
+ "PERF", # performance
167
+ "RET", # return rules
164
168
  "RUF", # ruff specific
169
+ "SIM", # simplify
165
170
  ]
166
171
 
167
172
  [tool.ruff.lint.per-file-ignores]
@@ -27,6 +27,7 @@ from aiohttp import CookieJar, client_exceptions
27
27
  from platformdirs import user_cache_dir, user_config_dir
28
28
  from yarl import URL
29
29
 
30
+ from uiprotect.data.base import ProtectBaseObject
30
31
  from uiprotect.data.convert import list_from_unifi_list
31
32
  from uiprotect.data.nvr import MetaInfo
32
33
  from uiprotect.data.user import Keyring, Keyrings, UlpUser, UlpUsers
@@ -157,6 +158,46 @@ def get_user_hash(host: str, username: str) -> str:
157
158
  return session.hexdigest()
158
159
 
159
160
 
161
+ class RTSPSStreams(ProtectBaseObject):
162
+ """RTSPS stream URLs for a camera."""
163
+
164
+ model_config = {"extra": "allow"}
165
+ # Intentionally no variables like 'high', 'medium', 'low' are defined here.
166
+ # The API naming appears inconsistent - what's called "quality" might actually be "channels".
167
+ # Besides standard qualities (high/medium/low), there are special cases like "package" for doorbells
168
+ # and unclear implementation for 180° cameras with dual sensors. Dynamic handling via __pydantic_extra__ is safer.
169
+
170
+ def get_stream_url(self, quality: str) -> str | None:
171
+ """Get stream URL for a specific quality level."""
172
+ return getattr(self, quality, None)
173
+
174
+ def get_available_stream_qualities(self) -> list[str]:
175
+ """Get list of available RTSPS stream quality levels (including inactive ones with null values)."""
176
+ if self.__pydantic_extra__ is None:
177
+ return []
178
+ return list(self.__pydantic_extra__.keys())
179
+
180
+ def get_active_stream_qualities(self) -> list[str]:
181
+ """Get list of currently active RTSPS stream quality levels (only those with stream URLs)."""
182
+ if self.__pydantic_extra__ is None:
183
+ return []
184
+ return [
185
+ key
186
+ for key, value in self.__pydantic_extra__.items()
187
+ if isinstance(value, str) and value is not None
188
+ ]
189
+
190
+ def get_inactive_stream_qualities(self) -> list[str]:
191
+ """Get list of inactive RTSPS stream quality levels (supported but not currently active)."""
192
+ if self.__pydantic_extra__ is None:
193
+ return []
194
+ return [
195
+ key
196
+ for key, value in self.__pydantic_extra__.items()
197
+ if not (isinstance(value, str) and value is not None)
198
+ ]
199
+
200
+
160
201
  class BaseApiClient:
161
202
  _host: str
162
203
  _port: int
@@ -443,7 +484,8 @@ class BaseApiClient:
443
484
  )
444
485
 
445
486
  try:
446
- if response.status != 200:
487
+ # Check for successful status codes (2xx range)
488
+ if not (200 <= response.status < 300):
447
489
  await self._raise_for_status(response, raise_exception)
448
490
  return None
449
491
 
@@ -466,16 +508,20 @@ class BaseApiClient:
466
508
  msg = "Request failed: %s - Status: %s - Reason: %s"
467
509
  status = response.status
468
510
 
511
+ # Success status codes (2xx) should not raise exceptions
512
+ if 200 <= status < 300:
513
+ return
514
+
469
515
  if raise_exception:
470
516
  if status in {
471
517
  HTTPStatus.UNAUTHORIZED.value,
472
518
  HTTPStatus.FORBIDDEN.value,
473
519
  }:
474
520
  raise NotAuthorized(msg % (url, status, reason))
475
- elif status == HTTPStatus.TOO_MANY_REQUESTS.value:
521
+ if status == HTTPStatus.TOO_MANY_REQUESTS.value:
476
522
  _LOGGER.debug("Too many requests - Login is rate limited: %s", response)
477
523
  raise NvrError(msg % (url, status, reason))
478
- elif (
524
+ if (
479
525
  status >= HTTPStatus.BAD_REQUEST.value
480
526
  and status < HTTPStatus.INTERNAL_SERVER_ERROR.value
481
527
  ):
@@ -1533,6 +1579,83 @@ class ProtectApiClient(BaseApiClient):
1533
1579
  params={"highQuality": pybool_to_json_bool(high_quality)},
1534
1580
  )
1535
1581
 
1582
+ async def create_camera_rtsps_streams(
1583
+ self,
1584
+ camera_id: str,
1585
+ qualities: list[str] | str,
1586
+ ) -> RTSPSStreams | None:
1587
+ """Creates RTSPS streams for a camera using public API."""
1588
+ if isinstance(qualities, str):
1589
+ qualities = [qualities]
1590
+
1591
+ data = {"qualities": qualities}
1592
+ response = await self.api_request_raw(
1593
+ public_api=True,
1594
+ url=f"/v1/cameras/{camera_id}/rtsps-stream",
1595
+ method="POST",
1596
+ json=data,
1597
+ )
1598
+
1599
+ if response is None:
1600
+ return None
1601
+
1602
+ try:
1603
+ response_json = orjson.loads(response)
1604
+ return RTSPSStreams(**response_json)
1605
+ except (orjson.JSONDecodeError, TypeError) as ex:
1606
+ _LOGGER.error(
1607
+ "Could not decode JSON response for create RTSPS streams (camera %s): %s",
1608
+ camera_id,
1609
+ ex,
1610
+ )
1611
+ return None
1612
+
1613
+ async def get_camera_rtsps_streams(
1614
+ self,
1615
+ camera_id: str,
1616
+ ) -> RTSPSStreams | None:
1617
+ """Gets existing RTSPS streams for a camera using public API."""
1618
+ response = await self.api_request_raw(
1619
+ public_api=True,
1620
+ url=f"/v1/cameras/{camera_id}/rtsps-stream",
1621
+ method="GET",
1622
+ )
1623
+
1624
+ if response is None:
1625
+ return None
1626
+
1627
+ try:
1628
+ response_json = orjson.loads(response)
1629
+ return RTSPSStreams(**response_json)
1630
+ except (orjson.JSONDecodeError, TypeError) as ex:
1631
+ _LOGGER.error(
1632
+ "Could not decode JSON response for get RTSPS streams (camera %s): %s",
1633
+ camera_id,
1634
+ ex,
1635
+ )
1636
+ return None
1637
+
1638
+ async def delete_camera_rtsps_streams(
1639
+ self,
1640
+ camera_id: str,
1641
+ qualities: list[str] | str,
1642
+ ) -> bool:
1643
+ """Deletes RTSPS streams for a camera using public API."""
1644
+ if isinstance(qualities, str):
1645
+ qualities = [qualities]
1646
+
1647
+ # Build query parameters for qualities
1648
+ params = [("qualities", quality) for quality in qualities]
1649
+
1650
+ response = await self.api_request_raw(
1651
+ public_api=True,
1652
+ url=f"/v1/cameras/{camera_id}/rtsps-stream",
1653
+ method="DELETE",
1654
+ params=params,
1655
+ )
1656
+
1657
+ return response is not None
1658
+
1536
1659
  async def get_package_camera_snapshot(
1537
1660
  self,
1538
1661
  camera_id: str,
@@ -2120,6 +2243,17 @@ class ProtectApiClient(BaseApiClient):
2120
2243
 
2121
2244
  return response["data"]["full_api_key"]
2122
2245
 
2246
+ def set_api_key(self, api_key: str) -> None:
2247
+ """Set the API key for the NVR."""
2248
+ if not api_key:
2249
+ raise BadRequest("API key cannot be empty")
2250
+
2251
+ self._api_key = api_key
2252
+
2253
+ def is_api_key_set(self) -> bool:
2254
+ """Check if the API key is set."""
2255
+ return bool(self._api_key)
2256
+
2123
2257
  async def get_meta_info(self) -> MetaInfo:
2124
2258
  """Get metadata about the NVR."""
2125
2259
  data = await self.api_request(
@@ -61,12 +61,10 @@ OPTION_PASSWORD = typer.Option(
61
61
  envvar="UFP_PASSWORD",
62
62
  )
63
63
  OPTION_API_KEY = typer.Option(
64
- ...,
64
+ None,
65
65
  "--api-key",
66
66
  "-k",
67
- help="UniFi Protect API key",
68
- prompt=True,
69
- hide_input=True,
67
+ help="UniFi Protect API key (required for public API operations)",
70
68
  envvar="UFP_API_KEY",
71
69
  )
72
70
  OPTION_ADDRESS = typer.Option(
@@ -149,7 +147,7 @@ def main(
149
147
  ctx: typer.Context,
150
148
  username: str = OPTION_USERNAME,
151
149
  password: str = OPTION_PASSWORD,
152
- api_key: str = OPTION_API_KEY,
150
+ api_key: str | None = OPTION_API_KEY,
153
151
  address: str = OPTION_ADDRESS,
154
152
  port: int = OPTION_PORT,
155
153
  verify: bool = OPTION_VERIFY,
@@ -572,3 +572,118 @@ def set_lcd_text(
572
572
  obj: d.Camera = ctx.obj.device
573
573
 
574
574
  base.run(ctx, obj.set_lcd_text(text_type, text, reset_at))
575
+
576
+
577
+ @app.command()
578
+ def create_rtsps_streams(
579
+ ctx: typer.Context,
580
+ qualities: list[str] = typer.Argument(
581
+ ...,
582
+ help="List of stream qualities to create (e.g., high medium low)",
583
+ ),
584
+ ) -> None:
585
+ """
586
+ Creates RTSPS streams for camera.
587
+
588
+ Available qualities are typically: high, medium, low, ultra.
589
+ Requires API key authentication and public API access.
590
+ """
591
+ base.require_device_id(ctx)
592
+ obj: d.Camera = ctx.obj.device
593
+
594
+ async def create_streams() -> None:
595
+ try:
596
+ result = await obj.create_rtsps_streams(qualities)
597
+ if result is None:
598
+ typer.secho("Failed to create RTSPS streams", fg="red")
599
+ raise typer.Exit(1)
600
+
601
+ if ctx.obj.output_format == base.OutputFormatEnum.JSON:
602
+ stream_data = {
603
+ quality: result.get_stream_url(quality)
604
+ for quality in result.get_available_stream_qualities()
605
+ }
606
+ base.json_output(stream_data)
607
+ else:
608
+ for quality in result.get_available_stream_qualities():
609
+ url = result.get_stream_url(quality)
610
+ typer.echo(f"{quality:10}\t{url}")
611
+ except Exception as e:
612
+ typer.secho(f"Error creating RTSPS streams: {e}", fg="red")
613
+ raise typer.Exit(1) from e
614
+
615
+ base.run(ctx, create_streams())
616
+
617
+
618
+ @app.command()
619
+ def get_rtsps_streams(ctx: typer.Context) -> None:
620
+ """
621
+ Gets existing RTSPS streams for camera.
622
+
623
+ Requires API key authentication and public API access.
624
+ """
625
+ base.require_device_id(ctx)
626
+ obj: d.Camera = ctx.obj.device
627
+
628
+ async def get_streams() -> None:
629
+ try:
630
+ result = await obj.get_rtsps_streams()
631
+ if result is None:
632
+ typer.secho("No RTSPS streams found or failed to retrieve", fg="yellow")
633
+ return
634
+
635
+ if ctx.obj.output_format == base.OutputFormatEnum.JSON:
636
+ stream_data = {
637
+ quality: result.get_stream_url(quality)
638
+ for quality in result.get_available_stream_qualities()
639
+ }
640
+ base.json_output(stream_data)
641
+ else:
642
+ available_qualities = result.get_available_stream_qualities()
643
+ if not available_qualities:
644
+ typer.echo("No RTSPS streams available")
645
+ else:
646
+ for quality in available_qualities:
647
+ url = result.get_stream_url(quality)
648
+ typer.echo(f"{quality:10}\t{url}")
649
+ except Exception as e:
650
+ typer.secho(f"Error getting RTSPS streams: {e}", fg="red")
651
+ raise typer.Exit(1) from e
652
+
653
+ base.run(ctx, get_streams())
654
+
655
+
656
+ @app.command()
657
+ def delete_rtsps_streams(
658
+ ctx: typer.Context,
659
+ qualities: list[str] = typer.Argument(
660
+ ...,
661
+ help="List of stream qualities to delete (e.g., high medium low)",
662
+ ),
663
+ ) -> None:
664
+ """
665
+ Deletes RTSPS streams for camera.
666
+
667
+ Requires API key authentication and public API access.
668
+ """
669
+ base.require_device_id(ctx)
670
+ obj: d.Camera = ctx.obj.device
671
+
672
+ async def delete_streams() -> None:
673
+ try:
674
+ result = await obj.delete_rtsps_streams(qualities)
675
+ if result:
676
+ typer.secho(
677
+ f"Successfully deleted RTSPS streams: {', '.join(qualities)}",
678
+ fg="green",
679
+ )
680
+ else:
681
+ typer.secho(
682
+ f"Failed to delete RTSPS streams: {', '.join(qualities)}", fg="red"
683
+ )
684
+ raise typer.Exit(1)
685
+ except Exception as e:
686
+ typer.secho(f"Error deleting RTSPS streams: {e}", fg="red")
687
+ raise typer.Exit(1) from e
688
+
689
+ base.run(ctx, delete_streams())
@@ -420,7 +420,7 @@ class Bootstrap(ProtectBaseObject):
420
420
  changed_data=add_obj.model_dump(),
421
421
  new_obj=add_obj,
422
422
  )
423
- elif action_type == "remove":
423
+ if action_type == "remove":
424
424
  to_remove = obj_from_bootstrap.by_id(action_id)
425
425
  if to_remove is None:
426
426
  return None
@@ -431,7 +431,7 @@ class Bootstrap(ProtectBaseObject):
431
431
  changed_data={},
432
432
  old_obj=to_remove,
433
433
  )
434
- elif action_type == "update":
434
+ if action_type == "update":
435
435
  updated_obj = obj_from_bootstrap.by_id(action_id)
436
436
  if updated_obj is None:
437
437
  return None
@@ -80,6 +80,7 @@ from .types import (
80
80
  from .user import User
81
81
 
82
82
  if TYPE_CHECKING:
83
+ from ..api import RTSPSStreams
83
84
  from .nvr import Event, Liveview
84
85
 
85
86
  PRIVACY_ZONE_NAME = "pyufp_privacy_zone"
@@ -1150,12 +1151,13 @@ class Camera(ProtectMotionDeviceModel):
1150
1151
 
1151
1152
  def update_from_dict(self, data: dict[str, Any]) -> Camera:
1152
1153
  # a message in the past is actually a signal to wipe the message
1153
- if (reset_at := data.get("lcd_message", {}).get("reset_at")) is not None:
1154
- if utc_now() > from_js_time(reset_at):
1155
- # Important: Make a copy of the data before modifying it
1156
- # since unifi_dict_to_dict will otherwise report incorrect changes
1157
- data = data.copy()
1158
- data["lcd_message"] = None
1154
+ if (
1155
+ reset_at := data.get("lcd_message", {}).get("reset_at")
1156
+ ) is not None and utc_now() > from_js_time(reset_at):
1157
+ # Important: Make a copy of the data before modifying it
1158
+ # since unifi_dict_to_dict will otherwise report incorrect changes
1159
+ data = data.copy()
1160
+ data["lcd_message"] = None
1159
1161
 
1160
1162
  return super().update_from_dict(data)
1161
1163
 
@@ -2101,6 +2103,29 @@ class Camera(ProtectMotionDeviceModel):
2101
2103
  camera_id=self.id, high_quality=high_quality
2102
2104
  )
2103
2105
 
2106
+ async def create_rtsps_streams(
2107
+ self, qualities: list[str] | str
2108
+ ) -> RTSPSStreams | None:
2109
+ """Creates RTSPS streams for camera using public API."""
2110
+ if self._api._api_key is None:
2111
+ raise NotAuthorized("Cannot create RTSPS streams without an API key.")
2112
+
2113
+ return await self._api.create_camera_rtsps_streams(self.id, qualities)
2114
+
2115
+ async def get_rtsps_streams(self) -> RTSPSStreams | None:
2116
+ """Gets existing RTSPS streams for camera using public API."""
2117
+ if self._api._api_key is None:
2118
+ raise NotAuthorized("Cannot get RTSPS streams without an API key.")
2119
+
2120
+ return await self._api.get_camera_rtsps_streams(self.id)
2121
+
2122
+ async def delete_rtsps_streams(self, qualities: list[str] | str) -> bool:
2123
+ """Deletes RTSPS streams for camera using public API."""
2124
+ if self._api._api_key is None:
2125
+ raise NotAuthorized("Cannot delete RTSPS streams without an API key.")
2126
+
2127
+ return await self._api.delete_camera_rtsps_streams(self.id, qualities)
2128
+
2104
2129
  async def get_package_snapshot(
2105
2130
  self,
2106
2131
  width: int | None = None,
@@ -137,7 +137,7 @@ class Websocket:
137
137
  if msg_type is WSMsgType.ERROR:
138
138
  _LOGGER.exception("Error from Websocket: %s", msg.data)
139
139
  break
140
- elif msg_type in _CLOSE_MESSAGE_TYPES:
140
+ if msg_type in _CLOSE_MESSAGE_TYPES:
141
141
  _LOGGER.debug("Websocket closed: %s", msg)
142
142
  break
143
143
 
File without changes
File without changes