axis 69__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-69 → axis-70}/PKG-INFO +3 -6
- {axis-69 → axis-70}/axis/__main__.py +1 -5
- {axis-69 → axis-70}/axis/interfaces/aiohttp_digest.py +1 -1
- {axis-69 → axis-70}/axis/interfaces/vapix.py +10 -83
- {axis-69 → axis-70}/axis/models/configuration.py +1 -2
- {axis-69 → axis-70}/axis/stream_manager.py +26 -0
- {axis-69 → axis-70}/axis/websocket.py +69 -11
- {axis-69 → axis-70}/axis.egg-info/PKG-INFO +3 -6
- {axis-69 → axis-70}/axis.egg-info/SOURCES.txt +1 -0
- {axis-69 → axis-70}/axis.egg-info/requires.txt +2 -5
- {axis-69 → axis-70}/pyproject.toml +3 -6
- {axis-69 → axis-70}/tests/test_api_discovery.py +8 -17
- axis-70/tests/test_auth_scheme.py +125 -0
- {axis-69 → axis-70}/tests/test_basic_device_info.py +12 -21
- {axis-69 → axis-70}/tests/test_configuration.py +20 -19
- axis-70/tests/test_conftest.py +164 -0
- {axis-69 → axis-70}/tests/test_event_instances.py +11 -5
- axis-70/tests/test_http_client_compat.py +374 -0
- {axis-69 → axis-70}/tests/test_light_control.py +52 -48
- {axis-69 → axis-70}/tests/test_mqtt.py +16 -16
- {axis-69 → axis-70}/tests/test_pir_sensor_configuration.py +11 -9
- {axis-69 → axis-70}/tests/test_port_cgi.py +17 -33
- {axis-69 → axis-70}/tests/test_port_management.py +15 -12
- {axis-69 → axis-70}/tests/test_ptz.py +86 -78
- {axis-69 → axis-70}/tests/test_pwdgrp_cgi.py +14 -18
- {axis-69 → axis-70}/tests/test_stream_manager.py +75 -0
- {axis-69 → axis-70}/tests/test_stream_profiles.py +29 -18
- {axis-69 → axis-70}/tests/test_user_groups.py +40 -15
- {axis-69 → axis-70}/tests/test_vapix.py +105 -79
- {axis-69 → axis-70}/tests/test_view_areas.py +26 -22
- {axis-69 → axis-70}/tests/test_websocket.py +97 -30
- axis-69/tests/test_auth_scheme.py +0 -86
- axis-69/tests/test_http_client_compat.py +0 -393
- {axis-69 → axis-70}/LICENSE +0 -0
- {axis-69 → axis-70}/README.md +0 -0
- {axis-69 → axis-70}/axis/__init__.py +0 -0
- {axis-69 → axis-70}/axis/device.py +0 -0
- {axis-69 → axis-70}/axis/errors.py +0 -0
- {axis-69 → axis-70}/axis/interfaces/__init__.py +0 -0
- {axis-69 → axis-70}/axis/interfaces/api_discovery.py +0 -0
- {axis-69 → axis-70}/axis/interfaces/api_handler.py +0 -0
- {axis-69 → axis-70}/axis/interfaces/applications/__init__.py +0 -0
- {axis-69 → axis-70}/axis/interfaces/applications/application_handler.py +0 -0
- {axis-69 → axis-70}/axis/interfaces/applications/applications.py +0 -0
- {axis-69 → axis-70}/axis/interfaces/applications/fence_guard.py +0 -0
- {axis-69 → axis-70}/axis/interfaces/applications/loitering_guard.py +0 -0
- {axis-69 → axis-70}/axis/interfaces/applications/motion_guard.py +0 -0
- {axis-69 → axis-70}/axis/interfaces/applications/object_analytics.py +0 -0
- {axis-69 → axis-70}/axis/interfaces/applications/vmd4.py +0 -0
- {axis-69 → axis-70}/axis/interfaces/basic_device_info.py +0 -0
- {axis-69 → axis-70}/axis/interfaces/event_instances.py +0 -0
- {axis-69 → axis-70}/axis/interfaces/event_manager.py +0 -0
- {axis-69 → axis-70}/axis/interfaces/light_control.py +0 -0
- {axis-69 → axis-70}/axis/interfaces/mqtt.py +0 -0
- {axis-69 → axis-70}/axis/interfaces/parameters/__init__.py +0 -0
- {axis-69 → axis-70}/axis/interfaces/parameters/brand.py +0 -0
- {axis-69 → axis-70}/axis/interfaces/parameters/image.py +0 -0
- {axis-69 → axis-70}/axis/interfaces/parameters/io_port.py +0 -0
- {axis-69 → axis-70}/axis/interfaces/parameters/param_cgi.py +0 -0
- {axis-69 → axis-70}/axis/interfaces/parameters/param_handler.py +0 -0
- {axis-69 → axis-70}/axis/interfaces/parameters/properties.py +0 -0
- {axis-69 → axis-70}/axis/interfaces/parameters/ptz.py +0 -0
- {axis-69 → axis-70}/axis/interfaces/parameters/stream_profile.py +0 -0
- {axis-69 → axis-70}/axis/interfaces/pir_sensor_configuration.py +0 -0
- {axis-69 → axis-70}/axis/interfaces/port_cgi.py +0 -0
- {axis-69 → axis-70}/axis/interfaces/port_management.py +0 -0
- {axis-69 → axis-70}/axis/interfaces/ptz.py +0 -0
- {axis-69 → axis-70}/axis/interfaces/pwdgrp_cgi.py +0 -0
- {axis-69 → axis-70}/axis/interfaces/stream_profiles.py +0 -0
- {axis-69 → axis-70}/axis/interfaces/user_groups.py +0 -0
- {axis-69 → axis-70}/axis/interfaces/view_areas.py +0 -0
- {axis-69 → axis-70}/axis/models/__init__.py +0 -0
- {axis-69 → axis-70}/axis/models/api.py +0 -0
- {axis-69 → axis-70}/axis/models/api_discovery.py +0 -0
- {axis-69 → axis-70}/axis/models/applications/__init__.py +0 -0
- {axis-69 → axis-70}/axis/models/applications/application.py +0 -0
- {axis-69 → axis-70}/axis/models/applications/fence_guard.py +0 -0
- {axis-69 → axis-70}/axis/models/applications/loitering_guard.py +0 -0
- {axis-69 → axis-70}/axis/models/applications/motion_guard.py +0 -0
- {axis-69 → axis-70}/axis/models/applications/object_analytics.py +0 -0
- {axis-69 → axis-70}/axis/models/applications/vmd4.py +0 -0
- {axis-69 → axis-70}/axis/models/basic_device_info.py +0 -0
- {axis-69 → axis-70}/axis/models/event.py +0 -0
- {axis-69 → axis-70}/axis/models/event_instance.py +0 -0
- {axis-69 → axis-70}/axis/models/light_control.py +0 -0
- {axis-69 → axis-70}/axis/models/mqtt.py +0 -0
- {axis-69 → axis-70}/axis/models/parameters/__init__.py +0 -0
- {axis-69 → axis-70}/axis/models/parameters/brand.py +0 -0
- {axis-69 → axis-70}/axis/models/parameters/image.py +0 -0
- {axis-69 → axis-70}/axis/models/parameters/io_port.py +0 -0
- {axis-69 → axis-70}/axis/models/parameters/param_cgi.py +0 -0
- {axis-69 → axis-70}/axis/models/parameters/properties.py +0 -0
- {axis-69 → axis-70}/axis/models/parameters/ptz.py +0 -0
- {axis-69 → axis-70}/axis/models/parameters/stream_profile.py +0 -0
- {axis-69 → axis-70}/axis/models/pir_sensor_configuration.py +0 -0
- {axis-69 → axis-70}/axis/models/port_cgi.py +0 -0
- {axis-69 → axis-70}/axis/models/port_management.py +0 -0
- {axis-69 → axis-70}/axis/models/ptz_cgi.py +0 -0
- {axis-69 → axis-70}/axis/models/pwdgrp_cgi.py +0 -0
- {axis-69 → axis-70}/axis/models/stream_profile.py +0 -0
- {axis-69 → axis-70}/axis/models/user_group.py +0 -0
- {axis-69 → axis-70}/axis/models/view_area.py +0 -0
- {axis-69 → axis-70}/axis/py.typed +0 -0
- {axis-69 → axis-70}/axis/rtsp.py +0 -0
- {axis-69 → axis-70}/axis/stream_transport.py +0 -0
- {axis-69 → axis-70}/axis.egg-info/dependency_links.txt +0 -0
- {axis-69 → axis-70}/axis.egg-info/entry_points.txt +0 -0
- {axis-69 → axis-70}/axis.egg-info/top_level.txt +0 -0
- {axis-69 → axis-70}/setup.cfg +0 -0
- {axis-69 → axis-70}/tests/test_api_handler.py +0 -0
- {axis-69 → axis-70}/tests/test_device.py +0 -0
- {axis-69 → axis-70}/tests/test_event.py +0 -0
- {axis-69 → axis-70}/tests/test_event_stream.py +0 -0
- {axis-69 → axis-70}/tests/test_main_http_client.py +0 -0
- {axis-69 → axis-70}/tests/test_rtsp.py +0 -0
{axis-69 → 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,23 +19,20 @@ 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: orjson==3.11.8; extra == "requirements"
|
|
27
|
+
Requires-Dist: orjson==3.11.9; extra == "requirements"
|
|
30
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==
|
|
31
|
+
Requires-Dist: mypy==2.0.0; extra == "requirements-test"
|
|
34
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.23.1; extra == "requirements-test"
|
|
39
36
|
Requires-Dist: ruff==0.15.12; extra == "requirements-test"
|
|
40
37
|
Requires-Dist: types-xmltodict==v1.0.1.20260408; extra == "requirements-test"
|
|
41
38
|
Provides-Extra: requirements-dev
|
|
@@ -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
|
|
|
@@ -35,7 +35,7 @@ class AiohttpDigestAuth:
|
|
|
35
35
|
"""Return if aiohttp requests should use library-managed digest auth.
|
|
36
36
|
|
|
37
37
|
Args:
|
|
38
|
-
http_client: Name of HTTP client
|
|
38
|
+
http_client: Name of HTTP client.
|
|
39
39
|
has_basic_auth: Whether basic auth is configured.
|
|
40
40
|
|
|
41
41
|
Returns:
|
|
@@ -7,7 +7,6 @@ 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
|
|
@@ -55,20 +54,14 @@ class Vapix:
|
|
|
55
54
|
def __init__(self, device: AxisDevice) -> None:
|
|
56
55
|
"""Store local reference to device config."""
|
|
57
56
|
self.device = device
|
|
58
|
-
self._http_client = self._client_name()
|
|
59
57
|
self._aiohttp_digest_middleware: Any | None = None
|
|
60
58
|
self._aiohttp_digest_auth = AiohttpDigestAuth(device)
|
|
61
59
|
|
|
62
|
-
if
|
|
63
|
-
|
|
64
|
-
self.auth = self._aiohttp_basic_auth()
|
|
65
|
-
else:
|
|
66
|
-
self.auth = None
|
|
67
|
-
self._aiohttp_digest_middleware = self._aiohttp_digest_middleware_obj()
|
|
68
|
-
elif device.config.auth_scheme == AuthScheme.BASIC:
|
|
69
|
-
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()
|
|
70
62
|
else:
|
|
71
|
-
self.auth =
|
|
63
|
+
self.auth = None
|
|
64
|
+
self._aiohttp_digest_middleware = self._aiohttp_digest_middleware_obj()
|
|
72
65
|
|
|
73
66
|
# Grouped handlers are registered in handler-construction order.
|
|
74
67
|
# Handlers with empty handler_groups are intentionally excluded.
|
|
@@ -327,20 +320,6 @@ class Vapix:
|
|
|
327
320
|
params=params,
|
|
328
321
|
)
|
|
329
322
|
|
|
330
|
-
except httpx.TimeoutException as errt:
|
|
331
|
-
message = "Timeout"
|
|
332
|
-
raise RequestError(message) from errt
|
|
333
|
-
|
|
334
|
-
except httpx.TransportError as errc:
|
|
335
|
-
LOGGER.debug("%s", errc)
|
|
336
|
-
message = f"Connection error: {errc}"
|
|
337
|
-
raise RequestError(message) from errc
|
|
338
|
-
|
|
339
|
-
except httpx.RequestError as err:
|
|
340
|
-
LOGGER.debug("%s", err)
|
|
341
|
-
message = f"Unknown error: {err}"
|
|
342
|
-
raise RequestError(message) from err
|
|
343
|
-
|
|
344
323
|
except TimeoutError as errt:
|
|
345
324
|
message = "Timeout"
|
|
346
325
|
raise RequestError(message) from errt
|
|
@@ -391,17 +370,8 @@ class Vapix:
|
|
|
391
370
|
headers: dict[str, str] | None,
|
|
392
371
|
params: dict[str, str] | None,
|
|
393
372
|
) -> tuple[int, dict[str, str], bytes]:
|
|
394
|
-
"""Execute request and normalize responses
|
|
395
|
-
|
|
396
|
-
return await self._perform_aiohttp_request(
|
|
397
|
-
method=method,
|
|
398
|
-
url=url,
|
|
399
|
-
content=content,
|
|
400
|
-
data=data,
|
|
401
|
-
headers=headers,
|
|
402
|
-
params=params,
|
|
403
|
-
)
|
|
404
|
-
return await self._perform_httpx_request(
|
|
373
|
+
"""Execute request and normalize responses."""
|
|
374
|
+
return await self._perform_aiohttp_request(
|
|
405
375
|
method=method,
|
|
406
376
|
url=url,
|
|
407
377
|
content=content,
|
|
@@ -410,29 +380,6 @@ class Vapix:
|
|
|
410
380
|
params=params,
|
|
411
381
|
)
|
|
412
382
|
|
|
413
|
-
async def _perform_httpx_request(
|
|
414
|
-
self,
|
|
415
|
-
method: str,
|
|
416
|
-
url: str,
|
|
417
|
-
content: bytes | None,
|
|
418
|
-
data: dict[str, str] | None,
|
|
419
|
-
headers: dict[str, str] | None,
|
|
420
|
-
params: dict[str, str] | None,
|
|
421
|
-
) -> tuple[int, dict[str, str], bytes]:
|
|
422
|
-
"""Execute request with a httpx session."""
|
|
423
|
-
session = self._httpx_session()
|
|
424
|
-
response = await session.request(
|
|
425
|
-
method,
|
|
426
|
-
url,
|
|
427
|
-
content=content,
|
|
428
|
-
data=data,
|
|
429
|
-
headers=headers,
|
|
430
|
-
params=params,
|
|
431
|
-
auth=self._httpx_auth(),
|
|
432
|
-
timeout=TIME_OUT,
|
|
433
|
-
)
|
|
434
|
-
return response.status_code, dict(response.headers), response.content
|
|
435
|
-
|
|
436
383
|
async def _perform_aiohttp_request(
|
|
437
384
|
self,
|
|
438
385
|
method: str,
|
|
@@ -449,8 +396,7 @@ class Vapix:
|
|
|
449
396
|
session = self._aiohttp_session()
|
|
450
397
|
|
|
451
398
|
if (
|
|
452
|
-
self.
|
|
453
|
-
and not self._aiohttp_auth()
|
|
399
|
+
not self._aiohttp_auth()
|
|
454
400
|
and self.device.config.auth_scheme != AuthScheme.BASIC
|
|
455
401
|
):
|
|
456
402
|
return await self._aiohttp_digest_auth.perform_request(
|
|
@@ -471,17 +417,9 @@ class Vapix:
|
|
|
471
417
|
response_content = await response.read()
|
|
472
418
|
return response.status, dict(response.headers), response_content
|
|
473
419
|
|
|
474
|
-
def _httpx_session(self) -> httpx.AsyncClient:
|
|
475
|
-
"""Return session cast to a httpx client."""
|
|
476
|
-
return cast("httpx.AsyncClient", self.device.config.session)
|
|
477
|
-
|
|
478
420
|
def _aiohttp_session(self) -> ClientSession:
|
|
479
421
|
"""Return session cast to an aiohttp client."""
|
|
480
|
-
return
|
|
481
|
-
|
|
482
|
-
def _httpx_auth(self) -> httpx.Auth | None:
|
|
483
|
-
"""Return auth cast for httpx requests."""
|
|
484
|
-
return cast("httpx.Auth | None", self.auth)
|
|
422
|
+
return self.device.config.session
|
|
485
423
|
|
|
486
424
|
def _aiohttp_auth(self) -> AiohttpBasicAuth | None:
|
|
487
425
|
"""Return auth cast for aiohttp requests."""
|
|
@@ -509,10 +447,8 @@ class Vapix:
|
|
|
509
447
|
)
|
|
510
448
|
|
|
511
449
|
def _basic_auth(self) -> object:
|
|
512
|
-
"""Create basic auth object
|
|
513
|
-
|
|
514
|
-
return self._aiohttp_basic_auth()
|
|
515
|
-
return httpx.BasicAuth(self.device.config.username, self.device.config.password)
|
|
450
|
+
"""Create basic auth object."""
|
|
451
|
+
return self._aiohttp_basic_auth()
|
|
516
452
|
|
|
517
453
|
def _aiohttp_basic_auth(self) -> object:
|
|
518
454
|
"""Create aiohttp basic auth object."""
|
|
@@ -520,12 +456,6 @@ class Vapix:
|
|
|
520
456
|
self.device.config.username, self.device.config.password
|
|
521
457
|
)
|
|
522
458
|
|
|
523
|
-
def _client_name(self) -> str:
|
|
524
|
-
"""Return normalized client name from configured session object."""
|
|
525
|
-
if isinstance(self.device.config.session, aiohttp.ClientSession):
|
|
526
|
-
return "aiohttp"
|
|
527
|
-
return "httpx"
|
|
528
|
-
|
|
529
459
|
def _should_retry_with_basic(
|
|
530
460
|
self, headers: dict[str, str], allow_auto_basic_retry: bool
|
|
531
461
|
) -> bool:
|
|
@@ -536,9 +466,6 @@ class Vapix:
|
|
|
536
466
|
if self.device.config.auth_scheme != AuthScheme.AUTO:
|
|
537
467
|
return False
|
|
538
468
|
|
|
539
|
-
if self._http_client == "httpx" and not isinstance(self.auth, httpx.DigestAuth):
|
|
540
|
-
return False
|
|
541
|
-
|
|
542
469
|
expected_auth = ""
|
|
543
470
|
for header_name, header_value in headers.items():
|
|
544
471
|
if header_name.lower() == "www-authenticate":
|
|
@@ -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):
|
|
@@ -13,7 +13,9 @@ from __future__ import annotations
|
|
|
13
13
|
|
|
14
14
|
import asyncio
|
|
15
15
|
from collections import deque
|
|
16
|
+
import enum
|
|
16
17
|
import logging
|
|
18
|
+
import ssl
|
|
17
19
|
from time import time
|
|
18
20
|
from typing import TYPE_CHECKING, Any
|
|
19
21
|
|
|
@@ -48,6 +50,46 @@ RECEIVE_TIMEOUT = (
|
|
|
48
50
|
BUFFER_SIZE = 200
|
|
49
51
|
|
|
50
52
|
|
|
53
|
+
class WebSocketFailureReason(enum.StrEnum):
|
|
54
|
+
"""Classified websocket startup failure reason."""
|
|
55
|
+
|
|
56
|
+
NONE = "none"
|
|
57
|
+
SSL_CERTIFICATE = "ssl_certificate"
|
|
58
|
+
OTHER = "other"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _walk_exception_chain(err: BaseException) -> list[BaseException]:
|
|
62
|
+
"""Return exceptions in causal chain for robust error classification."""
|
|
63
|
+
chain: list[BaseException] = []
|
|
64
|
+
seen: set[int] = set()
|
|
65
|
+
current: BaseException | None = err
|
|
66
|
+
|
|
67
|
+
while current is not None and id(current) not in seen:
|
|
68
|
+
chain.append(current)
|
|
69
|
+
seen.add(id(current))
|
|
70
|
+
current = current.__cause__ or current.__context__
|
|
71
|
+
|
|
72
|
+
return chain
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _classify_connect_error(err: BaseException) -> WebSocketFailureReason:
|
|
76
|
+
"""Classify websocket connect failure for fallback decisions."""
|
|
77
|
+
for exc in _walk_exception_chain(err):
|
|
78
|
+
if isinstance(
|
|
79
|
+
exc,
|
|
80
|
+
(
|
|
81
|
+
ssl.SSLCertVerificationError,
|
|
82
|
+
aiohttp.ClientConnectorCertificateError,
|
|
83
|
+
),
|
|
84
|
+
):
|
|
85
|
+
return WebSocketFailureReason.SSL_CERTIFICATE
|
|
86
|
+
|
|
87
|
+
if "CERTIFICATE_VERIFY_FAILED" in str(exc):
|
|
88
|
+
return WebSocketFailureReason.SSL_CERTIFICATE
|
|
89
|
+
|
|
90
|
+
return WebSocketFailureReason.OTHER
|
|
91
|
+
|
|
92
|
+
|
|
51
93
|
def _parse_ws_notification(notification: dict[str, Any]) -> dict[str, Any]:
|
|
52
94
|
"""Parse a VAPIX events:notify notification into the internal event dict format.
|
|
53
95
|
|
|
@@ -135,12 +177,14 @@ class WebSocketClient:
|
|
|
135
177
|
self._data: deque[dict[str, Any]] = deque(maxlen=BUFFER_SIZE)
|
|
136
178
|
|
|
137
179
|
self._ws_session: aiohttp.ClientSession | None = None
|
|
180
|
+
self._owns_ws_session = False
|
|
138
181
|
self._ws: aiohttp.ClientWebSocketResponse | None = None
|
|
139
182
|
self._receiver_task: asyncio.Task[None] | None = None
|
|
140
183
|
self._close_task: asyncio.Task[None] | None = None
|
|
141
184
|
self._stopped = False
|
|
142
185
|
self._starting = False
|
|
143
186
|
self._start_time: float | None = None
|
|
187
|
+
self._last_failure_reason = WebSocketFailureReason.NONE
|
|
144
188
|
|
|
145
189
|
@classmethod
|
|
146
190
|
def supported_by_device(cls, device: AxisDevice) -> bool:
|
|
@@ -155,6 +199,11 @@ class WebSocketClient:
|
|
|
155
199
|
except IndexError:
|
|
156
200
|
return {}
|
|
157
201
|
|
|
202
|
+
@property
|
|
203
|
+
def should_disable_runtime_websocket(self) -> bool:
|
|
204
|
+
"""Return true if websocket should be disabled for this runtime."""
|
|
205
|
+
return self._last_failure_reason == WebSocketFailureReason.SSL_CERTIFICATE
|
|
206
|
+
|
|
158
207
|
async def _get_session_token(self) -> str | None:
|
|
159
208
|
"""Obtain a short-lived session token for websocket authentication.
|
|
160
209
|
|
|
@@ -178,32 +227,39 @@ class WebSocketClient:
|
|
|
178
227
|
self._stopped = False
|
|
179
228
|
self.session.state = State.STARTING
|
|
180
229
|
self._start_time = time()
|
|
230
|
+
self._last_failure_reason = WebSocketFailureReason.NONE
|
|
181
231
|
|
|
182
232
|
try:
|
|
183
233
|
if self._close_task is not None:
|
|
184
234
|
await asyncio.shield(self._close_task)
|
|
185
235
|
|
|
186
236
|
token = await self._get_session_token()
|
|
237
|
+
self._ws_session = self.device.config.session
|
|
238
|
+
self._owns_ws_session = False
|
|
239
|
+
|
|
240
|
+
ws_connect_kwargs: dict[str, Any] = {
|
|
241
|
+
"heartbeat": HEARTBEAT_INTERVAL,
|
|
242
|
+
"timeout": self._ws_timeout,
|
|
243
|
+
}
|
|
244
|
+
if not self.device.config.verify_ssl:
|
|
245
|
+
ws_connect_kwargs["ssl"] = False
|
|
246
|
+
|
|
187
247
|
if token:
|
|
188
248
|
connect_url = f"{self.url}&wssession={token}"
|
|
189
|
-
self._ws_session = aiohttp.ClientSession()
|
|
190
249
|
else:
|
|
191
250
|
# Fall back to HTTP Basic auth in the upgrade handshake.
|
|
192
251
|
connect_url = self.url
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
self.device.config.password,
|
|
197
|
-
),
|
|
252
|
+
ws_connect_kwargs["auth"] = aiohttp.BasicAuth(
|
|
253
|
+
self.device.config.username,
|
|
254
|
+
self.device.config.password,
|
|
198
255
|
)
|
|
199
256
|
|
|
200
257
|
self._ws = await self._ws_session.ws_connect(
|
|
201
|
-
connect_url,
|
|
202
|
-
heartbeat=HEARTBEAT_INTERVAL,
|
|
203
|
-
timeout=self._ws_timeout,
|
|
258
|
+
connect_url, **ws_connect_kwargs
|
|
204
259
|
)
|
|
205
260
|
except (aiohttp.ClientError, TimeoutError, OSError) as err:
|
|
206
261
|
_LOGGER.warning("Websocket connect failed: %s", err)
|
|
262
|
+
self._last_failure_reason = _classify_connect_error(err)
|
|
207
263
|
await self._close()
|
|
208
264
|
self.session.state = State.STOPPED
|
|
209
265
|
self._signal(Signal.FAILED)
|
|
@@ -345,9 +401,11 @@ class WebSocketClient:
|
|
|
345
401
|
await self._ws.close()
|
|
346
402
|
self._ws = None
|
|
347
403
|
|
|
348
|
-
if self._ws_session is not None:
|
|
404
|
+
if self._ws_session is not None and self._owns_ws_session:
|
|
349
405
|
await self._ws_session.close()
|
|
350
|
-
|
|
406
|
+
|
|
407
|
+
self._ws_session = None
|
|
408
|
+
self._owns_ws_session = False
|
|
351
409
|
|
|
352
410
|
def _signal(self, signal: Signal) -> None:
|
|
353
411
|
"""Invoke the signal callback, swallowing any exceptions."""
|
|
@@ -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,23 +19,20 @@ 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: orjson==3.11.8; extra == "requirements"
|
|
27
|
+
Requires-Dist: orjson==3.11.9; extra == "requirements"
|
|
30
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==
|
|
31
|
+
Requires-Dist: mypy==2.0.0; extra == "requirements-test"
|
|
34
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.23.1; extra == "requirements-test"
|
|
39
36
|
Requires-Dist: ruff==0.15.12; extra == "requirements-test"
|
|
40
37
|
Requires-Dist: types-xmltodict==v1.0.1.20260408; extra == "requirements-test"
|
|
41
38
|
Provides-Extra: requirements-dev
|
|
@@ -1,14 +1,12 @@
|
|
|
1
1
|
aiohttp>=3.12
|
|
2
2
|
faust-cchardet>=2.1.18
|
|
3
|
-
httpx>=0.26
|
|
4
3
|
orjson>3.9
|
|
5
4
|
packaging>23
|
|
6
5
|
xmltodict>=0.13.0
|
|
7
6
|
|
|
8
7
|
[requirements]
|
|
9
8
|
aiohttp==3.13.5
|
|
10
|
-
|
|
11
|
-
orjson==3.11.8
|
|
9
|
+
orjson==3.11.9
|
|
12
10
|
packaging==26.2
|
|
13
11
|
xmltodict==1.0.4
|
|
14
12
|
|
|
@@ -16,11 +14,10 @@ xmltodict==1.0.4
|
|
|
16
14
|
pre-commit==4.6.0
|
|
17
15
|
|
|
18
16
|
[requirements-test]
|
|
19
|
-
mypy==
|
|
17
|
+
mypy==2.0.0
|
|
20
18
|
pytest==9.0.3
|
|
21
19
|
pytest-aiohttp==1.1.0
|
|
22
20
|
pytest-asyncio==1.3.0
|
|
23
21
|
pytest-cov==7.1.0
|
|
24
|
-
respx==0.23.1
|
|
25
22
|
ruff==0.15.12
|
|
26
23
|
types-xmltodict==v1.0.1.20260408
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "axis"
|
|
7
|
-
version = "
|
|
7
|
+
version = "70"
|
|
8
8
|
license = {text = "MIT"}
|
|
9
9
|
description = "A Python library for communicating with devices from Axis Communications"
|
|
10
10
|
readme = "README.md"
|
|
@@ -22,7 +22,6 @@ requires-python = ">=3.14.0"
|
|
|
22
22
|
dependencies = [
|
|
23
23
|
"aiohttp>=3.12",
|
|
24
24
|
"faust-cchardet>=2.1.18",
|
|
25
|
-
"httpx>=0.26",
|
|
26
25
|
"orjson>3.9",
|
|
27
26
|
"packaging>23",
|
|
28
27
|
"xmltodict>=0.13.0",
|
|
@@ -31,18 +30,16 @@ dependencies = [
|
|
|
31
30
|
[project.optional-dependencies]
|
|
32
31
|
requirements = [
|
|
33
32
|
"aiohttp==3.13.5",
|
|
34
|
-
"
|
|
35
|
-
"orjson==3.11.8",
|
|
33
|
+
"orjson==3.11.9",
|
|
36
34
|
"packaging==26.2",
|
|
37
35
|
"xmltodict==1.0.4",
|
|
38
36
|
]
|
|
39
37
|
requirements-test = [
|
|
40
|
-
"mypy==
|
|
38
|
+
"mypy==2.0.0",
|
|
41
39
|
"pytest==9.0.3",
|
|
42
40
|
"pytest-aiohttp==1.1.0",
|
|
43
41
|
"pytest-asyncio==1.3.0",
|
|
44
42
|
"pytest-cov==7.1.0",
|
|
45
|
-
"respx==0.23.1",
|
|
46
43
|
"ruff==0.15.12",
|
|
47
44
|
"types-xmltodict==v1.0.1.20260408",
|
|
48
45
|
]
|
|
@@ -4,22 +4,9 @@ pytest --cov-report term-missing --cov=axis.api_discovery tests/test_api_discove
|
|
|
4
4
|
"""
|
|
5
5
|
|
|
6
6
|
import json
|
|
7
|
-
from typing import TYPE_CHECKING
|
|
8
|
-
|
|
9
|
-
import pytest
|
|
10
7
|
|
|
11
8
|
from axis.models.api_discovery import ApiId, ApiStatus
|
|
12
9
|
|
|
13
|
-
if TYPE_CHECKING:
|
|
14
|
-
from axis.device import AxisDevice
|
|
15
|
-
from axis.interfaces.api_discovery import ApiDiscoveryHandler
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
@pytest.fixture
|
|
19
|
-
def api_discovery(axis_device: AxisDevice) -> ApiDiscoveryHandler:
|
|
20
|
-
"""Return the api_discovery mock object."""
|
|
21
|
-
return axis_device.vapix.api_discovery
|
|
22
|
-
|
|
23
10
|
|
|
24
11
|
async def test_api_id_enum():
|
|
25
12
|
"""Verify API ID of unsupported type."""
|
|
@@ -31,11 +18,13 @@ async def test_api_status_enum():
|
|
|
31
18
|
assert ApiStatus("unsupported") is ApiStatus.UNKNOWN
|
|
32
19
|
|
|
33
20
|
|
|
34
|
-
async def test_get_api_list(
|
|
21
|
+
async def test_get_api_list(http_route_mock, axis_device):
|
|
35
22
|
"""Test get_api_list call."""
|
|
36
|
-
route =
|
|
23
|
+
route = http_route_mock.post("/axis-cgi/apidiscovery.cgi").respond(
|
|
37
24
|
json=GET_API_LIST_RESPONSE,
|
|
38
25
|
)
|
|
26
|
+
api_discovery = axis_device.vapix.api_discovery
|
|
27
|
+
|
|
39
28
|
assert api_discovery.supported
|
|
40
29
|
await api_discovery.update()
|
|
41
30
|
|
|
@@ -59,11 +48,13 @@ async def test_get_api_list(respx_mock, api_discovery: ApiDiscoveryHandler):
|
|
|
59
48
|
assert item.version == "1.0"
|
|
60
49
|
|
|
61
50
|
|
|
62
|
-
async def test_get_supported_versions(
|
|
51
|
+
async def test_get_supported_versions(http_route_mock, axis_device):
|
|
63
52
|
"""Test get_supported_versions."""
|
|
64
|
-
route =
|
|
53
|
+
route = http_route_mock.post("/axis-cgi/apidiscovery.cgi").respond(
|
|
65
54
|
json=GET_SUPPORTED_VERSIONS_RESPONSE,
|
|
66
55
|
)
|
|
56
|
+
api_discovery = axis_device.vapix.api_discovery
|
|
57
|
+
|
|
67
58
|
response = await api_discovery.get_supported_versions()
|
|
68
59
|
|
|
69
60
|
assert route.called
|