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.
Files changed (115) hide show
  1. {axis-68 → axis-70}/PKG-INFO +8 -11
  2. {axis-68 → axis-70}/axis/__main__.py +1 -5
  3. axis-70/axis/interfaces/aiohttp_digest.py +266 -0
  4. {axis-68 → axis-70}/axis/interfaces/vapix.py +20 -81
  5. {axis-68 → axis-70}/axis/models/api_discovery.py +15 -0
  6. {axis-68 → axis-70}/axis/models/configuration.py +1 -2
  7. {axis-68 → axis-70}/axis/stream_manager.py +26 -0
  8. {axis-68 → axis-70}/axis/websocket.py +69 -11
  9. {axis-68 → axis-70}/axis.egg-info/PKG-INFO +8 -11
  10. {axis-68 → axis-70}/axis.egg-info/SOURCES.txt +2 -0
  11. {axis-68 → axis-70}/axis.egg-info/requires.txt +7 -10
  12. {axis-68 → axis-70}/pyproject.toml +9 -12
  13. {axis-68 → axis-70}/tests/test_api_discovery.py +8 -17
  14. axis-70/tests/test_auth_scheme.py +125 -0
  15. {axis-68 → axis-70}/tests/test_basic_device_info.py +12 -21
  16. {axis-68 → axis-70}/tests/test_configuration.py +20 -19
  17. axis-70/tests/test_conftest.py +164 -0
  18. {axis-68 → axis-70}/tests/test_event_instances.py +11 -5
  19. axis-70/tests/test_http_client_compat.py +374 -0
  20. {axis-68 → axis-70}/tests/test_light_control.py +52 -48
  21. {axis-68 → axis-70}/tests/test_mqtt.py +16 -16
  22. {axis-68 → axis-70}/tests/test_pir_sensor_configuration.py +11 -9
  23. {axis-68 → axis-70}/tests/test_port_cgi.py +17 -33
  24. {axis-68 → axis-70}/tests/test_port_management.py +15 -12
  25. {axis-68 → axis-70}/tests/test_ptz.py +86 -78
  26. {axis-68 → axis-70}/tests/test_pwdgrp_cgi.py +14 -18
  27. {axis-68 → axis-70}/tests/test_stream_manager.py +75 -0
  28. {axis-68 → axis-70}/tests/test_stream_profiles.py +29 -18
  29. {axis-68 → axis-70}/tests/test_user_groups.py +40 -15
  30. {axis-68 → axis-70}/tests/test_vapix.py +105 -79
  31. {axis-68 → axis-70}/tests/test_view_areas.py +26 -22
  32. {axis-68 → axis-70}/tests/test_websocket.py +97 -30
  33. axis-68/tests/test_auth_scheme.py +0 -86
  34. axis-68/tests/test_http_client_compat.py +0 -165
  35. {axis-68 → axis-70}/LICENSE +0 -0
  36. {axis-68 → axis-70}/README.md +0 -0
  37. {axis-68 → axis-70}/axis/__init__.py +0 -0
  38. {axis-68 → axis-70}/axis/device.py +0 -0
  39. {axis-68 → axis-70}/axis/errors.py +0 -0
  40. {axis-68 → axis-70}/axis/interfaces/__init__.py +0 -0
  41. {axis-68 → axis-70}/axis/interfaces/api_discovery.py +0 -0
  42. {axis-68 → axis-70}/axis/interfaces/api_handler.py +0 -0
  43. {axis-68 → axis-70}/axis/interfaces/applications/__init__.py +0 -0
  44. {axis-68 → axis-70}/axis/interfaces/applications/application_handler.py +0 -0
  45. {axis-68 → axis-70}/axis/interfaces/applications/applications.py +0 -0
  46. {axis-68 → axis-70}/axis/interfaces/applications/fence_guard.py +0 -0
  47. {axis-68 → axis-70}/axis/interfaces/applications/loitering_guard.py +0 -0
  48. {axis-68 → axis-70}/axis/interfaces/applications/motion_guard.py +0 -0
  49. {axis-68 → axis-70}/axis/interfaces/applications/object_analytics.py +0 -0
  50. {axis-68 → axis-70}/axis/interfaces/applications/vmd4.py +0 -0
  51. {axis-68 → axis-70}/axis/interfaces/basic_device_info.py +0 -0
  52. {axis-68 → axis-70}/axis/interfaces/event_instances.py +0 -0
  53. {axis-68 → axis-70}/axis/interfaces/event_manager.py +0 -0
  54. {axis-68 → axis-70}/axis/interfaces/light_control.py +0 -0
  55. {axis-68 → axis-70}/axis/interfaces/mqtt.py +0 -0
  56. {axis-68 → axis-70}/axis/interfaces/parameters/__init__.py +0 -0
  57. {axis-68 → axis-70}/axis/interfaces/parameters/brand.py +0 -0
  58. {axis-68 → axis-70}/axis/interfaces/parameters/image.py +0 -0
  59. {axis-68 → axis-70}/axis/interfaces/parameters/io_port.py +0 -0
  60. {axis-68 → axis-70}/axis/interfaces/parameters/param_cgi.py +0 -0
  61. {axis-68 → axis-70}/axis/interfaces/parameters/param_handler.py +0 -0
  62. {axis-68 → axis-70}/axis/interfaces/parameters/properties.py +0 -0
  63. {axis-68 → axis-70}/axis/interfaces/parameters/ptz.py +0 -0
  64. {axis-68 → axis-70}/axis/interfaces/parameters/stream_profile.py +0 -0
  65. {axis-68 → axis-70}/axis/interfaces/pir_sensor_configuration.py +0 -0
  66. {axis-68 → axis-70}/axis/interfaces/port_cgi.py +0 -0
  67. {axis-68 → axis-70}/axis/interfaces/port_management.py +0 -0
  68. {axis-68 → axis-70}/axis/interfaces/ptz.py +0 -0
  69. {axis-68 → axis-70}/axis/interfaces/pwdgrp_cgi.py +0 -0
  70. {axis-68 → axis-70}/axis/interfaces/stream_profiles.py +0 -0
  71. {axis-68 → axis-70}/axis/interfaces/user_groups.py +0 -0
  72. {axis-68 → axis-70}/axis/interfaces/view_areas.py +0 -0
  73. {axis-68 → axis-70}/axis/models/__init__.py +0 -0
  74. {axis-68 → axis-70}/axis/models/api.py +0 -0
  75. {axis-68 → axis-70}/axis/models/applications/__init__.py +0 -0
  76. {axis-68 → axis-70}/axis/models/applications/application.py +0 -0
  77. {axis-68 → axis-70}/axis/models/applications/fence_guard.py +0 -0
  78. {axis-68 → axis-70}/axis/models/applications/loitering_guard.py +0 -0
  79. {axis-68 → axis-70}/axis/models/applications/motion_guard.py +0 -0
  80. {axis-68 → axis-70}/axis/models/applications/object_analytics.py +0 -0
  81. {axis-68 → axis-70}/axis/models/applications/vmd4.py +0 -0
  82. {axis-68 → axis-70}/axis/models/basic_device_info.py +0 -0
  83. {axis-68 → axis-70}/axis/models/event.py +0 -0
  84. {axis-68 → axis-70}/axis/models/event_instance.py +0 -0
  85. {axis-68 → axis-70}/axis/models/light_control.py +0 -0
  86. {axis-68 → axis-70}/axis/models/mqtt.py +0 -0
  87. {axis-68 → axis-70}/axis/models/parameters/__init__.py +0 -0
  88. {axis-68 → axis-70}/axis/models/parameters/brand.py +0 -0
  89. {axis-68 → axis-70}/axis/models/parameters/image.py +0 -0
  90. {axis-68 → axis-70}/axis/models/parameters/io_port.py +0 -0
  91. {axis-68 → axis-70}/axis/models/parameters/param_cgi.py +0 -0
  92. {axis-68 → axis-70}/axis/models/parameters/properties.py +0 -0
  93. {axis-68 → axis-70}/axis/models/parameters/ptz.py +0 -0
  94. {axis-68 → axis-70}/axis/models/parameters/stream_profile.py +0 -0
  95. {axis-68 → axis-70}/axis/models/pir_sensor_configuration.py +0 -0
  96. {axis-68 → axis-70}/axis/models/port_cgi.py +0 -0
  97. {axis-68 → axis-70}/axis/models/port_management.py +0 -0
  98. {axis-68 → axis-70}/axis/models/ptz_cgi.py +0 -0
  99. {axis-68 → axis-70}/axis/models/pwdgrp_cgi.py +0 -0
  100. {axis-68 → axis-70}/axis/models/stream_profile.py +0 -0
  101. {axis-68 → axis-70}/axis/models/user_group.py +0 -0
  102. {axis-68 → axis-70}/axis/models/view_area.py +0 -0
  103. {axis-68 → axis-70}/axis/py.typed +0 -0
  104. {axis-68 → axis-70}/axis/rtsp.py +0 -0
  105. {axis-68 → axis-70}/axis/stream_transport.py +0 -0
  106. {axis-68 → axis-70}/axis.egg-info/dependency_links.txt +0 -0
  107. {axis-68 → axis-70}/axis.egg-info/entry_points.txt +0 -0
  108. {axis-68 → axis-70}/axis.egg-info/top_level.txt +0 -0
  109. {axis-68 → axis-70}/setup.cfg +0 -0
  110. {axis-68 → axis-70}/tests/test_api_handler.py +0 -0
  111. {axis-68 → axis-70}/tests/test_device.py +0 -0
  112. {axis-68 → axis-70}/tests/test_event.py +0 -0
  113. {axis-68 → axis-70}/tests/test_event_stream.py +0 -0
  114. {axis-68 → axis-70}/tests/test_main_http_client.py +0 -0
  115. {axis-68 → axis-70}/tests/test_rtsp.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: axis
3
- Version: 68
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: httpx==0.28.1; extra == "requirements"
29
- Requires-Dist: orjson==3.11.8; extra == "requirements"
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==1.20.0; extra == "requirements-test"
34
- Requires-Dist: pytest==9.0.2; extra == "requirements-test"
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: respx==0.22.0; extra == "requirements-test"
39
- Requires-Dist: ruff==0.15.9; extra == "requirements-test"
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.5.1; extra == "requirements-dev"
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
- session = device.config.session
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 self._http_client == "aiohttp":
61
- if device.config.auth_scheme == AuthScheme.BASIC:
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 = httpx.DigestAuth(device.config.username, device.config.password)
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 from supported HTTP clients."""
393
- if self._http_client == "aiohttp":
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 cast("ClientSession", self.device.config.session)
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 for configured HTTP client."""
501
- if self._http_client == "aiohttp":
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"
@@ -7,9 +7,8 @@ from typing import TYPE_CHECKING
7
7
 
8
8
  if TYPE_CHECKING:
9
9
  from aiohttp import ClientSession
10
- from httpx import AsyncClient
11
10
 
12
- type HTTPSession = AsyncClient | ClientSession
11
+ type HTTPSession = ClientSession
13
12
 
14
13
 
15
14
  LOGGER = logging.getLogger(__name__)
@@ -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):