axis 69__tar.gz → 71__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-71}/PKG-INFO +4 -7
- {axis-69 → axis-71}/axis/__main__.py +1 -5
- {axis-69 → axis-71}/axis/interfaces/aiohttp_digest.py +1 -1
- {axis-69 → axis-71}/axis/interfaces/mqtt.py +2 -0
- {axis-69 → axis-71}/axis/interfaces/vapix.py +10 -83
- {axis-69 → axis-71}/axis/models/configuration.py +1 -2
- {axis-69 → axis-71}/axis/models/event.py +27 -1
- {axis-69 → axis-71}/axis/stream_manager.py +26 -0
- {axis-69 → axis-71}/axis/websocket.py +71 -11
- {axis-69 → axis-71}/axis.egg-info/PKG-INFO +4 -7
- {axis-69 → axis-71}/axis.egg-info/SOURCES.txt +1 -0
- {axis-69 → axis-71}/axis.egg-info/requires.txt +3 -6
- {axis-69 → axis-71}/pyproject.toml +4 -7
- {axis-69 → axis-71}/tests/test_api_discovery.py +8 -17
- axis-71/tests/test_auth_scheme.py +125 -0
- {axis-69 → axis-71}/tests/test_basic_device_info.py +12 -21
- {axis-69 → axis-71}/tests/test_configuration.py +20 -19
- axis-71/tests/test_conftest.py +164 -0
- {axis-69 → axis-71}/tests/test_event.py +74 -0
- {axis-69 → axis-71}/tests/test_event_instances.py +11 -5
- {axis-69 → axis-71}/tests/test_event_stream.py +26 -0
- axis-71/tests/test_http_client_compat.py +374 -0
- {axis-69 → axis-71}/tests/test_light_control.py +52 -48
- {axis-69 → axis-71}/tests/test_mqtt.py +31 -16
- {axis-69 → axis-71}/tests/test_pir_sensor_configuration.py +11 -9
- {axis-69 → axis-71}/tests/test_port_cgi.py +17 -33
- {axis-69 → axis-71}/tests/test_port_management.py +15 -12
- {axis-69 → axis-71}/tests/test_ptz.py +86 -78
- {axis-69 → axis-71}/tests/test_pwdgrp_cgi.py +14 -18
- {axis-69 → axis-71}/tests/test_stream_manager.py +75 -0
- {axis-69 → axis-71}/tests/test_stream_profiles.py +29 -18
- {axis-69 → axis-71}/tests/test_user_groups.py +40 -15
- {axis-69 → axis-71}/tests/test_vapix.py +105 -79
- {axis-69 → axis-71}/tests/test_view_areas.py +26 -22
- {axis-69 → axis-71}/tests/test_websocket.py +146 -30
- axis-69/tests/test_auth_scheme.py +0 -86
- axis-69/tests/test_http_client_compat.py +0 -393
- {axis-69 → axis-71}/LICENSE +0 -0
- {axis-69 → axis-71}/README.md +0 -0
- {axis-69 → axis-71}/axis/__init__.py +0 -0
- {axis-69 → axis-71}/axis/device.py +0 -0
- {axis-69 → axis-71}/axis/errors.py +0 -0
- {axis-69 → axis-71}/axis/interfaces/__init__.py +0 -0
- {axis-69 → axis-71}/axis/interfaces/api_discovery.py +0 -0
- {axis-69 → axis-71}/axis/interfaces/api_handler.py +0 -0
- {axis-69 → axis-71}/axis/interfaces/applications/__init__.py +0 -0
- {axis-69 → axis-71}/axis/interfaces/applications/application_handler.py +0 -0
- {axis-69 → axis-71}/axis/interfaces/applications/applications.py +0 -0
- {axis-69 → axis-71}/axis/interfaces/applications/fence_guard.py +0 -0
- {axis-69 → axis-71}/axis/interfaces/applications/loitering_guard.py +0 -0
- {axis-69 → axis-71}/axis/interfaces/applications/motion_guard.py +0 -0
- {axis-69 → axis-71}/axis/interfaces/applications/object_analytics.py +0 -0
- {axis-69 → axis-71}/axis/interfaces/applications/vmd4.py +0 -0
- {axis-69 → axis-71}/axis/interfaces/basic_device_info.py +0 -0
- {axis-69 → axis-71}/axis/interfaces/event_instances.py +0 -0
- {axis-69 → axis-71}/axis/interfaces/event_manager.py +0 -0
- {axis-69 → axis-71}/axis/interfaces/light_control.py +0 -0
- {axis-69 → axis-71}/axis/interfaces/parameters/__init__.py +0 -0
- {axis-69 → axis-71}/axis/interfaces/parameters/brand.py +0 -0
- {axis-69 → axis-71}/axis/interfaces/parameters/image.py +0 -0
- {axis-69 → axis-71}/axis/interfaces/parameters/io_port.py +0 -0
- {axis-69 → axis-71}/axis/interfaces/parameters/param_cgi.py +0 -0
- {axis-69 → axis-71}/axis/interfaces/parameters/param_handler.py +0 -0
- {axis-69 → axis-71}/axis/interfaces/parameters/properties.py +0 -0
- {axis-69 → axis-71}/axis/interfaces/parameters/ptz.py +0 -0
- {axis-69 → axis-71}/axis/interfaces/parameters/stream_profile.py +0 -0
- {axis-69 → axis-71}/axis/interfaces/pir_sensor_configuration.py +0 -0
- {axis-69 → axis-71}/axis/interfaces/port_cgi.py +0 -0
- {axis-69 → axis-71}/axis/interfaces/port_management.py +0 -0
- {axis-69 → axis-71}/axis/interfaces/ptz.py +0 -0
- {axis-69 → axis-71}/axis/interfaces/pwdgrp_cgi.py +0 -0
- {axis-69 → axis-71}/axis/interfaces/stream_profiles.py +0 -0
- {axis-69 → axis-71}/axis/interfaces/user_groups.py +0 -0
- {axis-69 → axis-71}/axis/interfaces/view_areas.py +0 -0
- {axis-69 → axis-71}/axis/models/__init__.py +0 -0
- {axis-69 → axis-71}/axis/models/api.py +0 -0
- {axis-69 → axis-71}/axis/models/api_discovery.py +0 -0
- {axis-69 → axis-71}/axis/models/applications/__init__.py +0 -0
- {axis-69 → axis-71}/axis/models/applications/application.py +0 -0
- {axis-69 → axis-71}/axis/models/applications/fence_guard.py +0 -0
- {axis-69 → axis-71}/axis/models/applications/loitering_guard.py +0 -0
- {axis-69 → axis-71}/axis/models/applications/motion_guard.py +0 -0
- {axis-69 → axis-71}/axis/models/applications/object_analytics.py +0 -0
- {axis-69 → axis-71}/axis/models/applications/vmd4.py +0 -0
- {axis-69 → axis-71}/axis/models/basic_device_info.py +0 -0
- {axis-69 → axis-71}/axis/models/event_instance.py +0 -0
- {axis-69 → axis-71}/axis/models/light_control.py +0 -0
- {axis-69 → axis-71}/axis/models/mqtt.py +0 -0
- {axis-69 → axis-71}/axis/models/parameters/__init__.py +0 -0
- {axis-69 → axis-71}/axis/models/parameters/brand.py +0 -0
- {axis-69 → axis-71}/axis/models/parameters/image.py +0 -0
- {axis-69 → axis-71}/axis/models/parameters/io_port.py +0 -0
- {axis-69 → axis-71}/axis/models/parameters/param_cgi.py +0 -0
- {axis-69 → axis-71}/axis/models/parameters/properties.py +0 -0
- {axis-69 → axis-71}/axis/models/parameters/ptz.py +0 -0
- {axis-69 → axis-71}/axis/models/parameters/stream_profile.py +0 -0
- {axis-69 → axis-71}/axis/models/pir_sensor_configuration.py +0 -0
- {axis-69 → axis-71}/axis/models/port_cgi.py +0 -0
- {axis-69 → axis-71}/axis/models/port_management.py +0 -0
- {axis-69 → axis-71}/axis/models/ptz_cgi.py +0 -0
- {axis-69 → axis-71}/axis/models/pwdgrp_cgi.py +0 -0
- {axis-69 → axis-71}/axis/models/stream_profile.py +0 -0
- {axis-69 → axis-71}/axis/models/user_group.py +0 -0
- {axis-69 → axis-71}/axis/models/view_area.py +0 -0
- {axis-69 → axis-71}/axis/py.typed +0 -0
- {axis-69 → axis-71}/axis/rtsp.py +0 -0
- {axis-69 → axis-71}/axis/stream_transport.py +0 -0
- {axis-69 → axis-71}/axis.egg-info/dependency_links.txt +0 -0
- {axis-69 → axis-71}/axis.egg-info/entry_points.txt +0 -0
- {axis-69 → axis-71}/axis.egg-info/top_level.txt +0 -0
- {axis-69 → axis-71}/setup.cfg +0 -0
- {axis-69 → axis-71}/tests/test_api_handler.py +0 -0
- {axis-69 → axis-71}/tests/test_device.py +0 -0
- {axis-69 → axis-71}/tests/test_main_http_client.py +0 -0
- {axis-69 → axis-71}/tests/test_rtsp.py +0 -0
{axis-69 → axis-71}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: axis
|
|
3
|
-
Version:
|
|
3
|
+
Version: 71
|
|
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,25 +19,22 @@ 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
|
-
Requires-Dist: types-xmltodict==v1.0.1.
|
|
37
|
+
Requires-Dist: types-xmltodict==v1.0.1.20260508; extra == "requirements-test"
|
|
41
38
|
Provides-Extra: requirements-dev
|
|
42
39
|
Requires-Dist: pre-commit==4.6.0; extra == "requirements-dev"
|
|
43
40
|
Dynamic: license-file
|
|
@@ -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:
|
|
@@ -42,6 +42,8 @@ def mqtt_json_to_event(msg: bytes | bytearray | memoryview | str) -> dict[str, A
|
|
|
42
42
|
source, source_idx = next(iter(source_dict.items()))
|
|
43
43
|
if data_dict := msg_message.get("data"):
|
|
44
44
|
data_type, data_value = next(iter(data_dict.items()))
|
|
45
|
+
if "active" in data_dict:
|
|
46
|
+
data_type, data_value = "active", data_dict["active"]
|
|
45
47
|
|
|
46
48
|
return {
|
|
47
49
|
"topic": topic,
|
|
@@ -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":
|
|
@@ -91,6 +91,9 @@ TOPIC_TO_STATE = {
|
|
|
91
91
|
EventTopic.RELAY: "active",
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
+
KNOWN_FALSE_STATES = {"", "0", "false", "inactive", "low", "off"}
|
|
95
|
+
KNOWN_TRUE_STATES = {"1", "active", "high", "on", "true"}
|
|
96
|
+
|
|
94
97
|
EVENT_OPERATION = "operation"
|
|
95
98
|
EVENT_SOURCE = "source"
|
|
96
99
|
EVENT_SOURCE_IDX = "source_idx"
|
|
@@ -138,6 +141,28 @@ def extract_name_value(
|
|
|
138
141
|
return item.get("Name", ""), item.get("Value", "")
|
|
139
142
|
|
|
140
143
|
|
|
144
|
+
def is_tripped(value: object, topic_base: EventTopic, event_type: object) -> bool:
|
|
145
|
+
"""Return whether an event value should be considered active/tripped."""
|
|
146
|
+
if (expected_state := TOPIC_TO_STATE.get(topic_base)) is not None:
|
|
147
|
+
return str(value) == expected_state
|
|
148
|
+
|
|
149
|
+
value_text = str(value).strip()
|
|
150
|
+
state = value_text.casefold()
|
|
151
|
+
|
|
152
|
+
if state in KNOWN_FALSE_STATES:
|
|
153
|
+
return False
|
|
154
|
+
|
|
155
|
+
if state in KNOWN_TRUE_STATES:
|
|
156
|
+
return True
|
|
157
|
+
|
|
158
|
+
if str(event_type).casefold() == "active":
|
|
159
|
+
return False
|
|
160
|
+
|
|
161
|
+
# Non-empty semantic values (for example classification values like "human")
|
|
162
|
+
# are treated as stateless event triggers.
|
|
163
|
+
return bool(value_text)
|
|
164
|
+
|
|
165
|
+
|
|
141
166
|
@dataclass
|
|
142
167
|
class Event:
|
|
143
168
|
"""Event data from Axis device."""
|
|
@@ -166,6 +191,7 @@ class Event:
|
|
|
166
191
|
topic = data.get(EVENT_TOPIC, "")
|
|
167
192
|
source = data.get(EVENT_SOURCE, "")
|
|
168
193
|
source_idx = data.get(EVENT_SOURCE_IDX, "")
|
|
194
|
+
event_type = data.get(EVENT_TYPE, "")
|
|
169
195
|
value = data.get(EVENT_VALUE, "")
|
|
170
196
|
|
|
171
197
|
if (topic_base := EventTopic(topic)) is EventTopic.UNKNOWN:
|
|
@@ -181,7 +207,7 @@ class Event:
|
|
|
181
207
|
data=data,
|
|
182
208
|
group=TOPIC_TO_GROUP.get(topic_base, EventGroup.NONE),
|
|
183
209
|
id=source_idx,
|
|
184
|
-
is_tripped=value
|
|
210
|
+
is_tripped=is_tripped(value, topic_base, event_type),
|
|
185
211
|
operation=operation,
|
|
186
212
|
source=source,
|
|
187
213
|
state=value,
|
|
@@ -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
|
|
|
@@ -71,6 +113,8 @@ def _parse_ws_notification(notification: dict[str, Any]) -> dict[str, Any]:
|
|
|
71
113
|
|
|
72
114
|
source, source_idx = next(iter(source_dict.items()), ("", ""))
|
|
73
115
|
data_type, data_value = next(iter(data_dict.items()), ("", ""))
|
|
116
|
+
if "active" in data_dict:
|
|
117
|
+
data_type, data_value = "active", data_dict["active"]
|
|
74
118
|
|
|
75
119
|
return {
|
|
76
120
|
EVENT_TOPIC: topic,
|
|
@@ -135,12 +179,14 @@ class WebSocketClient:
|
|
|
135
179
|
self._data: deque[dict[str, Any]] = deque(maxlen=BUFFER_SIZE)
|
|
136
180
|
|
|
137
181
|
self._ws_session: aiohttp.ClientSession | None = None
|
|
182
|
+
self._owns_ws_session = False
|
|
138
183
|
self._ws: aiohttp.ClientWebSocketResponse | None = None
|
|
139
184
|
self._receiver_task: asyncio.Task[None] | None = None
|
|
140
185
|
self._close_task: asyncio.Task[None] | None = None
|
|
141
186
|
self._stopped = False
|
|
142
187
|
self._starting = False
|
|
143
188
|
self._start_time: float | None = None
|
|
189
|
+
self._last_failure_reason = WebSocketFailureReason.NONE
|
|
144
190
|
|
|
145
191
|
@classmethod
|
|
146
192
|
def supported_by_device(cls, device: AxisDevice) -> bool:
|
|
@@ -155,6 +201,11 @@ class WebSocketClient:
|
|
|
155
201
|
except IndexError:
|
|
156
202
|
return {}
|
|
157
203
|
|
|
204
|
+
@property
|
|
205
|
+
def should_disable_runtime_websocket(self) -> bool:
|
|
206
|
+
"""Return true if websocket should be disabled for this runtime."""
|
|
207
|
+
return self._last_failure_reason == WebSocketFailureReason.SSL_CERTIFICATE
|
|
208
|
+
|
|
158
209
|
async def _get_session_token(self) -> str | None:
|
|
159
210
|
"""Obtain a short-lived session token for websocket authentication.
|
|
160
211
|
|
|
@@ -178,32 +229,39 @@ class WebSocketClient:
|
|
|
178
229
|
self._stopped = False
|
|
179
230
|
self.session.state = State.STARTING
|
|
180
231
|
self._start_time = time()
|
|
232
|
+
self._last_failure_reason = WebSocketFailureReason.NONE
|
|
181
233
|
|
|
182
234
|
try:
|
|
183
235
|
if self._close_task is not None:
|
|
184
236
|
await asyncio.shield(self._close_task)
|
|
185
237
|
|
|
186
238
|
token = await self._get_session_token()
|
|
239
|
+
self._ws_session = self.device.config.session
|
|
240
|
+
self._owns_ws_session = False
|
|
241
|
+
|
|
242
|
+
ws_connect_kwargs: dict[str, Any] = {
|
|
243
|
+
"heartbeat": HEARTBEAT_INTERVAL,
|
|
244
|
+
"timeout": self._ws_timeout,
|
|
245
|
+
}
|
|
246
|
+
if not self.device.config.verify_ssl:
|
|
247
|
+
ws_connect_kwargs["ssl"] = False
|
|
248
|
+
|
|
187
249
|
if token:
|
|
188
250
|
connect_url = f"{self.url}&wssession={token}"
|
|
189
|
-
self._ws_session = aiohttp.ClientSession()
|
|
190
251
|
else:
|
|
191
252
|
# Fall back to HTTP Basic auth in the upgrade handshake.
|
|
192
253
|
connect_url = self.url
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
self.device.config.password,
|
|
197
|
-
),
|
|
254
|
+
ws_connect_kwargs["auth"] = aiohttp.BasicAuth(
|
|
255
|
+
self.device.config.username,
|
|
256
|
+
self.device.config.password,
|
|
198
257
|
)
|
|
199
258
|
|
|
200
259
|
self._ws = await self._ws_session.ws_connect(
|
|
201
|
-
connect_url,
|
|
202
|
-
heartbeat=HEARTBEAT_INTERVAL,
|
|
203
|
-
timeout=self._ws_timeout,
|
|
260
|
+
connect_url, **ws_connect_kwargs
|
|
204
261
|
)
|
|
205
262
|
except (aiohttp.ClientError, TimeoutError, OSError) as err:
|
|
206
263
|
_LOGGER.warning("Websocket connect failed: %s", err)
|
|
264
|
+
self._last_failure_reason = _classify_connect_error(err)
|
|
207
265
|
await self._close()
|
|
208
266
|
self.session.state = State.STOPPED
|
|
209
267
|
self._signal(Signal.FAILED)
|
|
@@ -345,9 +403,11 @@ class WebSocketClient:
|
|
|
345
403
|
await self._ws.close()
|
|
346
404
|
self._ws = None
|
|
347
405
|
|
|
348
|
-
if self._ws_session is not None:
|
|
406
|
+
if self._ws_session is not None and self._owns_ws_session:
|
|
349
407
|
await self._ws_session.close()
|
|
350
|
-
|
|
408
|
+
|
|
409
|
+
self._ws_session = None
|
|
410
|
+
self._owns_ws_session = False
|
|
351
411
|
|
|
352
412
|
def _signal(self, signal: Signal) -> None:
|
|
353
413
|
"""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: 71
|
|
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,25 +19,22 @@ 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
|
-
Requires-Dist: types-xmltodict==v1.0.1.
|
|
37
|
+
Requires-Dist: types-xmltodict==v1.0.1.20260508; extra == "requirements-test"
|
|
41
38
|
Provides-Extra: requirements-dev
|
|
42
39
|
Requires-Dist: pre-commit==4.6.0; extra == "requirements-dev"
|
|
43
40
|
Dynamic: license-file
|
|
@@ -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
|
-
types-xmltodict==v1.0.1.
|
|
23
|
+
types-xmltodict==v1.0.1.20260508
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "axis"
|
|
7
|
-
version = "
|
|
7
|
+
version = "71"
|
|
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,20 +30,18 @@ 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
|
-
"types-xmltodict==v1.0.1.
|
|
44
|
+
"types-xmltodict==v1.0.1.20260508",
|
|
48
45
|
]
|
|
49
46
|
requirements-dev = [
|
|
50
47
|
"pre-commit==4.6.0"
|