axis 66__tar.gz → 67__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 (110) hide show
  1. {axis-66 → axis-67}/PKG-INFO +10 -8
  2. {axis-66 → axis-67}/axis/__main__.py +49 -11
  3. {axis-66 → axis-67}/axis/device.py +5 -1
  4. {axis-66 → axis-67}/axis/interfaces/api_handler.py +2 -2
  5. {axis-66 → axis-67}/axis/interfaces/parameters/param_cgi.py +2 -2
  6. {axis-66 → axis-67}/axis/interfaces/parameters/param_handler.py +3 -2
  7. {axis-66 → axis-67}/axis/interfaces/vapix.py +234 -14
  8. {axis-66 → axis-67}/axis/models/api_discovery.py +2 -2
  9. axis-67/axis/models/configuration.py +87 -0
  10. {axis-66 → axis-67}/axis/models/event.py +18 -19
  11. {axis-66 → axis-67}/axis/models/event_instance.py +1 -1
  12. {axis-66 → axis-67}/axis/models/parameters/io_port.py +2 -2
  13. {axis-66 → axis-67}/axis/models/parameters/param_cgi.py +1 -1
  14. {axis-66 → axis-67}/axis/models/view_area.py +2 -2
  15. {axis-66 → axis-67}/axis/rtsp.py +4 -2
  16. {axis-66 → axis-67}/axis/stream_manager.py +1 -1
  17. {axis-66 → axis-67}/axis.egg-info/PKG-INFO +10 -8
  18. {axis-66 → axis-67}/axis.egg-info/SOURCES.txt +3 -0
  19. {axis-66 → axis-67}/axis.egg-info/requires.txt +7 -5
  20. {axis-66 → axis-67}/pyproject.toml +16 -11
  21. {axis-66 → axis-67}/tests/test_api_discovery.py +5 -2
  22. axis-67/tests/test_auth_scheme.py +86 -0
  23. {axis-66 → axis-67}/tests/test_basic_device_info.py +4 -2
  24. axis-67/tests/test_configuration.py +149 -0
  25. {axis-66 → axis-67}/tests/test_event_instances.py +5 -1
  26. {axis-66 → axis-67}/tests/test_event_stream.py +5 -2
  27. axis-67/tests/test_http_client_compat.py +165 -0
  28. {axis-66 → axis-67}/tests/test_light_control.py +5 -2
  29. axis-67/tests/test_main_http_client.py +22 -0
  30. {axis-66 → axis-67}/tests/test_mqtt.py +4 -1
  31. {axis-66 → axis-67}/tests/test_pir_sensor_configuration.py +4 -2
  32. {axis-66 → axis-67}/tests/test_port_cgi.py +5 -1
  33. {axis-66 → axis-67}/tests/test_ptz.py +5 -2
  34. {axis-66 → axis-67}/tests/test_pwdgrp_cgi.py +4 -1
  35. {axis-66 → axis-67}/tests/test_stream_profiles.py +4 -2
  36. {axis-66 → axis-67}/tests/test_vapix.py +6 -2
  37. {axis-66 → axis-67}/tests/test_view_areas.py +4 -1
  38. axis-66/axis/models/configuration.py +0 -25
  39. axis-66/tests/test_configuration.py +0 -51
  40. {axis-66 → axis-67}/LICENSE +0 -0
  41. {axis-66 → axis-67}/README.md +0 -0
  42. {axis-66 → axis-67}/axis/__init__.py +0 -0
  43. {axis-66 → axis-67}/axis/errors.py +0 -0
  44. {axis-66 → axis-67}/axis/interfaces/__init__.py +0 -0
  45. {axis-66 → axis-67}/axis/interfaces/api_discovery.py +0 -0
  46. {axis-66 → axis-67}/axis/interfaces/applications/__init__.py +0 -0
  47. {axis-66 → axis-67}/axis/interfaces/applications/application_handler.py +0 -0
  48. {axis-66 → axis-67}/axis/interfaces/applications/applications.py +0 -0
  49. {axis-66 → axis-67}/axis/interfaces/applications/fence_guard.py +0 -0
  50. {axis-66 → axis-67}/axis/interfaces/applications/loitering_guard.py +0 -0
  51. {axis-66 → axis-67}/axis/interfaces/applications/motion_guard.py +0 -0
  52. {axis-66 → axis-67}/axis/interfaces/applications/object_analytics.py +0 -0
  53. {axis-66 → axis-67}/axis/interfaces/applications/vmd4.py +0 -0
  54. {axis-66 → axis-67}/axis/interfaces/basic_device_info.py +0 -0
  55. {axis-66 → axis-67}/axis/interfaces/event_instances.py +0 -0
  56. {axis-66 → axis-67}/axis/interfaces/event_manager.py +0 -0
  57. {axis-66 → axis-67}/axis/interfaces/light_control.py +0 -0
  58. {axis-66 → axis-67}/axis/interfaces/mqtt.py +0 -0
  59. {axis-66 → axis-67}/axis/interfaces/parameters/__init__.py +0 -0
  60. {axis-66 → axis-67}/axis/interfaces/parameters/brand.py +0 -0
  61. {axis-66 → axis-67}/axis/interfaces/parameters/image.py +0 -0
  62. {axis-66 → axis-67}/axis/interfaces/parameters/io_port.py +0 -0
  63. {axis-66 → axis-67}/axis/interfaces/parameters/properties.py +0 -0
  64. {axis-66 → axis-67}/axis/interfaces/parameters/ptz.py +0 -0
  65. {axis-66 → axis-67}/axis/interfaces/parameters/stream_profile.py +0 -0
  66. {axis-66 → axis-67}/axis/interfaces/pir_sensor_configuration.py +0 -0
  67. {axis-66 → axis-67}/axis/interfaces/port_cgi.py +0 -0
  68. {axis-66 → axis-67}/axis/interfaces/port_management.py +0 -0
  69. {axis-66 → axis-67}/axis/interfaces/ptz.py +0 -0
  70. {axis-66 → axis-67}/axis/interfaces/pwdgrp_cgi.py +0 -0
  71. {axis-66 → axis-67}/axis/interfaces/stream_profiles.py +0 -0
  72. {axis-66 → axis-67}/axis/interfaces/user_groups.py +0 -0
  73. {axis-66 → axis-67}/axis/interfaces/view_areas.py +0 -0
  74. {axis-66 → axis-67}/axis/models/__init__.py +0 -0
  75. {axis-66 → axis-67}/axis/models/api.py +0 -0
  76. {axis-66 → axis-67}/axis/models/applications/__init__.py +0 -0
  77. {axis-66 → axis-67}/axis/models/applications/application.py +0 -0
  78. {axis-66 → axis-67}/axis/models/applications/fence_guard.py +0 -0
  79. {axis-66 → axis-67}/axis/models/applications/loitering_guard.py +0 -0
  80. {axis-66 → axis-67}/axis/models/applications/motion_guard.py +0 -0
  81. {axis-66 → axis-67}/axis/models/applications/object_analytics.py +0 -0
  82. {axis-66 → axis-67}/axis/models/applications/vmd4.py +0 -0
  83. {axis-66 → axis-67}/axis/models/basic_device_info.py +0 -0
  84. {axis-66 → axis-67}/axis/models/light_control.py +0 -0
  85. {axis-66 → axis-67}/axis/models/mqtt.py +0 -0
  86. {axis-66 → axis-67}/axis/models/parameters/__init__.py +0 -0
  87. {axis-66 → axis-67}/axis/models/parameters/brand.py +0 -0
  88. {axis-66 → axis-67}/axis/models/parameters/image.py +0 -0
  89. {axis-66 → axis-67}/axis/models/parameters/properties.py +0 -0
  90. {axis-66 → axis-67}/axis/models/parameters/ptz.py +0 -0
  91. {axis-66 → axis-67}/axis/models/parameters/stream_profile.py +0 -0
  92. {axis-66 → axis-67}/axis/models/pir_sensor_configuration.py +0 -0
  93. {axis-66 → axis-67}/axis/models/port_cgi.py +0 -0
  94. {axis-66 → axis-67}/axis/models/port_management.py +0 -0
  95. {axis-66 → axis-67}/axis/models/ptz_cgi.py +0 -0
  96. {axis-66 → axis-67}/axis/models/pwdgrp_cgi.py +0 -0
  97. {axis-66 → axis-67}/axis/models/stream_profile.py +0 -0
  98. {axis-66 → axis-67}/axis/models/user_group.py +0 -0
  99. {axis-66 → axis-67}/axis/py.typed +0 -0
  100. {axis-66 → axis-67}/axis.egg-info/dependency_links.txt +0 -0
  101. {axis-66 → axis-67}/axis.egg-info/entry_points.txt +0 -0
  102. {axis-66 → axis-67}/axis.egg-info/top_level.txt +0 -0
  103. {axis-66 → axis-67}/setup.cfg +0 -0
  104. {axis-66 → axis-67}/tests/test_api_handler.py +0 -0
  105. {axis-66 → axis-67}/tests/test_device.py +0 -0
  106. {axis-66 → axis-67}/tests/test_event.py +0 -0
  107. {axis-66 → axis-67}/tests/test_port_management.py +0 -0
  108. {axis-66 → axis-67}/tests/test_rtsp.py +0 -0
  109. {axis-66 → axis-67}/tests/test_stream_manager.py +0 -0
  110. {axis-66 → axis-67}/tests/test_user_groups.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: axis
3
- Version: 66
3
+ Version: 67
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
@@ -12,21 +12,23 @@ Classifier: Development Status :: 5 - Production/Stable
12
12
  Classifier: Intended Audience :: Developers
13
13
  Classifier: License :: OSI Approved :: MIT License
14
14
  Classifier: Operating System :: OS Independent
15
- Classifier: Programming Language :: Python :: 3.13
15
+ Classifier: Programming Language :: Python :: 3.14
16
16
  Classifier: Topic :: Home Automation
17
- Requires-Python: >=3.13.0
17
+ Requires-Python: >=3.14.0
18
18
  Description-Content-Type: text/markdown
19
19
  License-File: LICENSE
20
+ Requires-Dist: aiohttp>=3.12
20
21
  Requires-Dist: faust-cchardet>=2.1.18
21
22
  Requires-Dist: httpx>=0.26
22
23
  Requires-Dist: orjson>3.9
23
24
  Requires-Dist: packaging>23
24
25
  Requires-Dist: xmltodict>=0.13.0
25
26
  Provides-Extra: requirements
27
+ Requires-Dist: aiohttp==3.13.1; extra == "requirements"
26
28
  Requires-Dist: httpx==0.28.1; extra == "requirements"
27
- Requires-Dist: orjson==3.11.5; extra == "requirements"
28
- Requires-Dist: packaging==25.0; extra == "requirements"
29
- Requires-Dist: xmltodict==1.0.2; extra == "requirements"
29
+ Requires-Dist: orjson==3.11.7; extra == "requirements"
30
+ Requires-Dist: packaging==26.0; extra == "requirements"
31
+ Requires-Dist: xmltodict==1.0.4; extra == "requirements"
30
32
  Provides-Extra: requirements-test
31
33
  Requires-Dist: mypy==1.19.1; extra == "requirements-test"
32
34
  Requires-Dist: pytest==9.0.2; extra == "requirements-test"
@@ -34,8 +36,8 @@ Requires-Dist: pytest-aiohttp==1.1.0; extra == "requirements-test"
34
36
  Requires-Dist: pytest-asyncio==1.3.0; extra == "requirements-test"
35
37
  Requires-Dist: pytest-cov==7.0.0; extra == "requirements-test"
36
38
  Requires-Dist: respx==0.22.0; extra == "requirements-test"
37
- Requires-Dist: ruff==0.14.10; extra == "requirements-test"
38
- Requires-Dist: types-xmltodict==v1.0.1.20250920; extra == "requirements-test"
39
+ Requires-Dist: ruff==0.15.6; extra == "requirements-test"
40
+ Requires-Dist: types-xmltodict==v1.0.1.20260113; extra == "requirements-test"
39
41
  Provides-Extra: requirements-dev
40
42
  Requires-Dist: pre-commit==4.5.1; extra == "requirements-dev"
41
43
  Dynamic: license-file
@@ -3,27 +3,35 @@
3
3
  import argparse
4
4
  import asyncio
5
5
  import logging
6
+ from typing import TYPE_CHECKING
6
7
 
7
- from httpx import AsyncClient
8
+ import aiohttp
8
9
 
9
10
  import axis
10
11
  from axis.device import AxisDevice
11
- from axis.models.configuration import Configuration
12
- from axis.models.event import Event
12
+ from axis.models.configuration import Configuration, WebProtocol
13
+
14
+ if TYPE_CHECKING:
15
+ from axis.models.event import Event
13
16
 
14
17
  LOGGER = logging.getLogger(__name__)
15
18
 
16
19
 
17
- def event_handler(event: Event) -> None:
20
+ def event_handler(event: "Event") -> None:
18
21
  """Receive and print events from RTSP stream."""
19
22
  LOGGER.info(event)
20
23
 
21
24
 
22
25
  async def axis_device(
23
- host: str, port: int, username: str, password: str, is_companion: bool = False
24
- ) -> AxisDevice:
26
+ host: str,
27
+ port: int,
28
+ username: str,
29
+ password: str,
30
+ web_proto: WebProtocol,
31
+ is_companion: bool = False,
32
+ ) -> axis.device.AxisDevice:
25
33
  """Create a Axis device."""
26
- session = AsyncClient(verify=False) # noqa: S501
34
+ session = create_session()
27
35
  device = AxisDevice(
28
36
  Configuration(
29
37
  session,
@@ -32,6 +40,7 @@ async def axis_device(
32
40
  username=username,
33
41
  password=password,
34
42
  is_companion=is_companion,
43
+ web_proto=web_proto,
35
44
  )
36
45
  )
37
46
 
@@ -58,12 +67,24 @@ async def axis_device(
58
67
 
59
68
 
60
69
  async def main(
61
- host: str, port: int, username: str, password: str, params: bool, events: bool
70
+ host: str,
71
+ port: int,
72
+ username: str,
73
+ password: str,
74
+ params: bool,
75
+ events: bool,
76
+ web_proto: WebProtocol,
62
77
  ) -> None:
63
78
  """CLI method for library."""
64
79
  LOGGER.info("Connecting to Axis device")
65
80
 
66
- device = await axis_device(host, port, username, password)
81
+ device = await axis_device(
82
+ host,
83
+ port,
84
+ username,
85
+ password,
86
+ web_proto=web_proto,
87
+ )
67
88
 
68
89
  if not device:
69
90
  LOGGER.error("Couldn't connect to Axis device")
@@ -86,16 +107,32 @@ async def main(
86
107
  device.stream.stop()
87
108
 
88
109
  finally:
89
- await device.config.session.aclose()
110
+ session = device.config.session
111
+ if not isinstance(session, aiohttp.ClientSession):
112
+ message = "Configured session is not an aiohttp ClientSession"
113
+ raise RuntimeError(message)
114
+ await close_session(session)
90
115
  device.stream.stop()
91
116
 
92
117
 
118
+ def create_session() -> aiohttp.ClientSession:
119
+ """Create aiohttp session used for HTTP requests."""
120
+ connector = aiohttp.TCPConnector(ssl=False)
121
+ return aiohttp.ClientSession(connector=connector)
122
+
123
+
124
+ async def close_session(session: aiohttp.ClientSession) -> None:
125
+ """Close aiohttp session."""
126
+ await session.close()
127
+
128
+
93
129
  if __name__ == "__main__":
94
130
  parser = argparse.ArgumentParser()
95
131
  parser.add_argument("host", type=str)
96
132
  parser.add_argument("username", type=str)
97
133
  parser.add_argument("password", type=str)
98
- parser.add_argument("-p", "--port", type=int, default=80)
134
+ parser.add_argument("-p", "--port", type=int, default=0)
135
+ parser.add_argument("--proto", type=str, default="http")
99
136
  parser.add_argument("--events", action="store_true")
100
137
  parser.add_argument("--params", action="store_true")
101
138
  parser.add_argument("-D", "--debug", action="store_true")
@@ -125,6 +162,7 @@ if __name__ == "__main__":
125
162
  port=args.port,
126
163
  params=args.params,
127
164
  events=args.events,
165
+ web_proto=WebProtocol(args.proto),
128
166
  )
129
167
  )
130
168
 
@@ -1,10 +1,14 @@
1
1
  """Python library to enable Axis devices to integrate with Home Assistant."""
2
2
 
3
+ from typing import TYPE_CHECKING
4
+
3
5
  from .interfaces.event_manager import EventManager
4
6
  from .interfaces.vapix import Vapix
5
- from .models.configuration import Configuration
6
7
  from .stream_manager import StreamManager
7
8
 
9
+ if TYPE_CHECKING:
10
+ from .models.configuration import Configuration
11
+
8
12
 
9
13
  class AxisDevice:
10
14
  """Creates a new Axis device.self."""
@@ -73,11 +73,11 @@ class SubscriptionHandler:
73
73
  class ApiHandler(SubscriptionHandler, Generic[ApiItemT]):
74
74
  """Base class for a map of API Items."""
75
75
 
76
- api_id: "ApiId | None" = None
76
+ api_id: ApiId | None = None
77
77
  default_api_version: str | None = None
78
78
  skip_support_check = False
79
79
 
80
- def __init__(self, vapix: "Vapix") -> None:
80
+ def __init__(self, vapix: Vapix) -> None:
81
81
  """Initialize API items."""
82
82
  super().__init__()
83
83
  self.vapix = vapix
@@ -1,9 +1,9 @@
1
1
  """Axis Vapix parameter management."""
2
2
 
3
- from collections.abc import Sequence
4
3
  from typing import TYPE_CHECKING, Any, TypedDict
5
4
 
6
5
  if TYPE_CHECKING:
6
+ from collections.abc import Sequence
7
7
 
8
8
  class _DetectResultType(TypedDict):
9
9
  encoding: str
@@ -34,7 +34,7 @@ class Params(ApiHandler[Any]):
34
34
 
35
35
  api_id = ApiId.PARAM_CGI
36
36
 
37
- def __init__(self, vapix: "Vapix") -> None:
37
+ def __init__(self, vapix: Vapix) -> None:
38
38
  """Initialize parameter classes."""
39
39
  super().__init__(vapix)
40
40
 
@@ -5,10 +5,11 @@ Generalises parameter specific handling like
5
5
  - Defining parameter group
6
6
  """
7
7
 
8
- from collections.abc import Sequence
9
8
  from typing import TYPE_CHECKING
10
9
 
11
10
  if TYPE_CHECKING:
11
+ from collections.abc import Sequence
12
+
12
13
  from .param_cgi import Params
13
14
 
14
15
  from ...models.parameters.param_cgi import ParameterGroup, ParamItemT
@@ -21,7 +22,7 @@ class ParamHandler(ApiHandler[ParamItemT]):
21
22
  parameter_group: ParameterGroup
22
23
  parameter_item: type[ParamItemT]
23
24
 
24
- def __init__(self, param_handler: "Params") -> None:
25
+ def __init__(self, param_handler: Params) -> None:
25
26
  """Initialize API items."""
26
27
  super().__init__(param_handler.vapix)
27
28
  param_handler.subscribe(self._update_params_callback, self.parameter_group)
@@ -4,11 +4,13 @@ from __future__ import annotations
4
4
 
5
5
  import asyncio
6
6
  import logging
7
- from typing import TYPE_CHECKING, Any
7
+ from typing import TYPE_CHECKING, Any, cast
8
8
 
9
+ import aiohttp
9
10
  import httpx
10
11
 
11
12
  from ..errors import RequestError, raise_error
13
+ from ..models.configuration import AuthScheme
12
14
  from ..models.pwdgrp_cgi import SecondaryGroup
13
15
  from .api_discovery import ApiDiscoveryHandler
14
16
  from .applications import ApplicationsHandler
@@ -32,6 +34,8 @@ from .user_groups import UserGroups
32
34
  from .view_areas import ViewAreaHandler
33
35
 
34
36
  if TYPE_CHECKING:
37
+ from aiohttp import BasicAuth as AiohttpBasicAuth, ClientSession
38
+
35
39
  from ..device import AxisDevice
36
40
  from ..models.api import ApiRequest
37
41
  from ..models.stream_profile import StreamProfile
@@ -45,10 +49,24 @@ TIME_OUT = 15
45
49
  class Vapix:
46
50
  """Vapix parameter request."""
47
51
 
52
+ auth: object
53
+
48
54
  def __init__(self, device: AxisDevice) -> None:
49
55
  """Store local reference to device config."""
50
56
  self.device = device
51
- self.auth = httpx.DigestAuth(device.config.username, device.config.password)
57
+ self._http_client = self._client_name()
58
+ self._aiohttp_digest_middleware: Any | None = None
59
+
60
+ if self._http_client == "aiohttp":
61
+ if device.config.auth_scheme == AuthScheme.BASIC:
62
+ self.auth = self._aiohttp_basic_auth()
63
+ else:
64
+ self.auth = None
65
+ self._aiohttp_digest_middleware = self._aiohttp_digest_middleware_obj()
66
+ elif device.config.auth_scheme == AuthScheme.BASIC:
67
+ self.auth = httpx.BasicAuth(device.config.username, device.config.password)
68
+ else:
69
+ self.auth = httpx.DigestAuth(device.config.username, device.config.password)
52
70
 
53
71
  self.users = Users(self)
54
72
  self.user_groups = UserGroups(self)
@@ -256,21 +274,44 @@ class Vapix:
256
274
  data: dict[str, str] | None = None,
257
275
  headers: dict[str, str] | None = None,
258
276
  params: dict[str, str] | None = None,
277
+ ) -> bytes:
278
+ """Make a request to the device."""
279
+ return await self._request(
280
+ method=method,
281
+ path=path,
282
+ content=content,
283
+ data=data,
284
+ headers=headers,
285
+ params=params,
286
+ allow_auto_basic_retry=True,
287
+ )
288
+
289
+ async def _request(
290
+ self,
291
+ method: str,
292
+ path: str,
293
+ content: bytes | None = None,
294
+ data: dict[str, str] | None = None,
295
+ headers: dict[str, str] | None = None,
296
+ params: dict[str, str] | None = None,
297
+ allow_auto_basic_retry: bool = False,
259
298
  ) -> bytes:
260
299
  """Make a request to the device."""
261
300
  url = self.device.config.url + path
262
301
  LOGGER.debug("%s, %s, '%s', '%s', '%s'", method, url, content, data, params)
263
302
 
264
303
  try:
265
- response = await self.device.config.session.request(
266
- method,
267
- url,
304
+ (
305
+ status_code,
306
+ response_headers,
307
+ response_content,
308
+ ) = await self._perform_request(
309
+ method=method,
310
+ url=url,
268
311
  content=content,
269
312
  data=data,
270
313
  headers=headers,
271
314
  params=params,
272
- auth=self.auth,
273
- timeout=TIME_OUT,
274
315
  )
275
316
 
276
317
  except httpx.TimeoutException as errt:
@@ -287,18 +328,197 @@ class Vapix:
287
328
  message = f"Unknown error: {err}"
288
329
  raise RequestError(message) from err
289
330
 
290
- try:
291
- response.raise_for_status()
331
+ except TimeoutError as errt:
332
+ message = "Timeout"
333
+ raise RequestError(message) from errt
292
334
 
293
- except httpx.HTTPStatusError as errh:
294
- LOGGER.debug("%s, %s", response, errh)
295
- raise_error(response.status_code)
335
+ except Exception as err:
336
+ if aiohttp is not None and isinstance(err, aiohttp.ClientConnectionError):
337
+ LOGGER.debug("%s", err)
338
+ message = f"Connection error: {err}"
339
+ raise RequestError(message) from err
340
+ if aiohttp is not None and isinstance(err, aiohttp.ClientError):
341
+ LOGGER.debug("%s", err)
342
+ message = f"Unknown error: {err}"
343
+ raise RequestError(message) from err
344
+ raise
345
+
346
+ if status_code >= 400:
347
+ if self._should_retry_with_basic(response_headers, allow_auto_basic_retry):
348
+ self.auth = self._basic_auth()
349
+ self._aiohttp_digest_middleware = None
350
+ return await self._request(
351
+ method=method,
352
+ path=path,
353
+ content=content,
354
+ data=data,
355
+ headers=headers,
356
+ params=params,
357
+ allow_auto_basic_retry=False,
358
+ )
359
+
360
+ LOGGER.debug("status=%s headers=%s", status_code, response_headers)
361
+ raise_error(status_code)
296
362
 
297
363
  LOGGER.debug(
298
364
  "Response (from %s %s): %s",
299
365
  self.device.config.host,
300
366
  path,
301
- response.content,
367
+ response_content,
368
+ )
369
+
370
+ return response_content
371
+
372
+ async def _perform_request(
373
+ self,
374
+ method: str,
375
+ url: str,
376
+ content: bytes | None,
377
+ data: dict[str, str] | None,
378
+ headers: dict[str, str] | None,
379
+ params: dict[str, str] | None,
380
+ ) -> tuple[int, dict[str, str], bytes]:
381
+ """Execute request and normalize responses from supported HTTP clients."""
382
+ if self._http_client == "aiohttp":
383
+ return await self._perform_aiohttp_request(
384
+ method=method,
385
+ url=url,
386
+ content=content,
387
+ data=data,
388
+ headers=headers,
389
+ params=params,
390
+ )
391
+ return await self._perform_httpx_request(
392
+ method=method,
393
+ url=url,
394
+ content=content,
395
+ data=data,
396
+ headers=headers,
397
+ params=params,
398
+ )
399
+
400
+ async def _perform_httpx_request(
401
+ self,
402
+ method: str,
403
+ url: str,
404
+ content: bytes | None,
405
+ data: dict[str, str] | None,
406
+ headers: dict[str, str] | None,
407
+ params: dict[str, str] | None,
408
+ ) -> tuple[int, dict[str, str], bytes]:
409
+ """Execute request with a httpx session."""
410
+ session = self._httpx_session()
411
+ response = await session.request(
412
+ method,
413
+ url,
414
+ content=content,
415
+ data=data,
416
+ headers=headers,
417
+ params=params,
418
+ auth=self._httpx_auth(),
419
+ timeout=TIME_OUT,
420
+ )
421
+ return response.status_code, dict(response.headers), response.content
422
+
423
+ async def _perform_aiohttp_request(
424
+ self,
425
+ method: str,
426
+ url: str,
427
+ content: bytes | None,
428
+ data: dict[str, str] | None,
429
+ headers: dict[str, str] | None,
430
+ params: dict[str, str] | None,
431
+ ) -> tuple[int, dict[str, str], bytes]:
432
+ """Execute request with an aiohttp session."""
433
+ request_data: bytes | dict[str, str] | None = (
434
+ content if content is not None else data
435
+ )
436
+ session = self._aiohttp_session()
437
+ request_kwargs: dict[str, Any] = {
438
+ "data": request_data,
439
+ "headers": headers,
440
+ "params": params,
441
+ "auth": self._aiohttp_auth(),
442
+ "timeout": TIME_OUT,
443
+ }
444
+ if middlewares := self._aiohttp_middlewares():
445
+ request_kwargs["middlewares"] = middlewares
446
+
447
+ async with session.request(method, url, **request_kwargs) as response:
448
+ response_content = await response.read()
449
+ return response.status, dict(response.headers), response_content
450
+
451
+ def _httpx_session(self) -> httpx.AsyncClient:
452
+ """Return session cast to a httpx client."""
453
+ return cast("httpx.AsyncClient", self.device.config.session)
454
+
455
+ def _aiohttp_session(self) -> ClientSession:
456
+ """Return session cast to an aiohttp client."""
457
+ return cast("ClientSession", self.device.config.session)
458
+
459
+ def _httpx_auth(self) -> httpx.Auth | None:
460
+ """Return auth cast for httpx requests."""
461
+ return cast("httpx.Auth | None", self.auth)
462
+
463
+ def _aiohttp_auth(self) -> AiohttpBasicAuth | None:
464
+ """Return auth cast for aiohttp requests."""
465
+ return cast("AiohttpBasicAuth | None", self.auth)
466
+
467
+ def _aiohttp_middlewares(self) -> tuple[Any, ...] | None:
468
+ """Return aiohttp middlewares used for auth challenges."""
469
+ if self._aiohttp_digest_middleware is None:
470
+ return None
471
+ return (self._aiohttp_digest_middleware,)
472
+
473
+ def _aiohttp_digest_middleware_obj(self) -> Any | None:
474
+ """Create aiohttp digest middleware when available and relevant."""
475
+ if self.device.config.auth_scheme == AuthScheme.BASIC:
476
+ return None
477
+
478
+ middleware_cls = getattr(aiohttp, "DigestAuthMiddleware", None)
479
+ if middleware_cls is None:
480
+ LOGGER.debug("aiohttp DigestAuthMiddleware unavailable, digest disabled")
481
+ return None
482
+
483
+ return middleware_cls(
484
+ login=self.device.config.username,
485
+ password=self.device.config.password,
486
+ )
487
+
488
+ def _basic_auth(self) -> object:
489
+ """Create basic auth object for configured HTTP client."""
490
+ if self._http_client == "aiohttp":
491
+ return self._aiohttp_basic_auth()
492
+ return httpx.BasicAuth(self.device.config.username, self.device.config.password)
493
+
494
+ def _aiohttp_basic_auth(self) -> object:
495
+ """Create aiohttp basic auth object."""
496
+ return aiohttp.BasicAuth(
497
+ self.device.config.username, self.device.config.password
302
498
  )
303
499
 
304
- return response.content
500
+ def _client_name(self) -> str:
501
+ """Return normalized client name from configured session object."""
502
+ if isinstance(self.device.config.session, aiohttp.ClientSession):
503
+ return "aiohttp"
504
+ return "httpx"
505
+
506
+ def _should_retry_with_basic(
507
+ self, headers: dict[str, str], allow_auto_basic_retry: bool
508
+ ) -> bool:
509
+ """Return if request should retry once with basic authentication."""
510
+ if not allow_auto_basic_retry:
511
+ return False
512
+
513
+ if self.device.config.auth_scheme != AuthScheme.AUTO:
514
+ return False
515
+
516
+ if self._http_client == "httpx" and not isinstance(self.auth, httpx.DigestAuth):
517
+ return False
518
+
519
+ expected_auth = ""
520
+ for header_name, header_value in headers.items():
521
+ if header_name.lower() == "www-authenticate":
522
+ expected_auth = header_value.lower()
523
+ break
524
+ return "basic" in expected_auth
@@ -77,7 +77,7 @@ class ApiId(enum.StrEnum):
77
77
  UNKNOWN = "unknown"
78
78
 
79
79
  @classmethod
80
- def _missing_(cls, value: object) -> "ApiId":
80
+ def _missing_(cls, value: object) -> ApiId:
81
81
  """Set default enum member if an unknown value is provided."""
82
82
  LOGGER.info("Unsupported API discovery ID '%s'", value)
83
83
  return ApiId.UNKNOWN
@@ -95,7 +95,7 @@ class ApiStatus(enum.StrEnum):
95
95
  UNKNOWN = "unknown"
96
96
 
97
97
  @classmethod
98
- def _missing_(cls, value: object) -> "ApiStatus":
98
+ def _missing_(cls, value: object) -> ApiStatus:
99
99
  """Set default enum member if an unknown value is provided."""
100
100
  LOGGER.debug("Unsupported API discovery status '%s'", value)
101
101
  return ApiStatus.UNKNOWN
@@ -0,0 +1,87 @@
1
+ """Python library to enable Axis devices to integrate with Home Assistant."""
2
+
3
+ from dataclasses import KW_ONLY, dataclass
4
+ import enum
5
+ import logging
6
+ from typing import TYPE_CHECKING
7
+
8
+ if TYPE_CHECKING:
9
+ from aiohttp import ClientSession
10
+ from httpx import AsyncClient
11
+
12
+ type HTTPSession = AsyncClient | ClientSession
13
+
14
+
15
+ LOGGER = logging.getLogger(__name__)
16
+
17
+
18
+ class AuthScheme(enum.StrEnum):
19
+ """Supported HTTP authentication schemes."""
20
+
21
+ AUTO = "auto"
22
+ BASIC = "basic"
23
+ DIGEST = "digest"
24
+
25
+ @classmethod
26
+ def _missing_(cls, value: object) -> AuthScheme:
27
+ """Set default enum member if an unknown value is provided."""
28
+ LOGGER.debug("Unsupported auth scheme '%s'", value)
29
+ return AuthScheme.AUTO
30
+
31
+
32
+ class WebProtocol(enum.StrEnum):
33
+ """Supported web protocols."""
34
+
35
+ HTTP = "http"
36
+ HTTPS = "https"
37
+
38
+ @classmethod
39
+ def _missing_(cls, value: object) -> WebProtocol:
40
+ """Set default enum member if an unknown value is provided."""
41
+ LOGGER.debug("Unsupported web protocol '%s'", value)
42
+ return WebProtocol.HTTP
43
+
44
+
45
+ @dataclass
46
+ class Configuration:
47
+ """Device configuration.
48
+
49
+ A port value of 0 means use the default port for the configured protocol.
50
+ """
51
+
52
+ session: HTTPSession
53
+ host: str
54
+ _: KW_ONLY
55
+ username: str
56
+ password: str
57
+ port: int = 0
58
+ web_proto: WebProtocol = WebProtocol.HTTP
59
+ verify_ssl: bool = False
60
+ is_companion: bool = False
61
+ auth_scheme: AuthScheme = AuthScheme.AUTO
62
+
63
+ def __post_init__(self) -> None:
64
+ """Normalize auth and protocol values to enums and resolve default port."""
65
+ self.web_proto = WebProtocol(self.web_proto)
66
+ self._validate_host()
67
+ if self.port == 0:
68
+ self.port = 443 if self.web_proto == WebProtocol.HTTPS else 80
69
+ self.auth_scheme = AuthScheme(self.auth_scheme)
70
+
71
+ def _validate_host(self) -> None:
72
+ """Validate that host is a plain hostname or IP address."""
73
+ if not self.host or self.host.strip() != self.host:
74
+ msg = "Host must be a non-empty hostname or IP address"
75
+ raise ValueError(msg)
76
+
77
+ if "://" in self.host or any(char in self.host for char in ("/", "?", "#")):
78
+ msg = (
79
+ "Host must not include scheme, path, query, or fragment; "
80
+ "use host, web_proto, and port separately"
81
+ )
82
+ raise ValueError(msg)
83
+
84
+ @property
85
+ def url(self) -> str:
86
+ """Represent device base url."""
87
+ return f"{self.web_proto}://{self.host}:{self.port}"