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