axis 68__tar.gz → 70__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-68 → axis-70}/PKG-INFO +8 -11
- {axis-68 → axis-70}/axis/__main__.py +1 -5
- axis-70/axis/interfaces/aiohttp_digest.py +266 -0
- {axis-68 → axis-70}/axis/interfaces/vapix.py +20 -81
- {axis-68 → axis-70}/axis/models/api_discovery.py +15 -0
- {axis-68 → axis-70}/axis/models/configuration.py +1 -2
- {axis-68 → axis-70}/axis/stream_manager.py +26 -0
- {axis-68 → axis-70}/axis/websocket.py +69 -11
- {axis-68 → axis-70}/axis.egg-info/PKG-INFO +8 -11
- {axis-68 → axis-70}/axis.egg-info/SOURCES.txt +2 -0
- {axis-68 → axis-70}/axis.egg-info/requires.txt +7 -10
- {axis-68 → axis-70}/pyproject.toml +9 -12
- {axis-68 → axis-70}/tests/test_api_discovery.py +8 -17
- axis-70/tests/test_auth_scheme.py +125 -0
- {axis-68 → axis-70}/tests/test_basic_device_info.py +12 -21
- {axis-68 → axis-70}/tests/test_configuration.py +20 -19
- axis-70/tests/test_conftest.py +164 -0
- {axis-68 → axis-70}/tests/test_event_instances.py +11 -5
- axis-70/tests/test_http_client_compat.py +374 -0
- {axis-68 → axis-70}/tests/test_light_control.py +52 -48
- {axis-68 → axis-70}/tests/test_mqtt.py +16 -16
- {axis-68 → axis-70}/tests/test_pir_sensor_configuration.py +11 -9
- {axis-68 → axis-70}/tests/test_port_cgi.py +17 -33
- {axis-68 → axis-70}/tests/test_port_management.py +15 -12
- {axis-68 → axis-70}/tests/test_ptz.py +86 -78
- {axis-68 → axis-70}/tests/test_pwdgrp_cgi.py +14 -18
- {axis-68 → axis-70}/tests/test_stream_manager.py +75 -0
- {axis-68 → axis-70}/tests/test_stream_profiles.py +29 -18
- {axis-68 → axis-70}/tests/test_user_groups.py +40 -15
- {axis-68 → axis-70}/tests/test_vapix.py +105 -79
- {axis-68 → axis-70}/tests/test_view_areas.py +26 -22
- {axis-68 → axis-70}/tests/test_websocket.py +97 -30
- axis-68/tests/test_auth_scheme.py +0 -86
- axis-68/tests/test_http_client_compat.py +0 -165
- {axis-68 → axis-70}/LICENSE +0 -0
- {axis-68 → axis-70}/README.md +0 -0
- {axis-68 → axis-70}/axis/__init__.py +0 -0
- {axis-68 → axis-70}/axis/device.py +0 -0
- {axis-68 → axis-70}/axis/errors.py +0 -0
- {axis-68 → axis-70}/axis/interfaces/__init__.py +0 -0
- {axis-68 → axis-70}/axis/interfaces/api_discovery.py +0 -0
- {axis-68 → axis-70}/axis/interfaces/api_handler.py +0 -0
- {axis-68 → axis-70}/axis/interfaces/applications/__init__.py +0 -0
- {axis-68 → axis-70}/axis/interfaces/applications/application_handler.py +0 -0
- {axis-68 → axis-70}/axis/interfaces/applications/applications.py +0 -0
- {axis-68 → axis-70}/axis/interfaces/applications/fence_guard.py +0 -0
- {axis-68 → axis-70}/axis/interfaces/applications/loitering_guard.py +0 -0
- {axis-68 → axis-70}/axis/interfaces/applications/motion_guard.py +0 -0
- {axis-68 → axis-70}/axis/interfaces/applications/object_analytics.py +0 -0
- {axis-68 → axis-70}/axis/interfaces/applications/vmd4.py +0 -0
- {axis-68 → axis-70}/axis/interfaces/basic_device_info.py +0 -0
- {axis-68 → axis-70}/axis/interfaces/event_instances.py +0 -0
- {axis-68 → axis-70}/axis/interfaces/event_manager.py +0 -0
- {axis-68 → axis-70}/axis/interfaces/light_control.py +0 -0
- {axis-68 → axis-70}/axis/interfaces/mqtt.py +0 -0
- {axis-68 → axis-70}/axis/interfaces/parameters/__init__.py +0 -0
- {axis-68 → axis-70}/axis/interfaces/parameters/brand.py +0 -0
- {axis-68 → axis-70}/axis/interfaces/parameters/image.py +0 -0
- {axis-68 → axis-70}/axis/interfaces/parameters/io_port.py +0 -0
- {axis-68 → axis-70}/axis/interfaces/parameters/param_cgi.py +0 -0
- {axis-68 → axis-70}/axis/interfaces/parameters/param_handler.py +0 -0
- {axis-68 → axis-70}/axis/interfaces/parameters/properties.py +0 -0
- {axis-68 → axis-70}/axis/interfaces/parameters/ptz.py +0 -0
- {axis-68 → axis-70}/axis/interfaces/parameters/stream_profile.py +0 -0
- {axis-68 → axis-70}/axis/interfaces/pir_sensor_configuration.py +0 -0
- {axis-68 → axis-70}/axis/interfaces/port_cgi.py +0 -0
- {axis-68 → axis-70}/axis/interfaces/port_management.py +0 -0
- {axis-68 → axis-70}/axis/interfaces/ptz.py +0 -0
- {axis-68 → axis-70}/axis/interfaces/pwdgrp_cgi.py +0 -0
- {axis-68 → axis-70}/axis/interfaces/stream_profiles.py +0 -0
- {axis-68 → axis-70}/axis/interfaces/user_groups.py +0 -0
- {axis-68 → axis-70}/axis/interfaces/view_areas.py +0 -0
- {axis-68 → axis-70}/axis/models/__init__.py +0 -0
- {axis-68 → axis-70}/axis/models/api.py +0 -0
- {axis-68 → axis-70}/axis/models/applications/__init__.py +0 -0
- {axis-68 → axis-70}/axis/models/applications/application.py +0 -0
- {axis-68 → axis-70}/axis/models/applications/fence_guard.py +0 -0
- {axis-68 → axis-70}/axis/models/applications/loitering_guard.py +0 -0
- {axis-68 → axis-70}/axis/models/applications/motion_guard.py +0 -0
- {axis-68 → axis-70}/axis/models/applications/object_analytics.py +0 -0
- {axis-68 → axis-70}/axis/models/applications/vmd4.py +0 -0
- {axis-68 → axis-70}/axis/models/basic_device_info.py +0 -0
- {axis-68 → axis-70}/axis/models/event.py +0 -0
- {axis-68 → axis-70}/axis/models/event_instance.py +0 -0
- {axis-68 → axis-70}/axis/models/light_control.py +0 -0
- {axis-68 → axis-70}/axis/models/mqtt.py +0 -0
- {axis-68 → axis-70}/axis/models/parameters/__init__.py +0 -0
- {axis-68 → axis-70}/axis/models/parameters/brand.py +0 -0
- {axis-68 → axis-70}/axis/models/parameters/image.py +0 -0
- {axis-68 → axis-70}/axis/models/parameters/io_port.py +0 -0
- {axis-68 → axis-70}/axis/models/parameters/param_cgi.py +0 -0
- {axis-68 → axis-70}/axis/models/parameters/properties.py +0 -0
- {axis-68 → axis-70}/axis/models/parameters/ptz.py +0 -0
- {axis-68 → axis-70}/axis/models/parameters/stream_profile.py +0 -0
- {axis-68 → axis-70}/axis/models/pir_sensor_configuration.py +0 -0
- {axis-68 → axis-70}/axis/models/port_cgi.py +0 -0
- {axis-68 → axis-70}/axis/models/port_management.py +0 -0
- {axis-68 → axis-70}/axis/models/ptz_cgi.py +0 -0
- {axis-68 → axis-70}/axis/models/pwdgrp_cgi.py +0 -0
- {axis-68 → axis-70}/axis/models/stream_profile.py +0 -0
- {axis-68 → axis-70}/axis/models/user_group.py +0 -0
- {axis-68 → axis-70}/axis/models/view_area.py +0 -0
- {axis-68 → axis-70}/axis/py.typed +0 -0
- {axis-68 → axis-70}/axis/rtsp.py +0 -0
- {axis-68 → axis-70}/axis/stream_transport.py +0 -0
- {axis-68 → axis-70}/axis.egg-info/dependency_links.txt +0 -0
- {axis-68 → axis-70}/axis.egg-info/entry_points.txt +0 -0
- {axis-68 → axis-70}/axis.egg-info/top_level.txt +0 -0
- {axis-68 → axis-70}/setup.cfg +0 -0
- {axis-68 → axis-70}/tests/test_api_handler.py +0 -0
- {axis-68 → axis-70}/tests/test_device.py +0 -0
- {axis-68 → axis-70}/tests/test_event.py +0 -0
- {axis-68 → axis-70}/tests/test_event_stream.py +0 -0
- {axis-68 → axis-70}/tests/test_main_http_client.py +0 -0
- {axis-68 → axis-70}/tests/test_rtsp.py +0 -0
{axis-68 → axis-70}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: axis
|
|
3
|
-
Version:
|
|
3
|
+
Version: 70
|
|
4
4
|
Summary: A Python library for communicating with devices from Axis Communications
|
|
5
5
|
Author-email: Robert Svensson <Kane610@users.noreply.github.com>
|
|
6
6
|
License: MIT
|
|
@@ -19,27 +19,24 @@ Description-Content-Type: text/markdown
|
|
|
19
19
|
License-File: LICENSE
|
|
20
20
|
Requires-Dist: aiohttp>=3.12
|
|
21
21
|
Requires-Dist: faust-cchardet>=2.1.18
|
|
22
|
-
Requires-Dist: httpx>=0.26
|
|
23
22
|
Requires-Dist: orjson>3.9
|
|
24
23
|
Requires-Dist: packaging>23
|
|
25
24
|
Requires-Dist: xmltodict>=0.13.0
|
|
26
25
|
Provides-Extra: requirements
|
|
27
26
|
Requires-Dist: aiohttp==3.13.5; extra == "requirements"
|
|
28
|
-
Requires-Dist:
|
|
29
|
-
Requires-Dist:
|
|
30
|
-
Requires-Dist: packaging==26.0; extra == "requirements"
|
|
27
|
+
Requires-Dist: orjson==3.11.9; extra == "requirements"
|
|
28
|
+
Requires-Dist: packaging==26.2; extra == "requirements"
|
|
31
29
|
Requires-Dist: xmltodict==1.0.4; extra == "requirements"
|
|
32
30
|
Provides-Extra: requirements-test
|
|
33
|
-
Requires-Dist: mypy==
|
|
34
|
-
Requires-Dist: pytest==9.0.
|
|
31
|
+
Requires-Dist: mypy==2.0.0; extra == "requirements-test"
|
|
32
|
+
Requires-Dist: pytest==9.0.3; extra == "requirements-test"
|
|
35
33
|
Requires-Dist: pytest-aiohttp==1.1.0; extra == "requirements-test"
|
|
36
34
|
Requires-Dist: pytest-asyncio==1.3.0; extra == "requirements-test"
|
|
37
35
|
Requires-Dist: pytest-cov==7.1.0; extra == "requirements-test"
|
|
38
|
-
Requires-Dist:
|
|
39
|
-
Requires-Dist:
|
|
40
|
-
Requires-Dist: types-xmltodict==v1.0.1.20260113; extra == "requirements-test"
|
|
36
|
+
Requires-Dist: ruff==0.15.12; extra == "requirements-test"
|
|
37
|
+
Requires-Dist: types-xmltodict==v1.0.1.20260408; extra == "requirements-test"
|
|
41
38
|
Provides-Extra: requirements-dev
|
|
42
|
-
Requires-Dist: pre-commit==4.
|
|
39
|
+
Requires-Dist: pre-commit==4.6.0; extra == "requirements-dev"
|
|
43
40
|
Dynamic: license-file
|
|
44
41
|
|
|
45
42
|
# axis
|
|
@@ -113,11 +113,7 @@ async def main(
|
|
|
113
113
|
device.stream.stop()
|
|
114
114
|
|
|
115
115
|
finally:
|
|
116
|
-
|
|
117
|
-
if not isinstance(session, aiohttp.ClientSession):
|
|
118
|
-
message = "Configured session is not an aiohttp ClientSession"
|
|
119
|
-
raise RuntimeError(message)
|
|
120
|
-
await close_session(session)
|
|
116
|
+
await close_session(device.config.session)
|
|
121
117
|
device.stream.stop()
|
|
122
118
|
|
|
123
119
|
|
|
@@ -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.
|
|
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
|
|
@@ -7,11 +7,11 @@ import logging
|
|
|
7
7
|
from typing import TYPE_CHECKING, Any, cast
|
|
8
8
|
|
|
9
9
|
import aiohttp
|
|
10
|
-
import httpx
|
|
11
10
|
|
|
12
11
|
from ..errors import RequestError, raise_error
|
|
13
12
|
from ..models.configuration import AuthScheme
|
|
14
13
|
from ..models.pwdgrp_cgi import SecondaryGroup
|
|
14
|
+
from .aiohttp_digest import AiohttpDigestAuth
|
|
15
15
|
from .api_discovery import ApiDiscoveryHandler
|
|
16
16
|
from .api_handler import ApiHandler, HandlerGroup
|
|
17
17
|
from .applications import ApplicationsHandler
|
|
@@ -54,19 +54,14 @@ class Vapix:
|
|
|
54
54
|
def __init__(self, device: AxisDevice) -> None:
|
|
55
55
|
"""Store local reference to device config."""
|
|
56
56
|
self.device = device
|
|
57
|
-
self._http_client = self._client_name()
|
|
58
57
|
self._aiohttp_digest_middleware: Any | None = None
|
|
58
|
+
self._aiohttp_digest_auth = AiohttpDigestAuth(device)
|
|
59
59
|
|
|
60
|
-
if
|
|
61
|
-
|
|
62
|
-
self.auth = self._aiohttp_basic_auth()
|
|
63
|
-
else:
|
|
64
|
-
self.auth = None
|
|
65
|
-
self._aiohttp_digest_middleware = self._aiohttp_digest_middleware_obj()
|
|
66
|
-
elif device.config.auth_scheme == AuthScheme.BASIC:
|
|
67
|
-
self.auth = httpx.BasicAuth(device.config.username, device.config.password)
|
|
60
|
+
if device.config.auth_scheme == AuthScheme.BASIC:
|
|
61
|
+
self.auth = self._aiohttp_basic_auth()
|
|
68
62
|
else:
|
|
69
|
-
self.auth =
|
|
63
|
+
self.auth = None
|
|
64
|
+
self._aiohttp_digest_middleware = self._aiohttp_digest_middleware_obj()
|
|
70
65
|
|
|
71
66
|
# Grouped handlers are registered in handler-construction order.
|
|
72
67
|
# Handlers with empty handler_groups are intentionally excluded.
|
|
@@ -325,20 +320,6 @@ class Vapix:
|
|
|
325
320
|
params=params,
|
|
326
321
|
)
|
|
327
322
|
|
|
328
|
-
except httpx.TimeoutException as errt:
|
|
329
|
-
message = "Timeout"
|
|
330
|
-
raise RequestError(message) from errt
|
|
331
|
-
|
|
332
|
-
except httpx.TransportError as errc:
|
|
333
|
-
LOGGER.debug("%s", errc)
|
|
334
|
-
message = f"Connection error: {errc}"
|
|
335
|
-
raise RequestError(message) from errc
|
|
336
|
-
|
|
337
|
-
except httpx.RequestError as err:
|
|
338
|
-
LOGGER.debug("%s", err)
|
|
339
|
-
message = f"Unknown error: {err}"
|
|
340
|
-
raise RequestError(message) from err
|
|
341
|
-
|
|
342
323
|
except TimeoutError as errt:
|
|
343
324
|
message = "Timeout"
|
|
344
325
|
raise RequestError(message) from errt
|
|
@@ -389,17 +370,8 @@ class Vapix:
|
|
|
389
370
|
headers: dict[str, str] | None,
|
|
390
371
|
params: dict[str, str] | None,
|
|
391
372
|
) -> tuple[int, dict[str, str], bytes]:
|
|
392
|
-
"""Execute request and normalize responses
|
|
393
|
-
|
|
394
|
-
return await self._perform_aiohttp_request(
|
|
395
|
-
method=method,
|
|
396
|
-
url=url,
|
|
397
|
-
content=content,
|
|
398
|
-
data=data,
|
|
399
|
-
headers=headers,
|
|
400
|
-
params=params,
|
|
401
|
-
)
|
|
402
|
-
return await self._perform_httpx_request(
|
|
373
|
+
"""Execute request and normalize responses."""
|
|
374
|
+
return await self._perform_aiohttp_request(
|
|
403
375
|
method=method,
|
|
404
376
|
url=url,
|
|
405
377
|
content=content,
|
|
@@ -408,29 +380,6 @@ class Vapix:
|
|
|
408
380
|
params=params,
|
|
409
381
|
)
|
|
410
382
|
|
|
411
|
-
async def _perform_httpx_request(
|
|
412
|
-
self,
|
|
413
|
-
method: str,
|
|
414
|
-
url: str,
|
|
415
|
-
content: bytes | None,
|
|
416
|
-
data: dict[str, str] | None,
|
|
417
|
-
headers: dict[str, str] | None,
|
|
418
|
-
params: dict[str, str] | None,
|
|
419
|
-
) -> tuple[int, dict[str, str], bytes]:
|
|
420
|
-
"""Execute request with a httpx session."""
|
|
421
|
-
session = self._httpx_session()
|
|
422
|
-
response = await session.request(
|
|
423
|
-
method,
|
|
424
|
-
url,
|
|
425
|
-
content=content,
|
|
426
|
-
data=data,
|
|
427
|
-
headers=headers,
|
|
428
|
-
params=params,
|
|
429
|
-
auth=self._httpx_auth(),
|
|
430
|
-
timeout=TIME_OUT,
|
|
431
|
-
)
|
|
432
|
-
return response.status_code, dict(response.headers), response.content
|
|
433
|
-
|
|
434
383
|
async def _perform_aiohttp_request(
|
|
435
384
|
self,
|
|
436
385
|
method: str,
|
|
@@ -445,6 +394,15 @@ class Vapix:
|
|
|
445
394
|
content if content is not None else data
|
|
446
395
|
)
|
|
447
396
|
session = self._aiohttp_session()
|
|
397
|
+
|
|
398
|
+
if (
|
|
399
|
+
not self._aiohttp_auth()
|
|
400
|
+
and self.device.config.auth_scheme != AuthScheme.BASIC
|
|
401
|
+
):
|
|
402
|
+
return await self._aiohttp_digest_auth.perform_request(
|
|
403
|
+
session, method, url, request_data, headers, params
|
|
404
|
+
)
|
|
405
|
+
|
|
448
406
|
request_kwargs: dict[str, Any] = {
|
|
449
407
|
"data": request_data,
|
|
450
408
|
"headers": headers,
|
|
@@ -459,17 +417,9 @@ class Vapix:
|
|
|
459
417
|
response_content = await response.read()
|
|
460
418
|
return response.status, dict(response.headers), response_content
|
|
461
419
|
|
|
462
|
-
def _httpx_session(self) -> httpx.AsyncClient:
|
|
463
|
-
"""Return session cast to a httpx client."""
|
|
464
|
-
return cast("httpx.AsyncClient", self.device.config.session)
|
|
465
|
-
|
|
466
420
|
def _aiohttp_session(self) -> ClientSession:
|
|
467
421
|
"""Return session cast to an aiohttp client."""
|
|
468
|
-
return
|
|
469
|
-
|
|
470
|
-
def _httpx_auth(self) -> httpx.Auth | None:
|
|
471
|
-
"""Return auth cast for httpx requests."""
|
|
472
|
-
return cast("httpx.Auth | None", self.auth)
|
|
422
|
+
return self.device.config.session
|
|
473
423
|
|
|
474
424
|
def _aiohttp_auth(self) -> AiohttpBasicAuth | None:
|
|
475
425
|
"""Return auth cast for aiohttp requests."""
|
|
@@ -497,10 +447,8 @@ class Vapix:
|
|
|
497
447
|
)
|
|
498
448
|
|
|
499
449
|
def _basic_auth(self) -> object:
|
|
500
|
-
"""Create basic auth object
|
|
501
|
-
|
|
502
|
-
return self._aiohttp_basic_auth()
|
|
503
|
-
return httpx.BasicAuth(self.device.config.username, self.device.config.password)
|
|
450
|
+
"""Create basic auth object."""
|
|
451
|
+
return self._aiohttp_basic_auth()
|
|
504
452
|
|
|
505
453
|
def _aiohttp_basic_auth(self) -> object:
|
|
506
454
|
"""Create aiohttp basic auth object."""
|
|
@@ -508,12 +456,6 @@ class Vapix:
|
|
|
508
456
|
self.device.config.username, self.device.config.password
|
|
509
457
|
)
|
|
510
458
|
|
|
511
|
-
def _client_name(self) -> str:
|
|
512
|
-
"""Return normalized client name from configured session object."""
|
|
513
|
-
if isinstance(self.device.config.session, aiohttp.ClientSession):
|
|
514
|
-
return "aiohttp"
|
|
515
|
-
return "httpx"
|
|
516
|
-
|
|
517
459
|
def _should_retry_with_basic(
|
|
518
460
|
self, headers: dict[str, str], allow_auto_basic_retry: bool
|
|
519
461
|
) -> bool:
|
|
@@ -524,9 +466,6 @@ class Vapix:
|
|
|
524
466
|
if self.device.config.auth_scheme != AuthScheme.AUTO:
|
|
525
467
|
return False
|
|
526
468
|
|
|
527
|
-
if self._http_client == "httpx" and not isinstance(self.auth, httpx.DigestAuth):
|
|
528
|
-
return False
|
|
529
|
-
|
|
530
469
|
expected_auth = ""
|
|
531
470
|
for header_name, header_value in headers.items():
|
|
532
471
|
if header_name.lower() == "www-authenticate":
|
|
@@ -18,16 +18,22 @@ LOGGER = logging.getLogger(__name__)
|
|
|
18
18
|
class ApiId(enum.StrEnum):
|
|
19
19
|
"""The API discovery ID."""
|
|
20
20
|
|
|
21
|
+
AIR_QUALITY = "airquality"
|
|
21
22
|
ANALYTICS_METADATA_CONFIG = "analytics-metadata-config"
|
|
22
23
|
API_DISCOVERY = "api-discovery"
|
|
24
|
+
APPLICATION = "application"
|
|
23
25
|
AUDIO_ANALYTICS = "audio-analytics"
|
|
24
26
|
AUDIO_DEVICE_CONTROL = "audio-device-control"
|
|
25
27
|
AUDIO_MIXER = "audio-mixer"
|
|
26
28
|
AUDIO_STREAMING_CAPABILITIES = "audio-streaming-capabilities"
|
|
29
|
+
AUDIT_LOG = "audit-log"
|
|
27
30
|
BASIC_DEVICE_INFO = "basic-device-info"
|
|
28
31
|
CAPTURE_MODE = "capture-mode"
|
|
29
32
|
CUSTOM_HTTP_HEADER = "customhttpheader"
|
|
30
33
|
CUSTOM_FIRMWARE_CERTIFICATE = "custom-firmware-certificate"
|
|
34
|
+
DAY_NIGHT = "daynight"
|
|
35
|
+
DEVICE_CONFIG = "device-config"
|
|
36
|
+
DEVICE_SELF_TEST = "device-self-test"
|
|
31
37
|
DISK_MANAGEMENT = "disk-management"
|
|
32
38
|
DISK_NETWORK_SHARE = "disk-network-share"
|
|
33
39
|
DISK_PROPERTIES = "disk-properties"
|
|
@@ -40,6 +46,7 @@ class ApiId(enum.StrEnum):
|
|
|
40
46
|
IMAGE_SIZE = "image-size"
|
|
41
47
|
IO_PORT_MANAGEMENT = "io-port-management"
|
|
42
48
|
LIGHT_CONTROL = "light-control"
|
|
49
|
+
MEDIA_CGI = "media-cgi"
|
|
43
50
|
MEDIA_CLIP = "mediaclip"
|
|
44
51
|
MDNS_SD = "mdnssd"
|
|
45
52
|
MQTT_CLIENT = "mqtt-client"
|
|
@@ -48,10 +55,12 @@ class ApiId(enum.StrEnum):
|
|
|
48
55
|
NTP = "ntp"
|
|
49
56
|
OAK = "oak"
|
|
50
57
|
ON_SCREEN_CONTROLS = "onscreencontrols"
|
|
58
|
+
OPTICS_CONTROL = "optics-control"
|
|
51
59
|
OVERLAY_IMAGE = "overlayimage"
|
|
52
60
|
PACKAGE_MANAGER = "packagemanager"
|
|
53
61
|
PARAM_CGI = "param-cgi"
|
|
54
62
|
PIR_SENSOR_CONFIGURATION = "pir-sensor-configuration"
|
|
63
|
+
POWER_SETTINGS = "power-settings"
|
|
55
64
|
PRIVACY_MASK = "privacy-mask"
|
|
56
65
|
PTZ_CONTROL = "ptz-control"
|
|
57
66
|
RECORDING = "recording"
|
|
@@ -61,17 +70,23 @@ class ApiId(enum.StrEnum):
|
|
|
61
70
|
REMOTE_SERVICE = "remoteservice"
|
|
62
71
|
REMOTE_SYSLOG = "remote-syslog"
|
|
63
72
|
RTSP_OVER_WEBSOCKET = "rtsp-over-websocket"
|
|
73
|
+
SERIAL_PORT = "serial-port"
|
|
64
74
|
SHUTTERGAIN_CGI = "shuttergain-cgi"
|
|
65
75
|
SIGNED_VIDEO = "signed-video"
|
|
66
76
|
SIP = "sip"
|
|
67
77
|
SSH = "ssh"
|
|
78
|
+
STRAIGHTEN_IMAGE = "straightenimage"
|
|
68
79
|
STREAM_PROFILES = "stream-profiles"
|
|
80
|
+
STREAM_STATUS = "streamstatus"
|
|
81
|
+
SUPERVISED_IO = "supervised-io"
|
|
69
82
|
SYSTEM_READY = "systemready"
|
|
70
83
|
TEMPERATURE_CONTROL = "temperaturecontrol"
|
|
71
84
|
TIME_SERVICE = "time-service"
|
|
72
85
|
UPNP = "upnp"
|
|
73
86
|
USER_MANAGEMENT = "user-management"
|
|
87
|
+
VIDEO_STREAMING_INDICATOR = "video-streaming-indicator"
|
|
74
88
|
VIEW_AREA = "view-area"
|
|
89
|
+
WIDGET_OVERLAY = "widget-overlay"
|
|
75
90
|
ZIP_STREAM = "zipstream"
|
|
76
91
|
|
|
77
92
|
UNKNOWN = "unknown"
|
|
@@ -37,6 +37,7 @@ class StreamManager:
|
|
|
37
37
|
self.background_tasks: set[asyncio.Task[None]] = set()
|
|
38
38
|
self.retry_timer: asyncio.TimerHandle | None = None
|
|
39
39
|
self._starting = False
|
|
40
|
+
self._websocket_temporarily_disabled = False
|
|
40
41
|
|
|
41
42
|
@property
|
|
42
43
|
def stream_url(self) -> str:
|
|
@@ -84,6 +85,11 @@ class StreamManager:
|
|
|
84
85
|
"""Use websocket transport when event websocket API is available."""
|
|
85
86
|
if not self.event:
|
|
86
87
|
return False
|
|
88
|
+
if (
|
|
89
|
+
self._websocket_temporarily_disabled
|
|
90
|
+
and not self.device.config.websocket_force
|
|
91
|
+
):
|
|
92
|
+
return False
|
|
87
93
|
if self.device.config.websocket_force:
|
|
88
94
|
return True
|
|
89
95
|
return (
|
|
@@ -91,6 +97,25 @@ class StreamManager:
|
|
|
91
97
|
and WebSocketClient.supported_by_device(self.device)
|
|
92
98
|
)
|
|
93
99
|
|
|
100
|
+
def _handle_websocket_failure(self) -> None:
|
|
101
|
+
"""Disable websocket for runtime when TLS certificate validation fails."""
|
|
102
|
+
if self.device.config.websocket_force:
|
|
103
|
+
return
|
|
104
|
+
|
|
105
|
+
if self.stream is None:
|
|
106
|
+
return
|
|
107
|
+
|
|
108
|
+
if not getattr(self.stream, "should_disable_runtime_websocket", False):
|
|
109
|
+
return
|
|
110
|
+
|
|
111
|
+
if not self._websocket_temporarily_disabled:
|
|
112
|
+
_LOGGER.warning(
|
|
113
|
+
"Disabling websocket events for %s until restart after certificate verification failure",
|
|
114
|
+
self.device.config.host,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
self._websocket_temporarily_disabled = True
|
|
118
|
+
|
|
94
119
|
@property
|
|
95
120
|
def _is_stream_stopped(self) -> bool:
|
|
96
121
|
"""Return True when stream is missing or currently stopped."""
|
|
@@ -124,6 +149,7 @@ class StreamManager:
|
|
|
124
149
|
self.device.event.handler(self.data)
|
|
125
150
|
|
|
126
151
|
elif signal == Signal.FAILED:
|
|
152
|
+
self._handle_websocket_failure()
|
|
127
153
|
self.retry()
|
|
128
154
|
|
|
129
155
|
if signal in (Signal.PLAYING, Signal.FAILED):
|