axis 67__tar.gz → 69__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 (117) hide show
  1. axis-69/PKG-INFO +98 -0
  2. axis-69/README.md +54 -0
  3. {axis-67 → axis-69}/axis/__main__.py +21 -0
  4. axis-69/axis/interfaces/aiohttp_digest.py +266 -0
  5. {axis-67 → axis-69}/axis/interfaces/api_handler.py +50 -9
  6. {axis-67 → axis-69}/axis/interfaces/applications/application_handler.py +2 -1
  7. {axis-67 → axis-69}/axis/interfaces/basic_device_info.py +3 -3
  8. {axis-67 → axis-69}/axis/interfaces/light_control.py +36 -34
  9. {axis-67 → axis-69}/axis/interfaces/mqtt.py +21 -15
  10. {axis-67 → axis-69}/axis/interfaces/pir_sensor_configuration.py +8 -9
  11. {axis-67 → axis-69}/axis/interfaces/port_management.py +15 -4
  12. {axis-67 → axis-69}/axis/interfaces/stream_profiles.py +3 -3
  13. {axis-67 → axis-69}/axis/interfaces/vapix.py +46 -23
  14. {axis-67 → axis-69}/axis/interfaces/view_areas.py +7 -7
  15. {axis-67 → axis-69}/axis/models/api_discovery.py +15 -0
  16. {axis-67 → axis-69}/axis/models/configuration.py +2 -0
  17. {axis-67 → axis-69}/axis/rtsp.py +5 -0
  18. {axis-67 → axis-69}/axis/stream_manager.py +75 -15
  19. axis-69/axis/stream_transport.py +34 -0
  20. axis-69/axis/websocket.py +357 -0
  21. axis-69/axis.egg-info/PKG-INFO +98 -0
  22. {axis-67 → axis-69}/axis.egg-info/SOURCES.txt +5 -1
  23. {axis-67 → axis-69}/axis.egg-info/requires.txt +10 -10
  24. {axis-67 → axis-69}/pyproject.toml +18 -12
  25. axis-69/tests/test_api_handler.py +178 -0
  26. {axis-67 → axis-69}/tests/test_basic_device_info.py +1 -1
  27. {axis-67 → axis-69}/tests/test_configuration.py +19 -0
  28. axis-69/tests/test_http_client_compat.py +393 -0
  29. {axis-67 → axis-69}/tests/test_light_control.py +47 -47
  30. {axis-67 → axis-69}/tests/test_main_http_client.py +18 -1
  31. {axis-67 → axis-69}/tests/test_mqtt.py +36 -0
  32. {axis-67 → axis-69}/tests/test_rtsp.py +9 -0
  33. {axis-67 → axis-69}/tests/test_stream_manager.py +141 -2
  34. {axis-67 → axis-69}/tests/test_vapix.py +113 -1
  35. axis-69/tests/test_websocket.py +540 -0
  36. axis-67/PKG-INFO +0 -45
  37. axis-67/README.md +0 -1
  38. axis-67/axis.egg-info/PKG-INFO +0 -45
  39. axis-67/tests/test_api_handler.py +0 -83
  40. axis-67/tests/test_http_client_compat.py +0 -165
  41. {axis-67 → axis-69}/LICENSE +0 -0
  42. {axis-67 → axis-69}/axis/__init__.py +0 -0
  43. {axis-67 → axis-69}/axis/device.py +0 -0
  44. {axis-67 → axis-69}/axis/errors.py +0 -0
  45. {axis-67 → axis-69}/axis/interfaces/__init__.py +0 -0
  46. {axis-67 → axis-69}/axis/interfaces/api_discovery.py +0 -0
  47. {axis-67 → axis-69}/axis/interfaces/applications/__init__.py +0 -0
  48. {axis-67 → axis-69}/axis/interfaces/applications/applications.py +0 -0
  49. {axis-67 → axis-69}/axis/interfaces/applications/fence_guard.py +0 -0
  50. {axis-67 → axis-69}/axis/interfaces/applications/loitering_guard.py +0 -0
  51. {axis-67 → axis-69}/axis/interfaces/applications/motion_guard.py +0 -0
  52. {axis-67 → axis-69}/axis/interfaces/applications/object_analytics.py +0 -0
  53. {axis-67 → axis-69}/axis/interfaces/applications/vmd4.py +0 -0
  54. {axis-67 → axis-69}/axis/interfaces/event_instances.py +0 -0
  55. {axis-67 → axis-69}/axis/interfaces/event_manager.py +0 -0
  56. {axis-67 → axis-69}/axis/interfaces/parameters/__init__.py +0 -0
  57. {axis-67 → axis-69}/axis/interfaces/parameters/brand.py +0 -0
  58. {axis-67 → axis-69}/axis/interfaces/parameters/image.py +0 -0
  59. {axis-67 → axis-69}/axis/interfaces/parameters/io_port.py +0 -0
  60. {axis-67 → axis-69}/axis/interfaces/parameters/param_cgi.py +0 -0
  61. {axis-67 → axis-69}/axis/interfaces/parameters/param_handler.py +0 -0
  62. {axis-67 → axis-69}/axis/interfaces/parameters/properties.py +0 -0
  63. {axis-67 → axis-69}/axis/interfaces/parameters/ptz.py +0 -0
  64. {axis-67 → axis-69}/axis/interfaces/parameters/stream_profile.py +0 -0
  65. {axis-67 → axis-69}/axis/interfaces/port_cgi.py +0 -0
  66. {axis-67 → axis-69}/axis/interfaces/ptz.py +0 -0
  67. {axis-67 → axis-69}/axis/interfaces/pwdgrp_cgi.py +0 -0
  68. {axis-67 → axis-69}/axis/interfaces/user_groups.py +0 -0
  69. {axis-67 → axis-69}/axis/models/__init__.py +0 -0
  70. {axis-67 → axis-69}/axis/models/api.py +0 -0
  71. {axis-67 → axis-69}/axis/models/applications/__init__.py +0 -0
  72. {axis-67 → axis-69}/axis/models/applications/application.py +0 -0
  73. {axis-67 → axis-69}/axis/models/applications/fence_guard.py +0 -0
  74. {axis-67 → axis-69}/axis/models/applications/loitering_guard.py +0 -0
  75. {axis-67 → axis-69}/axis/models/applications/motion_guard.py +0 -0
  76. {axis-67 → axis-69}/axis/models/applications/object_analytics.py +0 -0
  77. {axis-67 → axis-69}/axis/models/applications/vmd4.py +0 -0
  78. {axis-67 → axis-69}/axis/models/basic_device_info.py +0 -0
  79. {axis-67 → axis-69}/axis/models/event.py +0 -0
  80. {axis-67 → axis-69}/axis/models/event_instance.py +0 -0
  81. {axis-67 → axis-69}/axis/models/light_control.py +0 -0
  82. {axis-67 → axis-69}/axis/models/mqtt.py +0 -0
  83. {axis-67 → axis-69}/axis/models/parameters/__init__.py +0 -0
  84. {axis-67 → axis-69}/axis/models/parameters/brand.py +0 -0
  85. {axis-67 → axis-69}/axis/models/parameters/image.py +0 -0
  86. {axis-67 → axis-69}/axis/models/parameters/io_port.py +0 -0
  87. {axis-67 → axis-69}/axis/models/parameters/param_cgi.py +0 -0
  88. {axis-67 → axis-69}/axis/models/parameters/properties.py +0 -0
  89. {axis-67 → axis-69}/axis/models/parameters/ptz.py +0 -0
  90. {axis-67 → axis-69}/axis/models/parameters/stream_profile.py +0 -0
  91. {axis-67 → axis-69}/axis/models/pir_sensor_configuration.py +0 -0
  92. {axis-67 → axis-69}/axis/models/port_cgi.py +0 -0
  93. {axis-67 → axis-69}/axis/models/port_management.py +0 -0
  94. {axis-67 → axis-69}/axis/models/ptz_cgi.py +0 -0
  95. {axis-67 → axis-69}/axis/models/pwdgrp_cgi.py +0 -0
  96. {axis-67 → axis-69}/axis/models/stream_profile.py +0 -0
  97. {axis-67 → axis-69}/axis/models/user_group.py +0 -0
  98. {axis-67 → axis-69}/axis/models/view_area.py +0 -0
  99. {axis-67 → axis-69}/axis/py.typed +0 -0
  100. {axis-67 → axis-69}/axis.egg-info/dependency_links.txt +0 -0
  101. {axis-67 → axis-69}/axis.egg-info/entry_points.txt +0 -0
  102. {axis-67 → axis-69}/axis.egg-info/top_level.txt +0 -0
  103. {axis-67 → axis-69}/setup.cfg +0 -0
  104. {axis-67 → axis-69}/tests/test_api_discovery.py +0 -0
  105. {axis-67 → axis-69}/tests/test_auth_scheme.py +0 -0
  106. {axis-67 → axis-69}/tests/test_device.py +0 -0
  107. {axis-67 → axis-69}/tests/test_event.py +0 -0
  108. {axis-67 → axis-69}/tests/test_event_instances.py +0 -0
  109. {axis-67 → axis-69}/tests/test_event_stream.py +0 -0
  110. {axis-67 → axis-69}/tests/test_pir_sensor_configuration.py +0 -0
  111. {axis-67 → axis-69}/tests/test_port_cgi.py +0 -0
  112. {axis-67 → axis-69}/tests/test_port_management.py +0 -0
  113. {axis-67 → axis-69}/tests/test_ptz.py +0 -0
  114. {axis-67 → axis-69}/tests/test_pwdgrp_cgi.py +0 -0
  115. {axis-67 → axis-69}/tests/test_stream_profiles.py +0 -0
  116. {axis-67 → axis-69}/tests/test_user_groups.py +0 -0
  117. {axis-67 → axis-69}/tests/test_view_areas.py +0 -0
axis-69/PKG-INFO ADDED
@@ -0,0 +1,98 @@
1
+ Metadata-Version: 2.4
2
+ Name: axis
3
+ Version: 69
4
+ Summary: A Python library for communicating with devices from Axis Communications
5
+ Author-email: Robert Svensson <Kane610@users.noreply.github.com>
6
+ License: MIT
7
+ Project-URL: Source Code, https://github.com/Kane610/axis
8
+ Project-URL: Bug Reports, https://github.com/Kane610/axis/issues
9
+ Project-URL: Forum, https://community.home-assistant.io/t/axis-camera-component/
10
+ Keywords: axis,vapix,homeassistant
11
+ Classifier: Development Status :: 5 - Production/Stable
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3.14
16
+ Classifier: Topic :: Home Automation
17
+ Requires-Python: >=3.14.0
18
+ Description-Content-Type: text/markdown
19
+ License-File: LICENSE
20
+ Requires-Dist: aiohttp>=3.12
21
+ Requires-Dist: faust-cchardet>=2.1.18
22
+ Requires-Dist: httpx>=0.26
23
+ Requires-Dist: orjson>3.9
24
+ Requires-Dist: packaging>23
25
+ Requires-Dist: xmltodict>=0.13.0
26
+ Provides-Extra: requirements
27
+ 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"
30
+ Requires-Dist: packaging==26.2; extra == "requirements"
31
+ Requires-Dist: xmltodict==1.0.4; extra == "requirements"
32
+ Provides-Extra: requirements-test
33
+ Requires-Dist: mypy==1.20.2; extra == "requirements-test"
34
+ Requires-Dist: pytest==9.0.3; extra == "requirements-test"
35
+ Requires-Dist: pytest-aiohttp==1.1.0; extra == "requirements-test"
36
+ Requires-Dist: pytest-asyncio==1.3.0; extra == "requirements-test"
37
+ Requires-Dist: pytest-cov==7.1.0; extra == "requirements-test"
38
+ Requires-Dist: respx==0.23.1; extra == "requirements-test"
39
+ Requires-Dist: ruff==0.15.12; extra == "requirements-test"
40
+ Requires-Dist: types-xmltodict==v1.0.1.20260408; extra == "requirements-test"
41
+ Provides-Extra: requirements-dev
42
+ Requires-Dist: pre-commit==4.6.0; extra == "requirements-dev"
43
+ Dynamic: license-file
44
+
45
+ # axis
46
+
47
+ Python project to set up a connection towards Axis Communications devices and to subscribe to specific events on the metadatastream.
48
+
49
+ ## Development setup
50
+
51
+ `uv` is required for development setup:
52
+
53
+ ```bash
54
+ uv python install 3.14
55
+ uv sync --python 3.14 --all-extras
56
+ ```
57
+
58
+ Or run the bootstrap script, which installs `uv` if needed and provisions Python 3.14 automatically:
59
+
60
+ ```bash
61
+ ./setup.sh
62
+ ```
63
+
64
+ Dependencies are locked via `uv.lock`. Regenerate lock data when dependency inputs change:
65
+
66
+ ```bash
67
+ uv lock
68
+ ```
69
+
70
+ Run checks with `uv`:
71
+
72
+ ```bash
73
+ uv run ruff check .
74
+ uv run ruff format --check .
75
+ uv run mypy axis
76
+ uv run pytest
77
+ ```
78
+
79
+ Initial `ty` support is configured as an opt-in check and does not replace `mypy`:
80
+
81
+ ```bash
82
+ uvx ty check
83
+ ```
84
+
85
+ ## Initialization architecture
86
+
87
+ Vapix initialization is phase-based and driven by handler metadata:
88
+
89
+ - `API_DISCOVERY`: handlers initialized after API discovery.
90
+ - `PARAM_CGI_FALLBACK`: handlers that may initialize from parameter support when not listed in discovery.
91
+ - `APPLICATION`: handlers initialized after applications are loaded.
92
+
93
+ Handlers declare phase membership through `handler_groups` and may customize phase eligibility through `should_initialize_in_group`.
94
+
95
+ Example fallback policy:
96
+
97
+ - `LightHandler` participates in both `API_DISCOVERY` and `PARAM_CGI_FALLBACK`.
98
+ - In `PARAM_CGI_FALLBACK`, it initializes only when not listed in API discovery and listed in parameters.
axis-69/README.md ADDED
@@ -0,0 +1,54 @@
1
+ # axis
2
+
3
+ Python project to set up a connection towards Axis Communications devices and to subscribe to specific events on the metadatastream.
4
+
5
+ ## Development setup
6
+
7
+ `uv` is required for development setup:
8
+
9
+ ```bash
10
+ uv python install 3.14
11
+ uv sync --python 3.14 --all-extras
12
+ ```
13
+
14
+ Or run the bootstrap script, which installs `uv` if needed and provisions Python 3.14 automatically:
15
+
16
+ ```bash
17
+ ./setup.sh
18
+ ```
19
+
20
+ Dependencies are locked via `uv.lock`. Regenerate lock data when dependency inputs change:
21
+
22
+ ```bash
23
+ uv lock
24
+ ```
25
+
26
+ Run checks with `uv`:
27
+
28
+ ```bash
29
+ uv run ruff check .
30
+ uv run ruff format --check .
31
+ uv run mypy axis
32
+ uv run pytest
33
+ ```
34
+
35
+ Initial `ty` support is configured as an opt-in check and does not replace `mypy`:
36
+
37
+ ```bash
38
+ uvx ty check
39
+ ```
40
+
41
+ ## Initialization architecture
42
+
43
+ Vapix initialization is phase-based and driven by handler metadata:
44
+
45
+ - `API_DISCOVERY`: handlers initialized after API discovery.
46
+ - `PARAM_CGI_FALLBACK`: handlers that may initialize from parameter support when not listed in discovery.
47
+ - `APPLICATION`: handlers initialized after applications are loaded.
48
+
49
+ Handlers declare phase membership through `handler_groups` and may customize phase eligibility through `should_initialize_in_group`.
50
+
51
+ Example fallback policy:
52
+
53
+ - `LightHandler` participates in both `API_DISCOVERY` and `PARAM_CGI_FALLBACK`.
54
+ - In `PARAM_CGI_FALLBACK`, it initializes only when not listed in API discovery and listed in parameters.
@@ -28,10 +28,12 @@ async def axis_device(
28
28
  username: str,
29
29
  password: str,
30
30
  web_proto: WebProtocol,
31
+ stream_mode: str = "rtsp",
31
32
  is_companion: bool = False,
32
33
  ) -> axis.device.AxisDevice:
33
34
  """Create a Axis device."""
34
35
  session = create_session()
36
+ websocket_enabled, websocket_force = websocket_flags_from_mode(stream_mode)
35
37
  device = AxisDevice(
36
38
  Configuration(
37
39
  session,
@@ -41,6 +43,8 @@ async def axis_device(
41
43
  password=password,
42
44
  is_companion=is_companion,
43
45
  web_proto=web_proto,
46
+ websocket_enabled=websocket_enabled,
47
+ websocket_force=websocket_force,
44
48
  )
45
49
  )
46
50
 
@@ -74,6 +78,7 @@ async def main(
74
78
  params: bool,
75
79
  events: bool,
76
80
  web_proto: WebProtocol,
81
+ stream_mode: str,
77
82
  ) -> None:
78
83
  """CLI method for library."""
79
84
  LOGGER.info("Connecting to Axis device")
@@ -84,6 +89,7 @@ async def main(
84
89
  username,
85
90
  password,
86
91
  web_proto=web_proto,
92
+ stream_mode=stream_mode,
87
93
  )
88
94
 
89
95
  if not device:
@@ -126,6 +132,15 @@ async def close_session(session: aiohttp.ClientSession) -> None:
126
132
  await session.close()
127
133
 
128
134
 
135
+ def websocket_flags_from_mode(stream_mode: str) -> tuple[bool, bool]:
136
+ """Translate CLI stream mode to websocket enable/force flags."""
137
+ if stream_mode == "auto":
138
+ return True, False
139
+ if stream_mode == "event":
140
+ return True, True
141
+ return False, False
142
+
143
+
129
144
  if __name__ == "__main__":
130
145
  parser = argparse.ArgumentParser()
131
146
  parser.add_argument("host", type=str)
@@ -135,6 +150,11 @@ if __name__ == "__main__":
135
150
  parser.add_argument("--proto", type=str, default="http")
136
151
  parser.add_argument("--events", action="store_true")
137
152
  parser.add_argument("--params", action="store_true")
153
+ parser.add_argument(
154
+ "--stream-mode",
155
+ choices=["auto", "rtsp", "event"],
156
+ default="rtsp",
157
+ )
138
158
  parser.add_argument("-D", "--debug", action="store_true")
139
159
  args = parser.parse_args()
140
160
 
@@ -163,6 +183,7 @@ if __name__ == "__main__":
163
183
  params=args.params,
164
184
  events=args.events,
165
185
  web_proto=WebProtocol(args.proto),
186
+ stream_mode=args.stream_mode,
166
187
  )
167
188
  )
168
189
 
@@ -0,0 +1,266 @@
1
+ """Aiohttp digest authentication handler.
2
+
3
+ Implements library-managed RFC 2617 digest authentication for aiohttp requests
4
+ to handle special characters in request parameters that break middleware-based auth.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import hashlib
10
+ import logging
11
+ import re
12
+ import secrets
13
+ from typing import TYPE_CHECKING, Any, cast
14
+ from urllib.parse import quote, urlsplit
15
+
16
+ from ..models.configuration import AuthScheme
17
+
18
+ if TYPE_CHECKING:
19
+ from ..device import AxisDevice
20
+
21
+ LOGGER = logging.getLogger(__name__)
22
+ TIME_OUT = 15
23
+
24
+
25
+ class AiohttpDigestAuth:
26
+ """Manages digest authentication for aiohttp requests."""
27
+
28
+ def __init__(self, device: AxisDevice) -> None:
29
+ """Initialize digest auth handler."""
30
+ self.device = device
31
+ self._nonce: str | None = None
32
+ self._nonce_count = 0
33
+
34
+ def should_use_library_digest(self, http_client: str, has_basic_auth: bool) -> bool:
35
+ """Return if aiohttp requests should use library-managed digest auth.
36
+
37
+ Args:
38
+ http_client: Name of HTTP client ("aiohttp" or "httpx").
39
+ has_basic_auth: Whether basic auth is configured.
40
+
41
+ Returns:
42
+ True if library-managed digest should be used.
43
+
44
+ """
45
+ return (
46
+ http_client == "aiohttp"
47
+ and not has_basic_auth
48
+ and self.device.config.auth_scheme != AuthScheme.BASIC
49
+ )
50
+
51
+ def request_target(
52
+ self, url: str, params: dict[str, str] | None, should_encode: bool
53
+ ) -> tuple[str, dict[str, str] | None]:
54
+ """Return request URL and params for aiohttp request.
55
+
56
+ With library-managed digest auth, pre-encode params into the URL so the
57
+ signed URI exactly matches the request-target on the wire.
58
+
59
+ Args:
60
+ url: Base request URL.
61
+ params: Optional query parameters.
62
+ should_encode: Whether to pre-encode params for digest signing.
63
+
64
+ Returns:
65
+ Tuple of (request_url, request_params) to use in actual request.
66
+
67
+ """
68
+ if params is None or not should_encode:
69
+ return url, params
70
+
71
+ separator = "&" if "?" in url else "?"
72
+ encoded_parts = [
73
+ f"{quote(k, safe='')}={quote(v, safe='')}" for k, v in params.items()
74
+ ]
75
+ encoded_query = "&".join(encoded_parts)
76
+ encoded_url = f"{url}{separator}{encoded_query}"
77
+ return encoded_url, None
78
+
79
+ def extract_challenge(self, headers: Any) -> str | None:
80
+ """Return digest challenge header when present.
81
+
82
+ Args:
83
+ headers: Response headers (dict-like or aiohttp MultiDictProxy).
84
+
85
+ Returns:
86
+ Digest challenge string if present, None otherwise.
87
+
88
+ """
89
+ candidates: list[str] = []
90
+ if hasattr(headers, "getall"):
91
+ candidates.extend(cast("list[str]", headers.getall("WWW-Authenticate", [])))
92
+ else:
93
+ for name, value in cast("dict[str, str]", headers).items():
94
+ if name.lower() == "www-authenticate":
95
+ candidates.append(value)
96
+
97
+ for value in candidates:
98
+ if value.lower().startswith("digest "):
99
+ return value
100
+ return None
101
+
102
+ def build_authorization(
103
+ self,
104
+ method: str,
105
+ request_url: str,
106
+ digest_challenge: str,
107
+ ) -> str | None:
108
+ """Build digest authorization header from challenge and request URI.
109
+
110
+ Args:
111
+ method: HTTP method (GET, POST, etc.).
112
+ request_url: Full request URL (will extract path + query).
113
+ digest_challenge: Digest challenge string from WWW-Authenticate header.
114
+
115
+ Returns:
116
+ Authorization header value or None if digest cannot be built.
117
+
118
+ """
119
+ challenge_values = {
120
+ key.lower(): value.strip('"')
121
+ for key, value in re.findall(
122
+ r"(\w+)=((?:\"[^\"]*\")|(?:[^,]+))", digest_challenge
123
+ )
124
+ }
125
+
126
+ realm = challenge_values.get("realm")
127
+ nonce = challenge_values.get("nonce")
128
+ if realm is None or nonce is None:
129
+ return None
130
+
131
+ algorithm = challenge_values.get("algorithm", "MD5").upper()
132
+ if algorithm != "MD5":
133
+ LOGGER.debug("Unsupported digest algorithm for aiohttp path: %s", algorithm)
134
+ return None
135
+
136
+ uri = self._digest_uri(request_url)
137
+ qop = None
138
+ if qop_header := challenge_values.get("qop"):
139
+ qop_values = [value.strip() for value in qop_header.split(",")]
140
+ if "auth" in qop_values:
141
+ qop = "auth"
142
+
143
+ username = self.device.config.username
144
+ password = self.device.config.password
145
+ ha1 = hashlib.md5(f"{username}:{realm}:{password}".encode()).hexdigest()
146
+ ha2 = hashlib.md5(f"{method.upper()}:{uri}".encode()).hexdigest()
147
+
148
+ parts = [
149
+ f'username="{username}"',
150
+ f'realm="{realm}"',
151
+ f'nonce="{nonce}"',
152
+ f'uri="{uri}"',
153
+ 'algorithm="MD5"',
154
+ ]
155
+
156
+ if opaque := challenge_values.get("opaque"):
157
+ parts.append(f'opaque="{opaque}"')
158
+
159
+ if qop == "auth":
160
+ if nonce != self._nonce:
161
+ self._nonce = nonce
162
+ self._nonce_count = 0
163
+
164
+ self._nonce_count += 1
165
+ nc = f"{self._nonce_count:08x}"
166
+ cnonce = secrets.token_hex(8)
167
+ response = hashlib.md5(
168
+ f"{ha1}:{nonce}:{nc}:{cnonce}:{qop}:{ha2}".encode()
169
+ ).hexdigest()
170
+ parts.extend(
171
+ [
172
+ f'response="{response}"',
173
+ f"qop={qop}",
174
+ f"nc={nc}",
175
+ f'cnonce="{cnonce}"',
176
+ ]
177
+ )
178
+ else:
179
+ response = hashlib.md5(f"{ha1}:{nonce}:{ha2}".encode()).hexdigest()
180
+ parts.append(f'response="{response}"')
181
+
182
+ return f"Digest {', '.join(parts)}"
183
+
184
+ def _digest_uri(self, request_url: str) -> str:
185
+ """Return path + query request-target URI for digest signing.
186
+
187
+ Args:
188
+ request_url: Full request URL.
189
+
190
+ Returns:
191
+ Path and query string for digest signature.
192
+
193
+ """
194
+ split_result = urlsplit(request_url)
195
+ if split_result.query:
196
+ return f"{split_result.path}?{split_result.query}"
197
+ return split_result.path
198
+
199
+ async def perform_request(
200
+ self,
201
+ session: Any,
202
+ method: str,
203
+ url: str,
204
+ request_data: bytes | dict[str, str] | None,
205
+ headers: dict[str, str] | None,
206
+ params: dict[str, str] | None,
207
+ ) -> tuple[int, dict[str, str], bytes]:
208
+ """Execute aiohttp request with digest auth handling.
209
+
210
+ Args:
211
+ session: aiohttp ClientSession.
212
+ method: HTTP method.
213
+ url: Request URL.
214
+ request_data: Request body (bytes or form data).
215
+ headers: Request headers.
216
+ params: Query parameters.
217
+
218
+ Returns:
219
+ Tuple of (status_code, response_headers, response_content).
220
+
221
+ """
222
+ request_url, request_params = self.request_target(url, params, True)
223
+ request_headers = dict(headers) if headers is not None else {}
224
+
225
+ # First attempt without auth to get challenge
226
+ async with session.request(
227
+ method,
228
+ request_url,
229
+ data=request_data,
230
+ headers=request_headers,
231
+ params=request_params,
232
+ auth=None,
233
+ timeout=TIME_OUT,
234
+ ) as response:
235
+ response_content = await response.read()
236
+ response_headers = dict(response.headers)
237
+ if response.status != 401:
238
+ return response.status, response_headers, response_content
239
+
240
+ digest_challenge = self.extract_challenge(response.headers)
241
+ if digest_challenge is None:
242
+ return response.status, response_headers, response_content
243
+
244
+ # Build digest auth and retry
245
+ digest_authorization = self.build_authorization(
246
+ method=method,
247
+ request_url=request_url,
248
+ digest_challenge=digest_challenge,
249
+ )
250
+ if digest_authorization is None:
251
+ return 401, {}, b""
252
+
253
+ retry_headers = dict(request_headers)
254
+ retry_headers["Authorization"] = digest_authorization
255
+
256
+ async with session.request(
257
+ method,
258
+ request_url,
259
+ data=request_data,
260
+ headers=retry_headers,
261
+ params=request_params,
262
+ auth=None,
263
+ timeout=TIME_OUT,
264
+ ) as response:
265
+ response_content = await response.read()
266
+ return response.status, dict(response.headers), response_content
@@ -8,6 +8,7 @@ from collections.abc import (
8
8
  Sequence,
9
9
  ValuesView,
10
10
  )
11
+ import enum
11
12
  from typing import TYPE_CHECKING, Generic, final
12
13
 
13
14
  from ..errors import Forbidden, PathNotFound, Unauthorized
@@ -25,6 +26,14 @@ UnsubscribeType = Callable[[], None]
25
26
  ID_FILTER_ALL = "*"
26
27
 
27
28
 
29
+ class HandlerGroup(enum.Enum):
30
+ """Group handlers by initialization phase in Vapix."""
31
+
32
+ API_DISCOVERY = "api_discovery"
33
+ PARAM_CGI_FALLBACK = "param_cgi_fallback"
34
+ APPLICATION = "application"
35
+
36
+
28
37
  class SubscriptionHandler:
29
38
  """Manage subscription and notification to subscribers."""
30
39
 
@@ -75,12 +84,14 @@ class ApiHandler(SubscriptionHandler, Generic[ApiItemT]):
75
84
 
76
85
  api_id: ApiId | None = None
77
86
  default_api_version: str | None = None
87
+ handler_groups: tuple[HandlerGroup, ...] = ()
78
88
  skip_support_check = False
79
89
 
80
90
  def __init__(self, vapix: Vapix) -> None:
81
91
  """Initialize API items."""
82
92
  super().__init__()
83
93
  self.vapix = vapix
94
+ self.vapix._register_handler(self)
84
95
  self._items: dict[str, ApiItemT] = {}
85
96
  self.initialized = False
86
97
 
@@ -101,6 +112,10 @@ class ApiHandler(SubscriptionHandler, Generic[ApiItemT]):
101
112
  """Is API listed in parameters."""
102
113
  return False
103
114
 
115
+ def should_initialize_in_group(self, group: HandlerGroup) -> bool:
116
+ """Return whether handler should initialize in the given group."""
117
+ return True
118
+
104
119
  async def _api_request(self) -> dict[str, ApiItemT]:
105
120
  """Get API data method defined by subclass."""
106
121
  raise NotImplementedError
@@ -134,15 +149,41 @@ class ApiHandler(SubscriptionHandler, Generic[ApiItemT]):
134
149
  return self.initialized
135
150
 
136
151
  @property
137
- def api_version(self) -> str | None:
138
- """Latest API version supported."""
139
- if (
140
- self.api_id is not None
141
- and (discovery_item := self.vapix.api_discovery.get(self.api_id))
142
- is not None
143
- ):
144
- return discovery_item.version
145
- return self.default_api_version
152
+ def api_version(self) -> str:
153
+ """Latest API version supported.
154
+
155
+ Returns the API version in this order of precedence:
156
+ 1. Version from device discovery (dynamic, device-specific)
157
+ 2. Handler's default_api_version (static, library-defined)
158
+ 3. Empty string "" (for handlers without discovery support)
159
+
160
+ Note: This property always returns a string (never None). Handlers that don't
161
+ support API versioning (no api_id) return empty string, which is safe because
162
+ they don't send api_version in their requests.
163
+
164
+ Returns:
165
+ str: API version (e.g., "1.0", "1.1") or empty string if not available.
166
+
167
+ """
168
+ discovery_version = self._get_api_discovery_version()
169
+ if discovery_version is not None:
170
+ return discovery_version
171
+ return self.default_api_version or ""
172
+
173
+ def _get_api_discovery_version(self) -> str | None:
174
+ """Get API version from discovery data when available."""
175
+ if self.api_id is None:
176
+ return None
177
+
178
+ discovery_item = self.vapix.api_discovery.get(self.api_id)
179
+ if discovery_item is None:
180
+ return None
181
+
182
+ discovery_version = getattr(discovery_item, "version", None)
183
+ if isinstance(discovery_version, str):
184
+ return discovery_version
185
+
186
+ return None
146
187
 
147
188
  def items(self) -> ItemsView[str, ApiItemT]:
148
189
  """Return items."""
@@ -4,13 +4,14 @@ from abc import abstractmethod
4
4
 
5
5
  from ...models.api import ApiItemT
6
6
  from ...models.applications.application import ApplicationName, ApplicationStatus
7
- from ..api_handler import ApiHandler
7
+ from ..api_handler import ApiHandler, HandlerGroup
8
8
 
9
9
 
10
10
  class ApplicationHandler(ApiHandler[ApiItemT]):
11
11
  """Generic application handler."""
12
12
 
13
13
  app_name: ApplicationName
14
+ handler_groups = (HandlerGroup.APPLICATION,)
14
15
 
15
16
  @property
16
17
  def supported(self) -> bool:
@@ -14,7 +14,7 @@ from ..models.basic_device_info import (
14
14
  GetSupportedVersionsRequest,
15
15
  GetSupportedVersionsResponse,
16
16
  )
17
- from .api_handler import ApiHandler
17
+ from .api_handler import ApiHandler, HandlerGroup
18
18
 
19
19
 
20
20
  class BasicDeviceInfoHandler(ApiHandler[DeviceInformation]):
@@ -22,6 +22,7 @@ class BasicDeviceInfoHandler(ApiHandler[DeviceInformation]):
22
22
 
23
23
  api_id = ApiId.BASIC_DEVICE_INFO
24
24
  default_api_version = API_VERSION
25
+ handler_groups = (HandlerGroup.API_DISCOVERY,)
25
26
 
26
27
  async def _api_request(self) -> dict[str, DeviceInformation]:
27
28
  """Get default data of basic device information."""
@@ -29,9 +30,8 @@ class BasicDeviceInfoHandler(ApiHandler[DeviceInformation]):
29
30
 
30
31
  async def get_all_properties(self) -> dict[str, DeviceInformation]:
31
32
  """List all properties of basic device info."""
32
- discovery_item = self.vapix.api_discovery[self.api_id]
33
33
  bytes_data = await self.vapix.api_request(
34
- GetAllPropertiesRequest(discovery_item.version)
34
+ GetAllPropertiesRequest(self.api_version)
35
35
  )
36
36
  response = GetAllPropertiesResponse.decode(bytes_data)
37
37
  return {"0": response.data}