axis 67__tar.gz → 69__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.
- axis-69/PKG-INFO +98 -0
- axis-69/README.md +54 -0
- {axis-67 → axis-69}/axis/__main__.py +21 -0
- axis-69/axis/interfaces/aiohttp_digest.py +266 -0
- {axis-67 → axis-69}/axis/interfaces/api_handler.py +50 -9
- {axis-67 → axis-69}/axis/interfaces/applications/application_handler.py +2 -1
- {axis-67 → axis-69}/axis/interfaces/basic_device_info.py +3 -3
- {axis-67 → axis-69}/axis/interfaces/light_control.py +36 -34
- {axis-67 → axis-69}/axis/interfaces/mqtt.py +21 -15
- {axis-67 → axis-69}/axis/interfaces/pir_sensor_configuration.py +8 -9
- {axis-67 → axis-69}/axis/interfaces/port_management.py +15 -4
- {axis-67 → axis-69}/axis/interfaces/stream_profiles.py +3 -3
- {axis-67 → axis-69}/axis/interfaces/vapix.py +46 -23
- {axis-67 → axis-69}/axis/interfaces/view_areas.py +7 -7
- {axis-67 → axis-69}/axis/models/api_discovery.py +15 -0
- {axis-67 → axis-69}/axis/models/configuration.py +2 -0
- {axis-67 → axis-69}/axis/rtsp.py +5 -0
- {axis-67 → axis-69}/axis/stream_manager.py +75 -15
- axis-69/axis/stream_transport.py +34 -0
- axis-69/axis/websocket.py +357 -0
- axis-69/axis.egg-info/PKG-INFO +98 -0
- {axis-67 → axis-69}/axis.egg-info/SOURCES.txt +5 -1
- {axis-67 → axis-69}/axis.egg-info/requires.txt +10 -10
- {axis-67 → axis-69}/pyproject.toml +18 -12
- axis-69/tests/test_api_handler.py +178 -0
- {axis-67 → axis-69}/tests/test_basic_device_info.py +1 -1
- {axis-67 → axis-69}/tests/test_configuration.py +19 -0
- axis-69/tests/test_http_client_compat.py +393 -0
- {axis-67 → axis-69}/tests/test_light_control.py +47 -47
- {axis-67 → axis-69}/tests/test_main_http_client.py +18 -1
- {axis-67 → axis-69}/tests/test_mqtt.py +36 -0
- {axis-67 → axis-69}/tests/test_rtsp.py +9 -0
- {axis-67 → axis-69}/tests/test_stream_manager.py +141 -2
- {axis-67 → axis-69}/tests/test_vapix.py +113 -1
- axis-69/tests/test_websocket.py +540 -0
- axis-67/PKG-INFO +0 -45
- axis-67/README.md +0 -1
- axis-67/axis.egg-info/PKG-INFO +0 -45
- axis-67/tests/test_api_handler.py +0 -83
- axis-67/tests/test_http_client_compat.py +0 -165
- {axis-67 → axis-69}/LICENSE +0 -0
- {axis-67 → axis-69}/axis/__init__.py +0 -0
- {axis-67 → axis-69}/axis/device.py +0 -0
- {axis-67 → axis-69}/axis/errors.py +0 -0
- {axis-67 → axis-69}/axis/interfaces/__init__.py +0 -0
- {axis-67 → axis-69}/axis/interfaces/api_discovery.py +0 -0
- {axis-67 → axis-69}/axis/interfaces/applications/__init__.py +0 -0
- {axis-67 → axis-69}/axis/interfaces/applications/applications.py +0 -0
- {axis-67 → axis-69}/axis/interfaces/applications/fence_guard.py +0 -0
- {axis-67 → axis-69}/axis/interfaces/applications/loitering_guard.py +0 -0
- {axis-67 → axis-69}/axis/interfaces/applications/motion_guard.py +0 -0
- {axis-67 → axis-69}/axis/interfaces/applications/object_analytics.py +0 -0
- {axis-67 → axis-69}/axis/interfaces/applications/vmd4.py +0 -0
- {axis-67 → axis-69}/axis/interfaces/event_instances.py +0 -0
- {axis-67 → axis-69}/axis/interfaces/event_manager.py +0 -0
- {axis-67 → axis-69}/axis/interfaces/parameters/__init__.py +0 -0
- {axis-67 → axis-69}/axis/interfaces/parameters/brand.py +0 -0
- {axis-67 → axis-69}/axis/interfaces/parameters/image.py +0 -0
- {axis-67 → axis-69}/axis/interfaces/parameters/io_port.py +0 -0
- {axis-67 → axis-69}/axis/interfaces/parameters/param_cgi.py +0 -0
- {axis-67 → axis-69}/axis/interfaces/parameters/param_handler.py +0 -0
- {axis-67 → axis-69}/axis/interfaces/parameters/properties.py +0 -0
- {axis-67 → axis-69}/axis/interfaces/parameters/ptz.py +0 -0
- {axis-67 → axis-69}/axis/interfaces/parameters/stream_profile.py +0 -0
- {axis-67 → axis-69}/axis/interfaces/port_cgi.py +0 -0
- {axis-67 → axis-69}/axis/interfaces/ptz.py +0 -0
- {axis-67 → axis-69}/axis/interfaces/pwdgrp_cgi.py +0 -0
- {axis-67 → axis-69}/axis/interfaces/user_groups.py +0 -0
- {axis-67 → axis-69}/axis/models/__init__.py +0 -0
- {axis-67 → axis-69}/axis/models/api.py +0 -0
- {axis-67 → axis-69}/axis/models/applications/__init__.py +0 -0
- {axis-67 → axis-69}/axis/models/applications/application.py +0 -0
- {axis-67 → axis-69}/axis/models/applications/fence_guard.py +0 -0
- {axis-67 → axis-69}/axis/models/applications/loitering_guard.py +0 -0
- {axis-67 → axis-69}/axis/models/applications/motion_guard.py +0 -0
- {axis-67 → axis-69}/axis/models/applications/object_analytics.py +0 -0
- {axis-67 → axis-69}/axis/models/applications/vmd4.py +0 -0
- {axis-67 → axis-69}/axis/models/basic_device_info.py +0 -0
- {axis-67 → axis-69}/axis/models/event.py +0 -0
- {axis-67 → axis-69}/axis/models/event_instance.py +0 -0
- {axis-67 → axis-69}/axis/models/light_control.py +0 -0
- {axis-67 → axis-69}/axis/models/mqtt.py +0 -0
- {axis-67 → axis-69}/axis/models/parameters/__init__.py +0 -0
- {axis-67 → axis-69}/axis/models/parameters/brand.py +0 -0
- {axis-67 → axis-69}/axis/models/parameters/image.py +0 -0
- {axis-67 → axis-69}/axis/models/parameters/io_port.py +0 -0
- {axis-67 → axis-69}/axis/models/parameters/param_cgi.py +0 -0
- {axis-67 → axis-69}/axis/models/parameters/properties.py +0 -0
- {axis-67 → axis-69}/axis/models/parameters/ptz.py +0 -0
- {axis-67 → axis-69}/axis/models/parameters/stream_profile.py +0 -0
- {axis-67 → axis-69}/axis/models/pir_sensor_configuration.py +0 -0
- {axis-67 → axis-69}/axis/models/port_cgi.py +0 -0
- {axis-67 → axis-69}/axis/models/port_management.py +0 -0
- {axis-67 → axis-69}/axis/models/ptz_cgi.py +0 -0
- {axis-67 → axis-69}/axis/models/pwdgrp_cgi.py +0 -0
- {axis-67 → axis-69}/axis/models/stream_profile.py +0 -0
- {axis-67 → axis-69}/axis/models/user_group.py +0 -0
- {axis-67 → axis-69}/axis/models/view_area.py +0 -0
- {axis-67 → axis-69}/axis/py.typed +0 -0
- {axis-67 → axis-69}/axis.egg-info/dependency_links.txt +0 -0
- {axis-67 → axis-69}/axis.egg-info/entry_points.txt +0 -0
- {axis-67 → axis-69}/axis.egg-info/top_level.txt +0 -0
- {axis-67 → axis-69}/setup.cfg +0 -0
- {axis-67 → axis-69}/tests/test_api_discovery.py +0 -0
- {axis-67 → axis-69}/tests/test_auth_scheme.py +0 -0
- {axis-67 → axis-69}/tests/test_device.py +0 -0
- {axis-67 → axis-69}/tests/test_event.py +0 -0
- {axis-67 → axis-69}/tests/test_event_instances.py +0 -0
- {axis-67 → axis-69}/tests/test_event_stream.py +0 -0
- {axis-67 → axis-69}/tests/test_pir_sensor_configuration.py +0 -0
- {axis-67 → axis-69}/tests/test_port_cgi.py +0 -0
- {axis-67 → axis-69}/tests/test_port_management.py +0 -0
- {axis-67 → axis-69}/tests/test_ptz.py +0 -0
- {axis-67 → axis-69}/tests/test_pwdgrp_cgi.py +0 -0
- {axis-67 → axis-69}/tests/test_stream_profiles.py +0 -0
- {axis-67 → axis-69}/tests/test_user_groups.py +0 -0
- {axis-67 → axis-69}/tests/test_view_areas.py +0 -0
axis-69/PKG-INFO
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: axis
|
|
3
|
+
Version: 69
|
|
4
|
+
Summary: A Python library for communicating with devices from Axis Communications
|
|
5
|
+
Author-email: Robert Svensson <Kane610@users.noreply.github.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Source Code, https://github.com/Kane610/axis
|
|
8
|
+
Project-URL: Bug Reports, https://github.com/Kane610/axis/issues
|
|
9
|
+
Project-URL: Forum, https://community.home-assistant.io/t/axis-camera-component/
|
|
10
|
+
Keywords: axis,vapix,homeassistant
|
|
11
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
16
|
+
Classifier: Topic :: Home Automation
|
|
17
|
+
Requires-Python: >=3.14.0
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
Requires-Dist: aiohttp>=3.12
|
|
21
|
+
Requires-Dist: faust-cchardet>=2.1.18
|
|
22
|
+
Requires-Dist: httpx>=0.26
|
|
23
|
+
Requires-Dist: orjson>3.9
|
|
24
|
+
Requires-Dist: packaging>23
|
|
25
|
+
Requires-Dist: xmltodict>=0.13.0
|
|
26
|
+
Provides-Extra: requirements
|
|
27
|
+
Requires-Dist: aiohttp==3.13.5; extra == "requirements"
|
|
28
|
+
Requires-Dist: httpx==0.28.1; extra == "requirements"
|
|
29
|
+
Requires-Dist: orjson==3.11.8; extra == "requirements"
|
|
30
|
+
Requires-Dist: packaging==26.2; extra == "requirements"
|
|
31
|
+
Requires-Dist: xmltodict==1.0.4; extra == "requirements"
|
|
32
|
+
Provides-Extra: requirements-test
|
|
33
|
+
Requires-Dist: mypy==1.20.2; extra == "requirements-test"
|
|
34
|
+
Requires-Dist: pytest==9.0.3; extra == "requirements-test"
|
|
35
|
+
Requires-Dist: pytest-aiohttp==1.1.0; extra == "requirements-test"
|
|
36
|
+
Requires-Dist: pytest-asyncio==1.3.0; extra == "requirements-test"
|
|
37
|
+
Requires-Dist: pytest-cov==7.1.0; extra == "requirements-test"
|
|
38
|
+
Requires-Dist: respx==0.23.1; extra == "requirements-test"
|
|
39
|
+
Requires-Dist: ruff==0.15.12; extra == "requirements-test"
|
|
40
|
+
Requires-Dist: types-xmltodict==v1.0.1.20260408; extra == "requirements-test"
|
|
41
|
+
Provides-Extra: requirements-dev
|
|
42
|
+
Requires-Dist: pre-commit==4.6.0; extra == "requirements-dev"
|
|
43
|
+
Dynamic: license-file
|
|
44
|
+
|
|
45
|
+
# axis
|
|
46
|
+
|
|
47
|
+
Python project to set up a connection towards Axis Communications devices and to subscribe to specific events on the metadatastream.
|
|
48
|
+
|
|
49
|
+
## Development setup
|
|
50
|
+
|
|
51
|
+
`uv` is required for development setup:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
uv python install 3.14
|
|
55
|
+
uv sync --python 3.14 --all-extras
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Or run the bootstrap script, which installs `uv` if needed and provisions Python 3.14 automatically:
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
./setup.sh
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Dependencies are locked via `uv.lock`. Regenerate lock data when dependency inputs change:
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
uv lock
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Run checks with `uv`:
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
uv run ruff check .
|
|
74
|
+
uv run ruff format --check .
|
|
75
|
+
uv run mypy axis
|
|
76
|
+
uv run pytest
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Initial `ty` support is configured as an opt-in check and does not replace `mypy`:
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
uvx ty check
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## Initialization architecture
|
|
86
|
+
|
|
87
|
+
Vapix initialization is phase-based and driven by handler metadata:
|
|
88
|
+
|
|
89
|
+
- `API_DISCOVERY`: handlers initialized after API discovery.
|
|
90
|
+
- `PARAM_CGI_FALLBACK`: handlers that may initialize from parameter support when not listed in discovery.
|
|
91
|
+
- `APPLICATION`: handlers initialized after applications are loaded.
|
|
92
|
+
|
|
93
|
+
Handlers declare phase membership through `handler_groups` and may customize phase eligibility through `should_initialize_in_group`.
|
|
94
|
+
|
|
95
|
+
Example fallback policy:
|
|
96
|
+
|
|
97
|
+
- `LightHandler` participates in both `API_DISCOVERY` and `PARAM_CGI_FALLBACK`.
|
|
98
|
+
- In `PARAM_CGI_FALLBACK`, it initializes only when not listed in API discovery and listed in parameters.
|
axis-69/README.md
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# axis
|
|
2
|
+
|
|
3
|
+
Python project to set up a connection towards Axis Communications devices and to subscribe to specific events on the metadatastream.
|
|
4
|
+
|
|
5
|
+
## Development setup
|
|
6
|
+
|
|
7
|
+
`uv` is required for development setup:
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
uv python install 3.14
|
|
11
|
+
uv sync --python 3.14 --all-extras
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Or run the bootstrap script, which installs `uv` if needed and provisions Python 3.14 automatically:
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
./setup.sh
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Dependencies are locked via `uv.lock`. Regenerate lock data when dependency inputs change:
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
uv lock
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Run checks with `uv`:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
uv run ruff check .
|
|
30
|
+
uv run ruff format --check .
|
|
31
|
+
uv run mypy axis
|
|
32
|
+
uv run pytest
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Initial `ty` support is configured as an opt-in check and does not replace `mypy`:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
uvx ty check
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Initialization architecture
|
|
42
|
+
|
|
43
|
+
Vapix initialization is phase-based and driven by handler metadata:
|
|
44
|
+
|
|
45
|
+
- `API_DISCOVERY`: handlers initialized after API discovery.
|
|
46
|
+
- `PARAM_CGI_FALLBACK`: handlers that may initialize from parameter support when not listed in discovery.
|
|
47
|
+
- `APPLICATION`: handlers initialized after applications are loaded.
|
|
48
|
+
|
|
49
|
+
Handlers declare phase membership through `handler_groups` and may customize phase eligibility through `should_initialize_in_group`.
|
|
50
|
+
|
|
51
|
+
Example fallback policy:
|
|
52
|
+
|
|
53
|
+
- `LightHandler` participates in both `API_DISCOVERY` and `PARAM_CGI_FALLBACK`.
|
|
54
|
+
- In `PARAM_CGI_FALLBACK`, it initializes only when not listed in API discovery and listed in parameters.
|
|
@@ -28,10 +28,12 @@ async def axis_device(
|
|
|
28
28
|
username: str,
|
|
29
29
|
password: str,
|
|
30
30
|
web_proto: WebProtocol,
|
|
31
|
+
stream_mode: str = "rtsp",
|
|
31
32
|
is_companion: bool = False,
|
|
32
33
|
) -> axis.device.AxisDevice:
|
|
33
34
|
"""Create a Axis device."""
|
|
34
35
|
session = create_session()
|
|
36
|
+
websocket_enabled, websocket_force = websocket_flags_from_mode(stream_mode)
|
|
35
37
|
device = AxisDevice(
|
|
36
38
|
Configuration(
|
|
37
39
|
session,
|
|
@@ -41,6 +43,8 @@ async def axis_device(
|
|
|
41
43
|
password=password,
|
|
42
44
|
is_companion=is_companion,
|
|
43
45
|
web_proto=web_proto,
|
|
46
|
+
websocket_enabled=websocket_enabled,
|
|
47
|
+
websocket_force=websocket_force,
|
|
44
48
|
)
|
|
45
49
|
)
|
|
46
50
|
|
|
@@ -74,6 +78,7 @@ async def main(
|
|
|
74
78
|
params: bool,
|
|
75
79
|
events: bool,
|
|
76
80
|
web_proto: WebProtocol,
|
|
81
|
+
stream_mode: str,
|
|
77
82
|
) -> None:
|
|
78
83
|
"""CLI method for library."""
|
|
79
84
|
LOGGER.info("Connecting to Axis device")
|
|
@@ -84,6 +89,7 @@ async def main(
|
|
|
84
89
|
username,
|
|
85
90
|
password,
|
|
86
91
|
web_proto=web_proto,
|
|
92
|
+
stream_mode=stream_mode,
|
|
87
93
|
)
|
|
88
94
|
|
|
89
95
|
if not device:
|
|
@@ -126,6 +132,15 @@ async def close_session(session: aiohttp.ClientSession) -> None:
|
|
|
126
132
|
await session.close()
|
|
127
133
|
|
|
128
134
|
|
|
135
|
+
def websocket_flags_from_mode(stream_mode: str) -> tuple[bool, bool]:
|
|
136
|
+
"""Translate CLI stream mode to websocket enable/force flags."""
|
|
137
|
+
if stream_mode == "auto":
|
|
138
|
+
return True, False
|
|
139
|
+
if stream_mode == "event":
|
|
140
|
+
return True, True
|
|
141
|
+
return False, False
|
|
142
|
+
|
|
143
|
+
|
|
129
144
|
if __name__ == "__main__":
|
|
130
145
|
parser = argparse.ArgumentParser()
|
|
131
146
|
parser.add_argument("host", type=str)
|
|
@@ -135,6 +150,11 @@ if __name__ == "__main__":
|
|
|
135
150
|
parser.add_argument("--proto", type=str, default="http")
|
|
136
151
|
parser.add_argument("--events", action="store_true")
|
|
137
152
|
parser.add_argument("--params", action="store_true")
|
|
153
|
+
parser.add_argument(
|
|
154
|
+
"--stream-mode",
|
|
155
|
+
choices=["auto", "rtsp", "event"],
|
|
156
|
+
default="rtsp",
|
|
157
|
+
)
|
|
138
158
|
parser.add_argument("-D", "--debug", action="store_true")
|
|
139
159
|
args = parser.parse_args()
|
|
140
160
|
|
|
@@ -163,6 +183,7 @@ if __name__ == "__main__":
|
|
|
163
183
|
params=args.params,
|
|
164
184
|
events=args.events,
|
|
165
185
|
web_proto=WebProtocol(args.proto),
|
|
186
|
+
stream_mode=args.stream_mode,
|
|
166
187
|
)
|
|
167
188
|
)
|
|
168
189
|
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
"""Aiohttp digest authentication handler.
|
|
2
|
+
|
|
3
|
+
Implements library-managed RFC 2617 digest authentication for aiohttp requests
|
|
4
|
+
to handle special characters in request parameters that break middleware-based auth.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import logging
|
|
11
|
+
import re
|
|
12
|
+
import secrets
|
|
13
|
+
from typing import TYPE_CHECKING, Any, cast
|
|
14
|
+
from urllib.parse import quote, urlsplit
|
|
15
|
+
|
|
16
|
+
from ..models.configuration import AuthScheme
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from ..device import AxisDevice
|
|
20
|
+
|
|
21
|
+
LOGGER = logging.getLogger(__name__)
|
|
22
|
+
TIME_OUT = 15
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class AiohttpDigestAuth:
|
|
26
|
+
"""Manages digest authentication for aiohttp requests."""
|
|
27
|
+
|
|
28
|
+
def __init__(self, device: AxisDevice) -> None:
|
|
29
|
+
"""Initialize digest auth handler."""
|
|
30
|
+
self.device = device
|
|
31
|
+
self._nonce: str | None = None
|
|
32
|
+
self._nonce_count = 0
|
|
33
|
+
|
|
34
|
+
def should_use_library_digest(self, http_client: str, has_basic_auth: bool) -> bool:
|
|
35
|
+
"""Return if aiohttp requests should use library-managed digest auth.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
http_client: Name of HTTP client ("aiohttp" or "httpx").
|
|
39
|
+
has_basic_auth: Whether basic auth is configured.
|
|
40
|
+
|
|
41
|
+
Returns:
|
|
42
|
+
True if library-managed digest should be used.
|
|
43
|
+
|
|
44
|
+
"""
|
|
45
|
+
return (
|
|
46
|
+
http_client == "aiohttp"
|
|
47
|
+
and not has_basic_auth
|
|
48
|
+
and self.device.config.auth_scheme != AuthScheme.BASIC
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
def request_target(
|
|
52
|
+
self, url: str, params: dict[str, str] | None, should_encode: bool
|
|
53
|
+
) -> tuple[str, dict[str, str] | None]:
|
|
54
|
+
"""Return request URL and params for aiohttp request.
|
|
55
|
+
|
|
56
|
+
With library-managed digest auth, pre-encode params into the URL so the
|
|
57
|
+
signed URI exactly matches the request-target on the wire.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
url: Base request URL.
|
|
61
|
+
params: Optional query parameters.
|
|
62
|
+
should_encode: Whether to pre-encode params for digest signing.
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
Tuple of (request_url, request_params) to use in actual request.
|
|
66
|
+
|
|
67
|
+
"""
|
|
68
|
+
if params is None or not should_encode:
|
|
69
|
+
return url, params
|
|
70
|
+
|
|
71
|
+
separator = "&" if "?" in url else "?"
|
|
72
|
+
encoded_parts = [
|
|
73
|
+
f"{quote(k, safe='')}={quote(v, safe='')}" for k, v in params.items()
|
|
74
|
+
]
|
|
75
|
+
encoded_query = "&".join(encoded_parts)
|
|
76
|
+
encoded_url = f"{url}{separator}{encoded_query}"
|
|
77
|
+
return encoded_url, None
|
|
78
|
+
|
|
79
|
+
def extract_challenge(self, headers: Any) -> str | None:
|
|
80
|
+
"""Return digest challenge header when present.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
headers: Response headers (dict-like or aiohttp MultiDictProxy).
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
Digest challenge string if present, None otherwise.
|
|
87
|
+
|
|
88
|
+
"""
|
|
89
|
+
candidates: list[str] = []
|
|
90
|
+
if hasattr(headers, "getall"):
|
|
91
|
+
candidates.extend(cast("list[str]", headers.getall("WWW-Authenticate", [])))
|
|
92
|
+
else:
|
|
93
|
+
for name, value in cast("dict[str, str]", headers).items():
|
|
94
|
+
if name.lower() == "www-authenticate":
|
|
95
|
+
candidates.append(value)
|
|
96
|
+
|
|
97
|
+
for value in candidates:
|
|
98
|
+
if value.lower().startswith("digest "):
|
|
99
|
+
return value
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
def build_authorization(
|
|
103
|
+
self,
|
|
104
|
+
method: str,
|
|
105
|
+
request_url: str,
|
|
106
|
+
digest_challenge: str,
|
|
107
|
+
) -> str | None:
|
|
108
|
+
"""Build digest authorization header from challenge and request URI.
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
method: HTTP method (GET, POST, etc.).
|
|
112
|
+
request_url: Full request URL (will extract path + query).
|
|
113
|
+
digest_challenge: Digest challenge string from WWW-Authenticate header.
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
Authorization header value or None if digest cannot be built.
|
|
117
|
+
|
|
118
|
+
"""
|
|
119
|
+
challenge_values = {
|
|
120
|
+
key.lower(): value.strip('"')
|
|
121
|
+
for key, value in re.findall(
|
|
122
|
+
r"(\w+)=((?:\"[^\"]*\")|(?:[^,]+))", digest_challenge
|
|
123
|
+
)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
realm = challenge_values.get("realm")
|
|
127
|
+
nonce = challenge_values.get("nonce")
|
|
128
|
+
if realm is None or nonce is None:
|
|
129
|
+
return None
|
|
130
|
+
|
|
131
|
+
algorithm = challenge_values.get("algorithm", "MD5").upper()
|
|
132
|
+
if algorithm != "MD5":
|
|
133
|
+
LOGGER.debug("Unsupported digest algorithm for aiohttp path: %s", algorithm)
|
|
134
|
+
return None
|
|
135
|
+
|
|
136
|
+
uri = self._digest_uri(request_url)
|
|
137
|
+
qop = None
|
|
138
|
+
if qop_header := challenge_values.get("qop"):
|
|
139
|
+
qop_values = [value.strip() for value in qop_header.split(",")]
|
|
140
|
+
if "auth" in qop_values:
|
|
141
|
+
qop = "auth"
|
|
142
|
+
|
|
143
|
+
username = self.device.config.username
|
|
144
|
+
password = self.device.config.password
|
|
145
|
+
ha1 = hashlib.md5(f"{username}:{realm}:{password}".encode()).hexdigest()
|
|
146
|
+
ha2 = hashlib.md5(f"{method.upper()}:{uri}".encode()).hexdigest()
|
|
147
|
+
|
|
148
|
+
parts = [
|
|
149
|
+
f'username="{username}"',
|
|
150
|
+
f'realm="{realm}"',
|
|
151
|
+
f'nonce="{nonce}"',
|
|
152
|
+
f'uri="{uri}"',
|
|
153
|
+
'algorithm="MD5"',
|
|
154
|
+
]
|
|
155
|
+
|
|
156
|
+
if opaque := challenge_values.get("opaque"):
|
|
157
|
+
parts.append(f'opaque="{opaque}"')
|
|
158
|
+
|
|
159
|
+
if qop == "auth":
|
|
160
|
+
if nonce != self._nonce:
|
|
161
|
+
self._nonce = nonce
|
|
162
|
+
self._nonce_count = 0
|
|
163
|
+
|
|
164
|
+
self._nonce_count += 1
|
|
165
|
+
nc = f"{self._nonce_count:08x}"
|
|
166
|
+
cnonce = secrets.token_hex(8)
|
|
167
|
+
response = hashlib.md5(
|
|
168
|
+
f"{ha1}:{nonce}:{nc}:{cnonce}:{qop}:{ha2}".encode()
|
|
169
|
+
).hexdigest()
|
|
170
|
+
parts.extend(
|
|
171
|
+
[
|
|
172
|
+
f'response="{response}"',
|
|
173
|
+
f"qop={qop}",
|
|
174
|
+
f"nc={nc}",
|
|
175
|
+
f'cnonce="{cnonce}"',
|
|
176
|
+
]
|
|
177
|
+
)
|
|
178
|
+
else:
|
|
179
|
+
response = hashlib.md5(f"{ha1}:{nonce}:{ha2}".encode()).hexdigest()
|
|
180
|
+
parts.append(f'response="{response}"')
|
|
181
|
+
|
|
182
|
+
return f"Digest {', '.join(parts)}"
|
|
183
|
+
|
|
184
|
+
def _digest_uri(self, request_url: str) -> str:
|
|
185
|
+
"""Return path + query request-target URI for digest signing.
|
|
186
|
+
|
|
187
|
+
Args:
|
|
188
|
+
request_url: Full request URL.
|
|
189
|
+
|
|
190
|
+
Returns:
|
|
191
|
+
Path and query string for digest signature.
|
|
192
|
+
|
|
193
|
+
"""
|
|
194
|
+
split_result = urlsplit(request_url)
|
|
195
|
+
if split_result.query:
|
|
196
|
+
return f"{split_result.path}?{split_result.query}"
|
|
197
|
+
return split_result.path
|
|
198
|
+
|
|
199
|
+
async def perform_request(
|
|
200
|
+
self,
|
|
201
|
+
session: Any,
|
|
202
|
+
method: str,
|
|
203
|
+
url: str,
|
|
204
|
+
request_data: bytes | dict[str, str] | None,
|
|
205
|
+
headers: dict[str, str] | None,
|
|
206
|
+
params: dict[str, str] | None,
|
|
207
|
+
) -> tuple[int, dict[str, str], bytes]:
|
|
208
|
+
"""Execute aiohttp request with digest auth handling.
|
|
209
|
+
|
|
210
|
+
Args:
|
|
211
|
+
session: aiohttp ClientSession.
|
|
212
|
+
method: HTTP method.
|
|
213
|
+
url: Request URL.
|
|
214
|
+
request_data: Request body (bytes or form data).
|
|
215
|
+
headers: Request headers.
|
|
216
|
+
params: Query parameters.
|
|
217
|
+
|
|
218
|
+
Returns:
|
|
219
|
+
Tuple of (status_code, response_headers, response_content).
|
|
220
|
+
|
|
221
|
+
"""
|
|
222
|
+
request_url, request_params = self.request_target(url, params, True)
|
|
223
|
+
request_headers = dict(headers) if headers is not None else {}
|
|
224
|
+
|
|
225
|
+
# First attempt without auth to get challenge
|
|
226
|
+
async with session.request(
|
|
227
|
+
method,
|
|
228
|
+
request_url,
|
|
229
|
+
data=request_data,
|
|
230
|
+
headers=request_headers,
|
|
231
|
+
params=request_params,
|
|
232
|
+
auth=None,
|
|
233
|
+
timeout=TIME_OUT,
|
|
234
|
+
) as response:
|
|
235
|
+
response_content = await response.read()
|
|
236
|
+
response_headers = dict(response.headers)
|
|
237
|
+
if response.status != 401:
|
|
238
|
+
return response.status, response_headers, response_content
|
|
239
|
+
|
|
240
|
+
digest_challenge = self.extract_challenge(response.headers)
|
|
241
|
+
if digest_challenge is None:
|
|
242
|
+
return response.status, response_headers, response_content
|
|
243
|
+
|
|
244
|
+
# Build digest auth and retry
|
|
245
|
+
digest_authorization = self.build_authorization(
|
|
246
|
+
method=method,
|
|
247
|
+
request_url=request_url,
|
|
248
|
+
digest_challenge=digest_challenge,
|
|
249
|
+
)
|
|
250
|
+
if digest_authorization is None:
|
|
251
|
+
return 401, {}, b""
|
|
252
|
+
|
|
253
|
+
retry_headers = dict(request_headers)
|
|
254
|
+
retry_headers["Authorization"] = digest_authorization
|
|
255
|
+
|
|
256
|
+
async with session.request(
|
|
257
|
+
method,
|
|
258
|
+
request_url,
|
|
259
|
+
data=request_data,
|
|
260
|
+
headers=retry_headers,
|
|
261
|
+
params=request_params,
|
|
262
|
+
auth=None,
|
|
263
|
+
timeout=TIME_OUT,
|
|
264
|
+
) as response:
|
|
265
|
+
response_content = await response.read()
|
|
266
|
+
return response.status, dict(response.headers), response_content
|
|
@@ -8,6 +8,7 @@ from collections.abc import (
|
|
|
8
8
|
Sequence,
|
|
9
9
|
ValuesView,
|
|
10
10
|
)
|
|
11
|
+
import enum
|
|
11
12
|
from typing import TYPE_CHECKING, Generic, final
|
|
12
13
|
|
|
13
14
|
from ..errors import Forbidden, PathNotFound, Unauthorized
|
|
@@ -25,6 +26,14 @@ UnsubscribeType = Callable[[], None]
|
|
|
25
26
|
ID_FILTER_ALL = "*"
|
|
26
27
|
|
|
27
28
|
|
|
29
|
+
class HandlerGroup(enum.Enum):
|
|
30
|
+
"""Group handlers by initialization phase in Vapix."""
|
|
31
|
+
|
|
32
|
+
API_DISCOVERY = "api_discovery"
|
|
33
|
+
PARAM_CGI_FALLBACK = "param_cgi_fallback"
|
|
34
|
+
APPLICATION = "application"
|
|
35
|
+
|
|
36
|
+
|
|
28
37
|
class SubscriptionHandler:
|
|
29
38
|
"""Manage subscription and notification to subscribers."""
|
|
30
39
|
|
|
@@ -75,12 +84,14 @@ class ApiHandler(SubscriptionHandler, Generic[ApiItemT]):
|
|
|
75
84
|
|
|
76
85
|
api_id: ApiId | None = None
|
|
77
86
|
default_api_version: str | None = None
|
|
87
|
+
handler_groups: tuple[HandlerGroup, ...] = ()
|
|
78
88
|
skip_support_check = False
|
|
79
89
|
|
|
80
90
|
def __init__(self, vapix: Vapix) -> None:
|
|
81
91
|
"""Initialize API items."""
|
|
82
92
|
super().__init__()
|
|
83
93
|
self.vapix = vapix
|
|
94
|
+
self.vapix._register_handler(self)
|
|
84
95
|
self._items: dict[str, ApiItemT] = {}
|
|
85
96
|
self.initialized = False
|
|
86
97
|
|
|
@@ -101,6 +112,10 @@ class ApiHandler(SubscriptionHandler, Generic[ApiItemT]):
|
|
|
101
112
|
"""Is API listed in parameters."""
|
|
102
113
|
return False
|
|
103
114
|
|
|
115
|
+
def should_initialize_in_group(self, group: HandlerGroup) -> bool:
|
|
116
|
+
"""Return whether handler should initialize in the given group."""
|
|
117
|
+
return True
|
|
118
|
+
|
|
104
119
|
async def _api_request(self) -> dict[str, ApiItemT]:
|
|
105
120
|
"""Get API data method defined by subclass."""
|
|
106
121
|
raise NotImplementedError
|
|
@@ -134,15 +149,41 @@ class ApiHandler(SubscriptionHandler, Generic[ApiItemT]):
|
|
|
134
149
|
return self.initialized
|
|
135
150
|
|
|
136
151
|
@property
|
|
137
|
-
def api_version(self) -> str
|
|
138
|
-
"""Latest API version supported.
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
)
|
|
144
|
-
|
|
145
|
-
|
|
152
|
+
def api_version(self) -> str:
|
|
153
|
+
"""Latest API version supported.
|
|
154
|
+
|
|
155
|
+
Returns the API version in this order of precedence:
|
|
156
|
+
1. Version from device discovery (dynamic, device-specific)
|
|
157
|
+
2. Handler's default_api_version (static, library-defined)
|
|
158
|
+
3. Empty string "" (for handlers without discovery support)
|
|
159
|
+
|
|
160
|
+
Note: This property always returns a string (never None). Handlers that don't
|
|
161
|
+
support API versioning (no api_id) return empty string, which is safe because
|
|
162
|
+
they don't send api_version in their requests.
|
|
163
|
+
|
|
164
|
+
Returns:
|
|
165
|
+
str: API version (e.g., "1.0", "1.1") or empty string if not available.
|
|
166
|
+
|
|
167
|
+
"""
|
|
168
|
+
discovery_version = self._get_api_discovery_version()
|
|
169
|
+
if discovery_version is not None:
|
|
170
|
+
return discovery_version
|
|
171
|
+
return self.default_api_version or ""
|
|
172
|
+
|
|
173
|
+
def _get_api_discovery_version(self) -> str | None:
|
|
174
|
+
"""Get API version from discovery data when available."""
|
|
175
|
+
if self.api_id is None:
|
|
176
|
+
return None
|
|
177
|
+
|
|
178
|
+
discovery_item = self.vapix.api_discovery.get(self.api_id)
|
|
179
|
+
if discovery_item is None:
|
|
180
|
+
return None
|
|
181
|
+
|
|
182
|
+
discovery_version = getattr(discovery_item, "version", None)
|
|
183
|
+
if isinstance(discovery_version, str):
|
|
184
|
+
return discovery_version
|
|
185
|
+
|
|
186
|
+
return None
|
|
146
187
|
|
|
147
188
|
def items(self) -> ItemsView[str, ApiItemT]:
|
|
148
189
|
"""Return items."""
|
|
@@ -4,13 +4,14 @@ from abc import abstractmethod
|
|
|
4
4
|
|
|
5
5
|
from ...models.api import ApiItemT
|
|
6
6
|
from ...models.applications.application import ApplicationName, ApplicationStatus
|
|
7
|
-
from ..api_handler import ApiHandler
|
|
7
|
+
from ..api_handler import ApiHandler, HandlerGroup
|
|
8
8
|
|
|
9
9
|
|
|
10
10
|
class ApplicationHandler(ApiHandler[ApiItemT]):
|
|
11
11
|
"""Generic application handler."""
|
|
12
12
|
|
|
13
13
|
app_name: ApplicationName
|
|
14
|
+
handler_groups = (HandlerGroup.APPLICATION,)
|
|
14
15
|
|
|
15
16
|
@property
|
|
16
17
|
def supported(self) -> bool:
|
|
@@ -14,7 +14,7 @@ from ..models.basic_device_info import (
|
|
|
14
14
|
GetSupportedVersionsRequest,
|
|
15
15
|
GetSupportedVersionsResponse,
|
|
16
16
|
)
|
|
17
|
-
from .api_handler import ApiHandler
|
|
17
|
+
from .api_handler import ApiHandler, HandlerGroup
|
|
18
18
|
|
|
19
19
|
|
|
20
20
|
class BasicDeviceInfoHandler(ApiHandler[DeviceInformation]):
|
|
@@ -22,6 +22,7 @@ class BasicDeviceInfoHandler(ApiHandler[DeviceInformation]):
|
|
|
22
22
|
|
|
23
23
|
api_id = ApiId.BASIC_DEVICE_INFO
|
|
24
24
|
default_api_version = API_VERSION
|
|
25
|
+
handler_groups = (HandlerGroup.API_DISCOVERY,)
|
|
25
26
|
|
|
26
27
|
async def _api_request(self) -> dict[str, DeviceInformation]:
|
|
27
28
|
"""Get default data of basic device information."""
|
|
@@ -29,9 +30,8 @@ class BasicDeviceInfoHandler(ApiHandler[DeviceInformation]):
|
|
|
29
30
|
|
|
30
31
|
async def get_all_properties(self) -> dict[str, DeviceInformation]:
|
|
31
32
|
"""List all properties of basic device info."""
|
|
32
|
-
discovery_item = self.vapix.api_discovery[self.api_id]
|
|
33
33
|
bytes_data = await self.vapix.api_request(
|
|
34
|
-
GetAllPropertiesRequest(
|
|
34
|
+
GetAllPropertiesRequest(self.api_version)
|
|
35
35
|
)
|
|
36
36
|
response = GetAllPropertiesResponse.decode(bytes_data)
|
|
37
37
|
return {"0": response.data}
|