axis 74__tar.gz → 75__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 (144) hide show
  1. {axis-74 → axis-75}/PKG-INFO +25 -17
  2. {axis-74 → axis-75}/README.md +15 -7
  3. {axis-74 → axis-75}/axis/cli/core/router.py +10 -3
  4. {axis-74 → axis-75}/axis/cli/main.py +5 -1
  5. {axis-74 → axis-75}/axis/cli/packs/events.py +44 -1
  6. axis-75/axis/interfaces/firmware_management.py +21 -0
  7. axis-75/axis/interfaces/restart_cgi.py +17 -0
  8. {axis-74 → axis-75}/axis/interfaces/vapix.py +30 -15
  9. axis-75/axis/interfaces/virtual_input.py +38 -0
  10. {axis-74 → axis-75}/axis/models/api.py +2 -0
  11. {axis-74 → axis-75}/axis/models/api_discovery.py +2 -0
  12. {axis-74 → axis-75}/axis/models/basic_device_info.py +2 -0
  13. axis-75/axis/models/firmware_management.py +64 -0
  14. {axis-74 → axis-75}/axis/models/parameters/properties.py +11 -0
  15. axis-75/axis/models/restart_cgi.py +15 -0
  16. axis-75/axis/models/virtual_input.py +138 -0
  17. {axis-74 → axis-75}/axis/rtsp.py +115 -19
  18. {axis-74 → axis-75}/axis/stream_manager.py +4 -2
  19. {axis-74 → axis-75}/axis/websocket.py +22 -5
  20. {axis-74 → axis-75}/axis.egg-info/PKG-INFO +25 -17
  21. {axis-74 → axis-75}/axis.egg-info/SOURCES.txt +9 -1
  22. {axis-74 → axis-75}/axis.egg-info/requires.txt +9 -9
  23. {axis-74 → axis-75}/pyproject.toml +15 -15
  24. {axis-74 → axis-75}/tests/test_auth_scheme.py +5 -4
  25. {axis-74 → axis-75}/tests/test_configuration.py +1 -0
  26. {axis-74 → axis-75}/tests/test_event.py +13 -0
  27. axis-75/tests/test_firmware_management.py +116 -0
  28. axis-75/tests/test_http_client_compat.py +219 -0
  29. axis-75/tests/test_main_http_client.py +263 -0
  30. {axis-74 → axis-75}/tests/test_request_response_contracts.py +11 -0
  31. axis-75/tests/test_restart_cgi.py +34 -0
  32. {axis-74 → axis-75}/tests/test_rtsp.py +235 -11
  33. {axis-74 → axis-75}/tests/test_stream_manager.py +54 -0
  34. {axis-74 → axis-75}/tests/test_vapix.py +109 -0
  35. axis-75/tests/test_virtual_input.py +168 -0
  36. {axis-74 → axis-75}/tests/test_websocket.py +57 -5
  37. axis-74/axis/interfaces/aiohttp_digest.py +0 -264
  38. axis-74/tests/test_http_client_compat.py +0 -370
  39. axis-74/tests/test_main_http_client.py +0 -39
  40. {axis-74 → axis-75}/LICENSE +0 -0
  41. {axis-74 → axis-75}/axis/__init__.py +0 -0
  42. {axis-74 → axis-75}/axis/__main__.py +0 -0
  43. {axis-74 → axis-75}/axis/cli/__init__.py +0 -0
  44. {axis-74 → axis-75}/axis/cli/core/__init__.py +0 -0
  45. {axis-74 → axis-75}/axis/cli/core/context.py +0 -0
  46. {axis-74 → axis-75}/axis/cli/core/contracts.py +0 -0
  47. {axis-74 → axis-75}/axis/cli/core/gateway.py +0 -0
  48. {axis-74 → axis-75}/axis/cli/core/io.py +0 -0
  49. {axis-74 → axis-75}/axis/cli/core/registry.py +0 -0
  50. {axis-74 → axis-75}/axis/cli/packs/__init__.py +0 -0
  51. {axis-74 → axis-75}/axis/cli/packs/accounts.py +0 -0
  52. {axis-74 → axis-75}/axis/cli/packs/api.py +0 -0
  53. {axis-74 → axis-75}/axis/cli/packs/devices.py +0 -0
  54. {axis-74 → axis-75}/axis/cli/packs/navigation.py +0 -0
  55. {axis-74 → axis-75}/axis/device.py +0 -0
  56. {axis-74 → axis-75}/axis/errors.py +0 -0
  57. {axis-74 → axis-75}/axis/interfaces/__init__.py +0 -0
  58. {axis-74 → axis-75}/axis/interfaces/api_discovery.py +0 -0
  59. {axis-74 → axis-75}/axis/interfaces/api_handler.py +0 -0
  60. {axis-74 → axis-75}/axis/interfaces/applications/__init__.py +0 -0
  61. {axis-74 → axis-75}/axis/interfaces/applications/application_handler.py +0 -0
  62. {axis-74 → axis-75}/axis/interfaces/applications/applications.py +0 -0
  63. {axis-74 → axis-75}/axis/interfaces/applications/fence_guard.py +0 -0
  64. {axis-74 → axis-75}/axis/interfaces/applications/loitering_guard.py +0 -0
  65. {axis-74 → axis-75}/axis/interfaces/applications/motion_guard.py +0 -0
  66. {axis-74 → axis-75}/axis/interfaces/applications/object_analytics.py +0 -0
  67. {axis-74 → axis-75}/axis/interfaces/applications/vmd4.py +0 -0
  68. {axis-74 → axis-75}/axis/interfaces/basic_device_info.py +0 -0
  69. {axis-74 → axis-75}/axis/interfaces/event_instances.py +0 -0
  70. {axis-74 → axis-75}/axis/interfaces/event_manager.py +0 -0
  71. {axis-74 → axis-75}/axis/interfaces/light_control.py +0 -0
  72. {axis-74 → axis-75}/axis/interfaces/mqtt.py +0 -0
  73. {axis-74 → axis-75}/axis/interfaces/parameters/__init__.py +0 -0
  74. {axis-74 → axis-75}/axis/interfaces/parameters/brand.py +0 -0
  75. {axis-74 → axis-75}/axis/interfaces/parameters/image.py +0 -0
  76. {axis-74 → axis-75}/axis/interfaces/parameters/io_port.py +0 -0
  77. {axis-74 → axis-75}/axis/interfaces/parameters/param_cgi.py +0 -0
  78. {axis-74 → axis-75}/axis/interfaces/parameters/param_handler.py +0 -0
  79. {axis-74 → axis-75}/axis/interfaces/parameters/properties.py +0 -0
  80. {axis-74 → axis-75}/axis/interfaces/parameters/ptz.py +0 -0
  81. {axis-74 → axis-75}/axis/interfaces/parameters/stream_profile.py +0 -0
  82. {axis-74 → axis-75}/axis/interfaces/pir_sensor_configuration.py +0 -0
  83. {axis-74 → axis-75}/axis/interfaces/port_cgi.py +0 -0
  84. {axis-74 → axis-75}/axis/interfaces/port_management.py +0 -0
  85. {axis-74 → axis-75}/axis/interfaces/ptz.py +0 -0
  86. {axis-74 → axis-75}/axis/interfaces/pwdgrp_cgi.py +0 -0
  87. {axis-74 → axis-75}/axis/interfaces/stream_profiles.py +0 -0
  88. {axis-74 → axis-75}/axis/interfaces/temperature_control.py +0 -0
  89. {axis-74 → axis-75}/axis/interfaces/user_groups.py +0 -0
  90. {axis-74 → axis-75}/axis/interfaces/view_areas.py +0 -0
  91. {axis-74 → axis-75}/axis/models/__init__.py +0 -0
  92. {axis-74 → axis-75}/axis/models/applications/__init__.py +0 -0
  93. {axis-74 → axis-75}/axis/models/applications/application.py +0 -0
  94. {axis-74 → axis-75}/axis/models/applications/fence_guard.py +0 -0
  95. {axis-74 → axis-75}/axis/models/applications/loitering_guard.py +0 -0
  96. {axis-74 → axis-75}/axis/models/applications/motion_guard.py +0 -0
  97. {axis-74 → axis-75}/axis/models/applications/object_analytics.py +0 -0
  98. {axis-74 → axis-75}/axis/models/applications/vmd4.py +0 -0
  99. {axis-74 → axis-75}/axis/models/configuration.py +0 -0
  100. {axis-74 → axis-75}/axis/models/event.py +0 -0
  101. {axis-74 → axis-75}/axis/models/event_instance.py +0 -0
  102. {axis-74 → axis-75}/axis/models/light_control.py +0 -0
  103. {axis-74 → axis-75}/axis/models/mqtt.py +0 -0
  104. {axis-74 → axis-75}/axis/models/parameters/__init__.py +0 -0
  105. {axis-74 → axis-75}/axis/models/parameters/brand.py +0 -0
  106. {axis-74 → axis-75}/axis/models/parameters/image.py +0 -0
  107. {axis-74 → axis-75}/axis/models/parameters/io_port.py +0 -0
  108. {axis-74 → axis-75}/axis/models/parameters/param_cgi.py +0 -0
  109. {axis-74 → axis-75}/axis/models/parameters/ptz.py +0 -0
  110. {axis-74 → axis-75}/axis/models/parameters/stream_profile.py +0 -0
  111. {axis-74 → axis-75}/axis/models/pir_sensor_configuration.py +0 -0
  112. {axis-74 → axis-75}/axis/models/port_cgi.py +0 -0
  113. {axis-74 → axis-75}/axis/models/port_management.py +0 -0
  114. {axis-74 → axis-75}/axis/models/ptz_cgi.py +0 -0
  115. {axis-74 → axis-75}/axis/models/pwdgrp_cgi.py +0 -0
  116. {axis-74 → axis-75}/axis/models/stream_profile.py +0 -0
  117. {axis-74 → axis-75}/axis/models/temperature_control.py +0 -0
  118. {axis-74 → axis-75}/axis/models/user_group.py +0 -0
  119. {axis-74 → axis-75}/axis/models/view_area.py +0 -0
  120. {axis-74 → axis-75}/axis/py.typed +0 -0
  121. {axis-74 → axis-75}/axis/stream_transport.py +0 -0
  122. {axis-74 → axis-75}/axis.egg-info/dependency_links.txt +0 -0
  123. {axis-74 → axis-75}/axis.egg-info/entry_points.txt +0 -0
  124. {axis-74 → axis-75}/axis.egg-info/top_level.txt +0 -0
  125. {axis-74 → axis-75}/setup.cfg +0 -0
  126. {axis-74 → axis-75}/tests/test_api_discovery.py +0 -0
  127. {axis-74 → axis-75}/tests/test_api_handler.py +0 -0
  128. {axis-74 → axis-75}/tests/test_basic_device_info.py +0 -0
  129. {axis-74 → axis-75}/tests/test_conftest.py +0 -0
  130. {axis-74 → axis-75}/tests/test_device.py +0 -0
  131. {axis-74 → axis-75}/tests/test_event_instances.py +0 -0
  132. {axis-74 → axis-75}/tests/test_event_instances_protocol_parity.py +0 -0
  133. {axis-74 → axis-75}/tests/test_event_stream.py +0 -0
  134. {axis-74 → axis-75}/tests/test_light_control.py +0 -0
  135. {axis-74 → axis-75}/tests/test_mqtt.py +0 -0
  136. {axis-74 → axis-75}/tests/test_pir_sensor_configuration.py +0 -0
  137. {axis-74 → axis-75}/tests/test_port_cgi.py +0 -0
  138. {axis-74 → axis-75}/tests/test_port_management.py +0 -0
  139. {axis-74 → axis-75}/tests/test_ptz.py +0 -0
  140. {axis-74 → axis-75}/tests/test_pwdgrp_cgi.py +0 -0
  141. {axis-74 → axis-75}/tests/test_stream_profiles.py +0 -0
  142. {axis-74 → axis-75}/tests/test_temperature_control.py +0 -0
  143. {axis-74 → axis-75}/tests/test_user_groups.py +0 -0
  144. {axis-74 → axis-75}/tests/test_view_areas.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: axis
3
- Version: 74
3
+ Version: 75
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
@@ -17,7 +17,7 @@ Classifier: Topic :: Home Automation
17
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
+ Requires-Dist: aiohttp>=3.14.0
21
21
  Requires-Dist: faust-cchardet>=2.1.18
22
22
  Requires-Dist: orjson>3.9
23
23
  Requires-Dist: packaging>23
@@ -26,21 +26,21 @@ Requires-Dist: tomli-w>=1.0
26
26
  Requires-Dist: xmltodict>=0.13.0
27
27
  Requires-Dist: zeroconf>=0.150.0
28
28
  Provides-Extra: requirements
29
- Requires-Dist: aiohttp==3.14.1; extra == "requirements"
30
- Requires-Dist: orjson==3.11.9; extra == "requirements"
31
- Requires-Dist: packaging==26.2; extra == "requirements"
29
+ Requires-Dist: aiohttp==3.14.5; extra == "requirements"
30
+ Requires-Dist: orjson==3.13.0; extra == "requirements"
31
+ Requires-Dist: packaging==26.3; extra == "requirements"
32
32
  Requires-Dist: xmltodict==1.0.4; extra == "requirements"
33
33
  Requires-Dist: zeroconf>=0.150.0; extra == "requirements"
34
34
  Provides-Extra: requirements-test
35
- Requires-Dist: mypy==2.1.0; extra == "requirements-test"
36
- Requires-Dist: pytest==9.1.1; extra == "requirements-test"
35
+ Requires-Dist: mypy==2.4.1; extra == "requirements-test"
36
+ Requires-Dist: pytest==9.2.0; extra == "requirements-test"
37
37
  Requires-Dist: pytest-aiohttp==1.1.1; extra == "requirements-test"
38
- Requires-Dist: pytest-asyncio==1.4.0; extra == "requirements-test"
38
+ Requires-Dist: pytest-asyncio==1.4.1.post1; extra == "requirements-test"
39
39
  Requires-Dist: pytest-cov==7.1.0; extra == "requirements-test"
40
- Requires-Dist: ruff==0.15.20; extra == "requirements-test"
40
+ Requires-Dist: ruff==0.17.4; extra == "requirements-test"
41
41
  Requires-Dist: types-xmltodict==v1.0.1.20260518; extra == "requirements-test"
42
42
  Provides-Extra: requirements-dev
43
- Requires-Dist: pre-commit==4.6.0; extra == "requirements-dev"
43
+ Requires-Dist: pre-commit==4.7.0; extra == "requirements-dev"
44
44
  Dynamic: license-file
45
45
 
46
46
  # axis
@@ -62,11 +62,11 @@ Or run the bootstrap script, which installs `uv` if needed and provisions Python
62
62
  ./setup.sh
63
63
  ```
64
64
 
65
- Dependencies are locked via `uv.lock`. Regenerate lock data when dependency inputs change:
66
-
67
- ```bash
68
- uv lock
69
- ```
65
+ Dependencies are locked via `uv.lock`. The pre-commit hook installed by `./setup.sh`
66
+ automatically refreshes it when `pyproject.toml` or `uv.lock` is included in a commit.
67
+ If the hook modifies it, include the updated lockfile when retrying the commit.
68
+ CI rejects stale or missing lockfiles, even when local hooks are bypassed, and
69
+ runs checks with `--locked` to prevent implicit lockfile updates.
70
70
 
71
71
  Run checks with `uv`:
72
72
 
@@ -77,6 +77,14 @@ uv run mypy axis
77
77
  uv run pytest
78
78
  ```
79
79
 
80
+ Pull requests expose independent **Lint**, **Formatting**, **Typing**, and
81
+ **Tests (Python 3.14)** checks in GitHub. A failed check does not prevent the
82
+ others from running. Ruff lint findings also appear as file-and-line annotations.
83
+ The test job uploads JUnit XML and XML/HTML coverage reports as the
84
+ `test-reports-python-3.14` artifact, including when tests fail.
85
+ The workflow run summary shows the test outcome, counts, duration, line coverage,
86
+ and configured coverage threshold. Missing reports are flagged explicitly.
87
+
80
88
  Initial `ty` support is configured as an opt-in check and does not replace `mypy`:
81
89
 
82
90
  ```bash
@@ -123,12 +131,12 @@ Examples:
123
131
  ```python
124
132
  @dataclass
125
133
  class ListApisRequest(ApiRequest[GetAllApisResponse]):
126
- response_type = GetAllApisResponse
134
+ response_type = GetAllApisResponse
127
135
 
128
136
 
129
137
  @dataclass
130
138
  class SetPortsRequest(ApiRequest[ApiResponse[bytes]]):
131
- response_type = BytesResponse
139
+ response_type = BytesResponse
132
140
  ```
133
141
 
134
142
  Handler methods may unwrap `.data` when they intentionally preserve a bytes-returning boundary.
@@ -17,11 +17,11 @@ Or run the bootstrap script, which installs `uv` if needed and provisions Python
17
17
  ./setup.sh
18
18
  ```
19
19
 
20
- Dependencies are locked via `uv.lock`. Regenerate lock data when dependency inputs change:
21
-
22
- ```bash
23
- uv lock
24
- ```
20
+ Dependencies are locked via `uv.lock`. The pre-commit hook installed by `./setup.sh`
21
+ automatically refreshes it when `pyproject.toml` or `uv.lock` is included in a commit.
22
+ If the hook modifies it, include the updated lockfile when retrying the commit.
23
+ CI rejects stale or missing lockfiles, even when local hooks are bypassed, and
24
+ runs checks with `--locked` to prevent implicit lockfile updates.
25
25
 
26
26
  Run checks with `uv`:
27
27
 
@@ -32,6 +32,14 @@ uv run mypy axis
32
32
  uv run pytest
33
33
  ```
34
34
 
35
+ Pull requests expose independent **Lint**, **Formatting**, **Typing**, and
36
+ **Tests (Python 3.14)** checks in GitHub. A failed check does not prevent the
37
+ others from running. Ruff lint findings also appear as file-and-line annotations.
38
+ The test job uploads JUnit XML and XML/HTML coverage reports as the
39
+ `test-reports-python-3.14` artifact, including when tests fail.
40
+ The workflow run summary shows the test outcome, counts, duration, line coverage,
41
+ and configured coverage threshold. Missing reports are flagged explicitly.
42
+
35
43
  Initial `ty` support is configured as an opt-in check and does not replace `mypy`:
36
44
 
37
45
  ```bash
@@ -78,12 +86,12 @@ Examples:
78
86
  ```python
79
87
  @dataclass
80
88
  class ListApisRequest(ApiRequest[GetAllApisResponse]):
81
- response_type = GetAllApisResponse
89
+ response_type = GetAllApisResponse
82
90
 
83
91
 
84
92
  @dataclass
85
93
  class SetPortsRequest(ApiRequest[ApiResponse[bytes]]):
86
- response_type = BytesResponse
94
+ response_type = BytesResponse
87
95
  ```
88
96
 
89
97
  Handler methods may unwrap `.data` when they intentionally preserve a bytes-returning boundary.
@@ -42,7 +42,7 @@ class CliRouter:
42
42
 
43
43
  async def run(
44
44
  self, ctx: CliContext, io: CliIO, start_node_id: str = "main"
45
- ) -> None:
45
+ ) -> bool:
46
46
  current = start_node_id
47
47
  while True:
48
48
  node = self.nodes[current]
@@ -57,7 +57,7 @@ class CliRouter:
57
57
 
58
58
  if selection == "e":
59
59
  io.write("Exiting.")
60
- raise SystemExit(0)
60
+ return True
61
61
  if selection == "b":
62
62
  if node.parent_id is None:
63
63
  continue
@@ -92,9 +92,16 @@ class CliRouter:
92
92
  io.write(f"Unknown command: {matched.command_id}")
93
93
  continue
94
94
 
95
- result = await command.run(ctx, io)
95
+ try:
96
+ result = await command.run(ctx, io)
97
+ except SystemExit as exc:
98
+ if exc.code == 0:
99
+ return True
100
+ raise
96
101
  payload = result.payload
97
102
  if result.status == "ok" and isinstance(payload, dict):
103
+ if payload.get("exit") is True:
104
+ return True
98
105
  requested_node = payload.get("next_node_id")
99
106
  if isinstance(requested_node, str):
100
107
  if requested_node in self.nodes:
@@ -144,10 +144,14 @@ def main(*, debug: bool | None = None) -> None:
144
144
  terminal_io = TerminalIO()
145
145
  while True:
146
146
  try:
147
- asyncio.run(runtime.router.run(runtime, terminal_io, start_node_id="main"))
147
+ should_exit = asyncio.run(
148
+ runtime.router.run(runtime, terminal_io, start_node_id="main")
149
+ )
148
150
  except KeyboardInterrupt:
149
151
  print("\nInterrupted. Use 'e' to exit.") # noqa: T201
150
152
  continue
153
+ if should_exit:
154
+ return
151
155
 
152
156
 
153
157
  __all__ = [
@@ -19,7 +19,9 @@ from axis.cli.packs.devices import (
19
19
  )
20
20
  from axis.device import AxisDevice
21
21
  from axis.errors import RequestError
22
- from axis.models.configuration import Configuration
22
+ from axis.models.configuration import Configuration, WebProtocol
23
+ from axis.rtsp import RTSPClient, Signal
24
+ from axis.websocket import WebSocketClient
23
25
 
24
26
  if TYPE_CHECKING:
25
27
  from axis.cli.core.context import CliContext
@@ -54,6 +56,31 @@ def _debug_dump(label: str, payload: object) -> None:
54
56
  print(f"[debug] {label}:\n{pformat(payload)}") # noqa: T201
55
57
 
56
58
 
59
+ def _event_stream_details(device: AxisDevice) -> tuple[str, str]:
60
+ if device.stream.use_websocket:
61
+ if device.config.web_proto == WebProtocol.HTTPS:
62
+ verification = "enabled" if device.config.verify_ssl else "disabled"
63
+ return (
64
+ "WebSocket over WSS",
65
+ f"TLS encrypted; certificate verification {verification}; session-token authentication.",
66
+ )
67
+ return "WebSocket over WS", "Unencrypted; session-token authentication."
68
+
69
+ return "RTSP", "Unencrypted; authentication is negotiated when connected."
70
+
71
+
72
+ def _event_authentication_details(device: AxisDevice) -> str:
73
+ stream = device.stream.stream
74
+ if isinstance(stream, WebSocketClient):
75
+ return stream.authentication or "Negotiating"
76
+ if isinstance(stream, RTSPClient):
77
+ if stream.session.digest:
78
+ return "Digest"
79
+ if stream.session.basic:
80
+ return "Basic"
81
+ return "Negotiating"
82
+
83
+
57
84
  def _render_events_node(ctx: CliContext, io: CliIO) -> None:
58
85
  if ctx.selected_serial is None or ctx.selected_device is None:
59
86
  io.write("\nNo selected device.")
@@ -176,6 +203,7 @@ async def _live_listen_async(
176
203
  host=credentials["host"],
177
204
  username=credentials["username"],
178
205
  password=credentials["password"],
206
+ websocket_enabled=True,
179
207
  )
180
208
  device = AxisDevice(config)
181
209
 
@@ -186,13 +214,28 @@ async def _live_listen_async(
186
214
  callback=_on_event,
187
215
  id_filter=topic_filter,
188
216
  )
217
+
218
+ def _on_connection_status(signal: Signal) -> None:
219
+ if signal == Signal.PLAYING:
220
+ print( # noqa: T201
221
+ f"Authentication: {_event_authentication_details(device)}"
222
+ )
223
+
224
+ device.stream.connection_status_callback.append(_on_connection_status)
189
225
  stop_event = asyncio.Event()
190
226
  try:
227
+ await device.vapix.api_discovery.update()
191
228
  device.enable_events()
229
+ protocol, security = _event_stream_details(device)
230
+ print(f"Event transport: {protocol}") # noqa: T201
231
+ print(f"Security: {security}") # noqa: T201
232
+ device.stream.start()
192
233
  await stop_event.wait()
193
234
  except asyncio.CancelledError:
194
235
  pass
195
236
  finally:
237
+ device.stream.connection_status_callback.remove(_on_connection_status)
238
+ device.stream.stop()
196
239
  unsubscribe()
197
240
 
198
241
  try:
@@ -0,0 +1,21 @@
1
+ """Firmware management API.
2
+
3
+ Listed in API discovery from AXIS OS 7.40, it replaces restart.cgi for
4
+ rebooting. Requires an administrator account.
5
+ """
6
+
7
+ from ..models.api import ApiItem
8
+ from ..models.api_discovery import ApiId
9
+ from ..models.firmware_management import API_VERSION, RebootRequest
10
+ from .api_handler import ApiHandler
11
+
12
+
13
+ class FirmwareManagementHandler(ApiHandler[ApiItem]):
14
+ """Firmware management of a device."""
15
+
16
+ api_id = ApiId.FIRMWARE_MANAGER
17
+ default_api_version = API_VERSION
18
+
19
+ async def reboot(self) -> None:
20
+ """Reboot the device."""
21
+ await self.vapix.api_request(RebootRequest())
@@ -0,0 +1,17 @@
1
+ """Axis Vapix restart API.
2
+
3
+ Restart the device. Requires an administrator account. Superseded by the
4
+ firmware management API, use this on devices that don't list it.
5
+ """
6
+
7
+ from ..models.api import ApiItem
8
+ from ..models.restart_cgi import RestartRequest
9
+ from .api_handler import ApiHandler
10
+
11
+
12
+ class RestartHandler(ApiHandler[ApiItem]):
13
+ """Restart the device through restart.cgi."""
14
+
15
+ async def restart(self) -> None:
16
+ """Restart the device."""
17
+ await self.vapix.api_request(RestartRequest())
@@ -10,7 +10,6 @@ import aiohttp
10
10
  from ..errors import RequestError, raise_error
11
11
  from ..models.configuration import AuthScheme
12
12
  from ..models.pwdgrp_cgi import SecondaryGroup
13
- from .aiohttp_digest import AiohttpDigestAuth
14
13
  from .api_discovery import ApiDiscoveryHandler
15
14
  from .api_handler import ApiHandler, HandlerGroup
16
15
  from .applications import ApplicationsHandler
@@ -21,6 +20,7 @@ from .applications.object_analytics import ObjectAnalyticsHandler
21
20
  from .applications.vmd4 import Vmd4Handler
22
21
  from .basic_device_info import BasicDeviceInfoHandler
23
22
  from .event_instances import EventInstanceHandler
23
+ from .firmware_management import FirmwareManagementHandler
24
24
  from .light_control import LightHandler
25
25
  from .mqtt import MqttClientHandler
26
26
  from .parameters.param_cgi import Params
@@ -29,10 +29,12 @@ from .port_cgi import Ports
29
29
  from .port_management import IoPortManagement
30
30
  from .ptz import PtzControl
31
31
  from .pwdgrp_cgi import Users
32
+ from .restart_cgi import RestartHandler
32
33
  from .stream_profiles import StreamProfilesHandler
33
34
  from .temperature_control import TemperatureControlHandler
34
35
  from .user_groups import UserGroups
35
36
  from .view_areas import ViewAreaHandler
37
+ from .virtual_input import VirtualInputHandler
36
38
 
37
39
  if TYPE_CHECKING:
38
40
  from collections.abc import Awaitable
@@ -68,16 +70,15 @@ class InterfaceState:
68
70
  class Vapix:
69
71
  """Vapix parameter request."""
70
72
 
71
- auth: aiohttp.BasicAuth | None
73
+ auth: str | None
72
74
 
73
75
  def __init__(self, device: AxisDevice) -> None:
74
76
  """Store local reference to device config."""
75
77
  self.device = device
76
78
  self._aiohttp_digest_middleware: Any | None = None
77
- self._aiohttp_digest_auth = AiohttpDigestAuth(device)
78
79
 
79
80
  if device.config.auth_scheme == AuthScheme.BASIC:
80
- self.auth = self._basic_auth()
81
+ self.auth = self._basic_auth_header()
81
82
  else:
82
83
  self.auth = None
83
84
  self._aiohttp_digest_middleware = self._aiohttp_digest_middleware_obj()
@@ -95,6 +96,7 @@ class Vapix:
95
96
  self.params: Params = Params(self)
96
97
 
97
98
  self.basic_device_info = BasicDeviceInfoHandler(self)
99
+ self.firmware_management = FirmwareManagementHandler(self)
98
100
  self.io_port_management = IoPortManagement(self)
99
101
  self.light_control = LightHandler(self)
100
102
  self.mqtt = MqttClientHandler(self)
@@ -105,6 +107,8 @@ class Vapix:
105
107
 
106
108
  self.port_cgi = Ports(self)
107
109
  self.ptz = PtzControl(self)
110
+ self.restart_cgi = RestartHandler(self)
111
+ self.virtual_input = VirtualInputHandler(self)
108
112
 
109
113
  self.applications: ApplicationsHandler = ApplicationsHandler(self)
110
114
  self.fence_guard = FenceGuardHandler(self)
@@ -124,6 +128,15 @@ class Vapix:
124
128
  return self.params.property_handler["0"].firmware_version
125
129
  return ""
126
130
 
131
+ @property
132
+ def product_full_name(self) -> str:
133
+ """Product full name of device."""
134
+ if self.basic_device_info.initialized:
135
+ return self.basic_device_info["0"].product_full_name
136
+ if self.params.brand_handler.initialized:
137
+ return self.params.brand_handler["0"].product_full_name
138
+ return ""
139
+
127
140
  @property
128
141
  def product_number(self) -> str:
129
142
  """Product number of device."""
@@ -178,6 +191,8 @@ class Vapix:
178
191
  """Initialize Vapix functions."""
179
192
  await self.initialize_api_discovery()
180
193
  await self.initialize_param_cgi(preload_data=False)
194
+ if "Companion" in self.product_full_name:
195
+ self.device.config.is_companion = True
181
196
  await self.initialize_applications()
182
197
 
183
198
  async def initialize_api_discovery(self) -> None:
@@ -340,7 +355,7 @@ class Vapix:
340
355
  ) -> ApiResponseT:
341
356
  """Make a request and decode response based on the request contract."""
342
357
  params = api_request.params or {}
343
- if self.device.config.is_companion:
358
+ if self.device.config.is_companion and api_request.supports_companion_param:
344
359
  params["Axis-Orig-Sw"] = "true"
345
360
  bytes_data = await self.request(
346
361
  method=api_request.method,
@@ -419,7 +434,7 @@ class Vapix:
419
434
 
420
435
  if status_code >= 400:
421
436
  if self._should_retry_with_basic(response_headers, allow_auto_basic_retry):
422
- self.auth = self._basic_auth()
437
+ self.auth = self._basic_auth_header()
423
438
  self._aiohttp_digest_middleware = None
424
439
  return await self._request(
425
440
  method=method,
@@ -458,16 +473,16 @@ class Vapix:
458
473
  )
459
474
  session = self.device.config.session
460
475
 
461
- if not self.auth and self.device.config.auth_scheme != AuthScheme.BASIC:
462
- return await self._aiohttp_digest_auth.perform_request(
463
- session, method, url, request_data, headers, params
464
- )
476
+ request_headers = dict(headers or {})
477
+ if self.auth is not None and not any(
478
+ header.lower() == "authorization" for header in request_headers
479
+ ):
480
+ request_headers["Authorization"] = self.auth
465
481
 
466
482
  request_kwargs: dict[str, Any] = {
467
483
  "data": request_data,
468
- "headers": headers,
484
+ "headers": request_headers,
469
485
  "params": params,
470
- "auth": self.auth,
471
486
  "timeout": TIME_OUT,
472
487
  }
473
488
  if self._aiohttp_digest_middleware is not None:
@@ -492,9 +507,9 @@ class Vapix:
492
507
  password=self.device.config.password,
493
508
  )
494
509
 
495
- def _basic_auth(self) -> aiohttp.BasicAuth:
496
- """Create aiohttp basic auth object."""
497
- return aiohttp.BasicAuth(
510
+ def _basic_auth_header(self) -> str:
511
+ """Create an HTTP Basic Authorization header value."""
512
+ return aiohttp.encode_basic_auth(
498
513
  self.device.config.username, self.device.config.password
499
514
  )
500
515
 
@@ -0,0 +1,38 @@
1
+ """Virtual input API.
2
+
3
+ Activate and deactivate virtual input ports, which can be used as start event
4
+ or condition in the device's action rules. Virtual inputs are not listed in API
5
+ discovery, support is announced by Properties.VirtualInput.VirtualInput. The
6
+ available ports and their state are only reported through the VirtualInput
7
+ event, which is left to the caller to combine with this API.
8
+
9
+ https://developer.axis.com/vapix/network-video/input-and-outputs/#virtual-input-api
10
+ """
11
+
12
+ from ..models.api import ApiItem
13
+ from ..models.virtual_input import (
14
+ ActivateVirtualInputRequest,
15
+ DeactivateVirtualInputRequest,
16
+ )
17
+ from .api_handler import ApiHandler
18
+
19
+
20
+ class VirtualInputHandler(ApiHandler[ApiItem]):
21
+ """Virtual input ports of a device."""
22
+
23
+ @property
24
+ def listed_in_parameters(self) -> bool:
25
+ """Is virtual input supported."""
26
+ if prop := self.vapix.params.property_handler.get("0"):
27
+ return prop.virtual_input
28
+ return False
29
+
30
+ async def activate(self, port: int) -> bool:
31
+ """Activate virtual input port, return True if the state changed."""
32
+ response = await self.vapix.api_request(ActivateVirtualInputRequest(port))
33
+ return response.data
34
+
35
+ async def deactivate(self, port: int) -> bool:
36
+ """Deactivate virtual input port, return True if the state changed."""
37
+ response = await self.vapix.api_request(DeactivateVirtualInputRequest(port))
38
+ return response.data
@@ -68,6 +68,8 @@ class ApiRequest[ResponseT = ApiResponse[bytes]]:
68
68
  path: str = field(init=False)
69
69
  content_type: str = field(init=False)
70
70
  response_type: ClassVar[type[ApiResponseDecoder[ResponseT]]]
71
+ # Some APIs reject the Axis-Orig-Sw parameter used by Companion devices
72
+ supports_companion_param: ClassVar[bool] = True
71
73
 
72
74
  @property
73
75
  def content(self) -> bytes | None:
@@ -229,6 +229,7 @@ class ListApisRequest(ApiRequest[GetAllApisResponse]):
229
229
  path = "/axis-cgi/apidiscovery.cgi"
230
230
  content_type = "application/json"
231
231
  response_type = GetAllApisResponse
232
+ supports_companion_param = False
232
233
  error_codes = error_codes
233
234
 
234
235
  api_version: str = API_VERSION
@@ -276,6 +277,7 @@ class GetSupportedVersionsRequest(ApiRequest[GetSupportedVersionsResponse]):
276
277
  path = "/axis-cgi/apidiscovery.cgi"
277
278
  content_type = "application/json"
278
279
  response_type = GetSupportedVersionsResponse
280
+ supports_companion_param = False
279
281
  error_codes = error_codes
280
282
 
281
283
  context: str = CONTEXT
@@ -151,6 +151,7 @@ class GetAllPropertiesRequest(ApiRequest[GetAllPropertiesResponse]):
151
151
  path = "/axis-cgi/basicdeviceinfo.cgi"
152
152
  content_type = "application/json"
153
153
  response_type = GetAllPropertiesResponse
154
+ supports_companion_param = False
154
155
  error_codes = error_codes
155
156
 
156
157
  api_version: str = API_VERSION
@@ -198,6 +199,7 @@ class GetSupportedVersionsRequest(ApiRequest[GetSupportedVersionsResponse]):
198
199
  path = "/axis-cgi/basicdeviceinfo.cgi"
199
200
  content_type = "application/json"
200
201
  response_type = GetSupportedVersionsResponse
202
+ supports_companion_param = False
201
203
  error_codes = error_codes
202
204
 
203
205
  context: str = CONTEXT
@@ -0,0 +1,64 @@
1
+ """Firmware management API.
2
+
3
+ Manage the firmware of the device. Only rebooting is supported here.
4
+
5
+ https://developer.axis.com/vapix/network-video/firmware-management-api/
6
+ """
7
+
8
+ from dataclasses import dataclass
9
+ from typing import Any, Self
10
+
11
+ import orjson
12
+
13
+ from ..errors import ResponseError
14
+ from .api import CONTEXT, ApiRequest, ApiResponse
15
+
16
+ API_VERSION = "1.0"
17
+
18
+
19
+ @dataclass
20
+ class RebootResponse(ApiResponse[None]):
21
+ """Response from rebooting the device."""
22
+
23
+ data: None
24
+
25
+ @classmethod
26
+ def decode(cls, bytes_data: bytes) -> Self:
27
+ """Decode response, raise ResponseError if the device reports an error."""
28
+ msg = "Invalid firmware management response"
29
+ try:
30
+ data: Any = orjson.loads(bytes_data)
31
+ except orjson.JSONDecodeError as err:
32
+ raise ResponseError(msg) from err
33
+ if not isinstance(data, dict):
34
+ raise ResponseError(msg)
35
+ if isinstance(error := data.get("error"), dict):
36
+ msg = (
37
+ f"Firmware management error {error.get('code')}: {error.get('message')}"
38
+ )
39
+ raise ResponseError(msg)
40
+ return cls(data=None)
41
+
42
+
43
+ @dataclass
44
+ class RebootRequest(ApiRequest[RebootResponse]):
45
+ """Request object for rebooting the device."""
46
+
47
+ method = "post"
48
+ path = "/axis-cgi/firmwaremanagement.cgi"
49
+ content_type = "application/json"
50
+ response_type = RebootResponse
51
+
52
+ api_version: str = API_VERSION
53
+ context: str = CONTEXT
54
+
55
+ @property
56
+ def content(self) -> bytes:
57
+ """Initialize request data."""
58
+ return orjson.dumps(
59
+ {
60
+ "apiVersion": self.api_version,
61
+ "context": self.context,
62
+ "method": "reboot",
63
+ }
64
+ )
@@ -103,6 +103,12 @@ class PropertySystemParamT(TypedDict):
103
103
  Soc: str
104
104
 
105
105
 
106
+ class PropertyVirtualInputParamT(TypedDict):
107
+ """Represent a virtual input object."""
108
+
109
+ VirtualInput: bool
110
+
111
+
106
112
  class PropertyParamT(TypedDict):
107
113
  """Represent a property object."""
108
114
 
@@ -113,6 +119,7 @@ class PropertyParamT(TypedDict):
113
119
  LightControl: NotRequired[PropertyLightControlParamT]
114
120
  PTZ: NotRequired[PropertyPtzParamT]
115
121
  System: PropertySystemParamT
122
+ VirtualInput: NotRequired[PropertyVirtualInputParamT]
116
123
 
117
124
 
118
125
  @dataclass(frozen=True)
@@ -174,6 +181,9 @@ class PropertyParam(ParamItem):
174
181
  system_serial_number: str
175
182
  """Device serial number."""
176
183
 
184
+ virtual_input: bool
185
+ """Support virtual input ports."""
186
+
177
187
  @classmethod
178
188
  def decode(cls, data: PropertyParamT) -> Self:
179
189
  """Decode dictionary to class object."""
@@ -200,4 +210,5 @@ class PropertyParam(ParamItem):
200
210
  ptz=data.get("PTZ", {}).get("PTZ", False),
201
211
  digital_ptz=data.get("PTZ", {}).get("DigitalPTZ", False),
202
212
  system_serial_number=data["System"]["SerialNumber"],
213
+ virtual_input=data.get("VirtualInput", {}).get("VirtualInput", False),
203
214
  )
@@ -0,0 +1,15 @@
1
+ """Restart API."""
2
+
3
+ from dataclasses import dataclass
4
+
5
+ from .api import ApiRequest, ApiResponse, BytesResponse
6
+
7
+
8
+ @dataclass
9
+ class RestartRequest(ApiRequest[ApiResponse[bytes]]):
10
+ """Request object for restarting the device."""
11
+
12
+ method = "get"
13
+ path = "/axis-cgi/restart.cgi"
14
+ content_type = "text/plain"
15
+ response_type = BytesResponse