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.
- {uiprotect-7.18.1 → uiprotect-7.20.0}/PKG-INFO +1 -1
- {uiprotect-7.18.1 → uiprotect-7.20.0}/pyproject.toml +7 -2
- {uiprotect-7.18.1 → uiprotect-7.20.0}/src/uiprotect/api.py +137 -3
- {uiprotect-7.18.1 → uiprotect-7.20.0}/src/uiprotect/cli/__init__.py +3 -5
- {uiprotect-7.18.1 → uiprotect-7.20.0}/src/uiprotect/cli/cameras.py +115 -0
- {uiprotect-7.18.1 → uiprotect-7.20.0}/src/uiprotect/data/bootstrap.py +2 -2
- {uiprotect-7.18.1 → uiprotect-7.20.0}/src/uiprotect/data/devices.py +31 -6
- {uiprotect-7.18.1 → uiprotect-7.20.0}/src/uiprotect/websocket.py +1 -1
- {uiprotect-7.18.1 → uiprotect-7.20.0}/LICENSE +0 -0
- {uiprotect-7.18.1 → uiprotect-7.20.0}/README.md +0 -0
- {uiprotect-7.18.1 → uiprotect-7.20.0}/src/uiprotect/__init__.py +0 -0
- {uiprotect-7.18.1 → uiprotect-7.20.0}/src/uiprotect/__main__.py +0 -0
- {uiprotect-7.18.1 → uiprotect-7.20.0}/src/uiprotect/_compat.py +0 -0
- {uiprotect-7.18.1 → uiprotect-7.20.0}/src/uiprotect/cli/aiports.py +0 -0
- {uiprotect-7.18.1 → uiprotect-7.20.0}/src/uiprotect/cli/backup.py +0 -0
- {uiprotect-7.18.1 → uiprotect-7.20.0}/src/uiprotect/cli/base.py +0 -0
- {uiprotect-7.18.1 → uiprotect-7.20.0}/src/uiprotect/cli/chimes.py +0 -0
- {uiprotect-7.18.1 → uiprotect-7.20.0}/src/uiprotect/cli/doorlocks.py +0 -0
- {uiprotect-7.18.1 → uiprotect-7.20.0}/src/uiprotect/cli/events.py +0 -0
- {uiprotect-7.18.1 → uiprotect-7.20.0}/src/uiprotect/cli/lights.py +0 -0
- {uiprotect-7.18.1 → uiprotect-7.20.0}/src/uiprotect/cli/liveviews.py +0 -0
- {uiprotect-7.18.1 → uiprotect-7.20.0}/src/uiprotect/cli/nvr.py +0 -0
- {uiprotect-7.18.1 → uiprotect-7.20.0}/src/uiprotect/cli/sensors.py +0 -0
- {uiprotect-7.18.1 → uiprotect-7.20.0}/src/uiprotect/cli/viewers.py +0 -0
- {uiprotect-7.18.1 → uiprotect-7.20.0}/src/uiprotect/data/__init__.py +0 -0
- {uiprotect-7.18.1 → uiprotect-7.20.0}/src/uiprotect/data/base.py +0 -0
- {uiprotect-7.18.1 → uiprotect-7.20.0}/src/uiprotect/data/convert.py +0 -0
- {uiprotect-7.18.1 → uiprotect-7.20.0}/src/uiprotect/data/nvr.py +0 -0
- {uiprotect-7.18.1 → uiprotect-7.20.0}/src/uiprotect/data/types.py +0 -0
- {uiprotect-7.18.1 → uiprotect-7.20.0}/src/uiprotect/data/user.py +0 -0
- {uiprotect-7.18.1 → uiprotect-7.20.0}/src/uiprotect/data/websocket.py +0 -0
- {uiprotect-7.18.1 → uiprotect-7.20.0}/src/uiprotect/exceptions.py +0 -0
- {uiprotect-7.18.1 → uiprotect-7.20.0}/src/uiprotect/py.typed +0 -0
- {uiprotect-7.18.1 → uiprotect-7.20.0}/src/uiprotect/release_cache.json +0 -0
- {uiprotect-7.18.1 → uiprotect-7.20.0}/src/uiprotect/stream.py +0 -0
- {uiprotect-7.18.1 → uiprotect-7.20.0}/src/uiprotect/test_util/__init__.py +0 -0
- {uiprotect-7.18.1 → uiprotect-7.20.0}/src/uiprotect/test_util/anonymize.py +0 -0
- {uiprotect-7.18.1 → uiprotect-7.20.0}/src/uiprotect/utils.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "uiprotect"
|
|
3
|
-
version = "7.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
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
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|