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