axis 71__tar.gz → 73__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 (138) hide show
  1. {axis-71 → axis-73}/PKG-INFO +63 -8
  2. axis-73/README.md +105 -0
  3. {axis-71 → axis-73}/axis/__main__.py +3 -3
  4. axis-73/axis/cli/__init__.py +4 -0
  5. axis-73/axis/cli/core/__init__.py +1 -0
  6. axis-73/axis/cli/core/context.py +25 -0
  7. axis-73/axis/cli/core/contracts.py +32 -0
  8. axis-73/axis/cli/core/gateway.py +52 -0
  9. axis-73/axis/cli/core/io.py +25 -0
  10. axis-73/axis/cli/core/registry.py +26 -0
  11. axis-73/axis/cli/core/router.py +114 -0
  12. axis-73/axis/cli/main.py +205 -0
  13. axis-73/axis/cli/packs/__init__.py +1 -0
  14. axis-73/axis/cli/packs/accounts.py +292 -0
  15. axis-73/axis/cli/packs/api.py +429 -0
  16. axis-73/axis/cli/packs/devices.py +999 -0
  17. axis-73/axis/cli/packs/events.py +254 -0
  18. axis-73/axis/cli/packs/navigation.py +290 -0
  19. {axis-71 → axis-73}/axis/interfaces/aiohttp_digest.py +0 -2
  20. {axis-71 → axis-73}/axis/interfaces/api_discovery.py +3 -6
  21. {axis-71 → axis-73}/axis/interfaces/api_handler.py +12 -6
  22. {axis-71 → axis-73}/axis/interfaces/applications/application_handler.py +2 -2
  23. {axis-71 → axis-73}/axis/interfaces/applications/applications.py +1 -3
  24. {axis-71 → axis-73}/axis/interfaces/applications/fence_guard.py +1 -3
  25. {axis-71 → axis-73}/axis/interfaces/applications/loitering_guard.py +1 -3
  26. {axis-71 → axis-73}/axis/interfaces/applications/motion_guard.py +1 -3
  27. {axis-71 → axis-73}/axis/interfaces/applications/object_analytics.py +1 -3
  28. {axis-71 → axis-73}/axis/interfaces/applications/vmd4.py +1 -3
  29. {axis-71 → axis-73}/axis/interfaces/basic_device_info.py +2 -6
  30. axis-73/axis/interfaces/event_instances.py +43 -0
  31. {axis-71 → axis-73}/axis/interfaces/event_manager.py +13 -4
  32. {axis-71 → axis-73}/axis/interfaces/light_control.py +28 -37
  33. {axis-71 → axis-73}/axis/interfaces/mqtt.py +2 -37
  34. {axis-71 → axis-73}/axis/interfaces/parameters/param_cgi.py +4 -15
  35. {axis-71 → axis-73}/axis/interfaces/parameters/param_handler.py +2 -2
  36. {axis-71 → axis-73}/axis/interfaces/pir_sensor_configuration.py +3 -11
  37. {axis-71 → axis-73}/axis/interfaces/port_management.py +4 -6
  38. {axis-71 → axis-73}/axis/interfaces/ptz.py +6 -3
  39. {axis-71 → axis-73}/axis/interfaces/pwdgrp_cgi.py +2 -3
  40. {axis-71 → axis-73}/axis/interfaces/stream_profiles.py +2 -6
  41. axis-73/axis/interfaces/temperature_control.py +93 -0
  42. {axis-71 → axis-73}/axis/interfaces/user_groups.py +3 -3
  43. {axis-71 → axis-73}/axis/interfaces/vapix.py +102 -60
  44. {axis-71 → axis-73}/axis/interfaces/view_areas.py +5 -14
  45. {axis-71 → axis-73}/axis/models/api.py +20 -16
  46. {axis-71 → axis-73}/axis/models/api_discovery.py +5 -4
  47. {axis-71 → axis-73}/axis/models/applications/application.py +10 -9
  48. {axis-71 → axis-73}/axis/models/applications/fence_guard.py +22 -21
  49. {axis-71 → axis-73}/axis/models/applications/loitering_guard.py +22 -21
  50. {axis-71 → axis-73}/axis/models/applications/motion_guard.py +22 -21
  51. {axis-71 → axis-73}/axis/models/applications/object_analytics.py +22 -21
  52. {axis-71 → axis-73}/axis/models/applications/vmd4.py +22 -21
  53. {axis-71 → axis-73}/axis/models/basic_device_info.py +5 -4
  54. {axis-71 → axis-73}/axis/models/configuration.py +5 -3
  55. {axis-71 → axis-73}/axis/models/event.py +13 -43
  56. axis-73/axis/models/event_instance.py +420 -0
  57. {axis-71 → axis-73}/axis/models/light_control.py +175 -151
  58. {axis-71 → axis-73}/axis/models/mqtt.py +84 -56
  59. {axis-71 → axis-73}/axis/models/parameters/brand.py +1 -3
  60. {axis-71 → axis-73}/axis/models/parameters/param_cgi.py +28 -4
  61. {axis-71 → axis-73}/axis/models/parameters/properties.py +1 -3
  62. {axis-71 → axis-73}/axis/models/parameters/ptz.py +9 -11
  63. {axis-71 → axis-73}/axis/models/parameters/stream_profile.py +1 -3
  64. {axis-71 → axis-73}/axis/models/pir_sensor_configuration.py +31 -28
  65. {axis-71 → axis-73}/axis/models/port_cgi.py +3 -2
  66. {axis-71 → axis-73}/axis/models/port_management.py +48 -44
  67. {axis-71 → axis-73}/axis/models/ptz_cgi.py +9 -5
  68. {axis-71 → axis-73}/axis/models/pwdgrp_cgi.py +23 -21
  69. {axis-71 → axis-73}/axis/models/stream_profile.py +26 -25
  70. axis-73/axis/models/temperature_control.py +216 -0
  71. {axis-71 → axis-73}/axis/models/user_group.py +10 -9
  72. {axis-71 → axis-73}/axis/models/view_area.py +30 -27
  73. {axis-71 → axis-73}/axis/stream_transport.py +0 -2
  74. {axis-71 → axis-73}/axis/websocket.py +0 -2
  75. {axis-71 → axis-73}/axis.egg-info/PKG-INFO +63 -8
  76. {axis-71 → axis-73}/axis.egg-info/SOURCES.txt +20 -0
  77. {axis-71 → axis-73}/axis.egg-info/entry_points.txt +1 -0
  78. {axis-71 → axis-73}/axis.egg-info/requires.txt +11 -7
  79. {axis-71 → axis-73}/pyproject.toml +15 -11
  80. {axis-71 → axis-73}/tests/test_api_discovery.py +11 -9
  81. {axis-71 → axis-73}/tests/test_api_handler.py +24 -0
  82. {axis-71 → axis-73}/tests/test_basic_device_info.py +60 -24
  83. axis-73/tests/test_conftest.py +448 -0
  84. {axis-71 → axis-73}/tests/test_event.py +67 -20
  85. axis-73/tests/test_event_instances.py +519 -0
  86. axis-73/tests/test_event_instances_protocol_parity.py +115 -0
  87. {axis-71 → axis-73}/tests/test_event_stream.py +56 -18
  88. {axis-71 → axis-73}/tests/test_http_client_compat.py +3 -7
  89. {axis-71 → axis-73}/tests/test_light_control.py +212 -72
  90. {axis-71 → axis-73}/tests/test_mqtt.py +80 -20
  91. {axis-71 → axis-73}/tests/test_pir_sensor_configuration.py +46 -39
  92. {axis-71 → axis-73}/tests/test_port_cgi.py +39 -5
  93. {axis-71 → axis-73}/tests/test_port_management.py +92 -87
  94. {axis-71 → axis-73}/tests/test_ptz.py +184 -84
  95. {axis-71 → axis-73}/tests/test_pwdgrp_cgi.py +41 -17
  96. axis-73/tests/test_request_response_contracts.py +243 -0
  97. {axis-71 → axis-73}/tests/test_stream_profiles.py +49 -34
  98. axis-73/tests/test_temperature_control.py +249 -0
  99. {axis-71 → axis-73}/tests/test_vapix.py +266 -39
  100. {axis-71 → axis-73}/tests/test_view_areas.py +62 -32
  101. axis-71/README.md +0 -54
  102. axis-71/axis/interfaces/event_instances.py +0 -23
  103. axis-71/axis/models/event_instance.py +0 -174
  104. axis-71/tests/test_conftest.py +0 -164
  105. axis-71/tests/test_event_instances.py +0 -271
  106. {axis-71 → axis-73}/LICENSE +0 -0
  107. {axis-71 → axis-73}/axis/__init__.py +0 -0
  108. {axis-71 → axis-73}/axis/device.py +0 -0
  109. {axis-71 → axis-73}/axis/errors.py +0 -0
  110. {axis-71 → axis-73}/axis/interfaces/__init__.py +0 -0
  111. {axis-71 → axis-73}/axis/interfaces/applications/__init__.py +0 -0
  112. {axis-71 → axis-73}/axis/interfaces/parameters/__init__.py +0 -0
  113. {axis-71 → axis-73}/axis/interfaces/parameters/brand.py +0 -0
  114. {axis-71 → axis-73}/axis/interfaces/parameters/image.py +0 -0
  115. {axis-71 → axis-73}/axis/interfaces/parameters/io_port.py +0 -0
  116. {axis-71 → axis-73}/axis/interfaces/parameters/properties.py +0 -0
  117. {axis-71 → axis-73}/axis/interfaces/parameters/ptz.py +0 -0
  118. {axis-71 → axis-73}/axis/interfaces/parameters/stream_profile.py +0 -0
  119. {axis-71 → axis-73}/axis/interfaces/port_cgi.py +0 -0
  120. {axis-71 → axis-73}/axis/models/__init__.py +0 -0
  121. {axis-71 → axis-73}/axis/models/applications/__init__.py +0 -0
  122. {axis-71 → axis-73}/axis/models/parameters/__init__.py +0 -0
  123. {axis-71 → axis-73}/axis/models/parameters/image.py +0 -0
  124. {axis-71 → axis-73}/axis/models/parameters/io_port.py +0 -0
  125. {axis-71 → axis-73}/axis/py.typed +0 -0
  126. {axis-71 → axis-73}/axis/rtsp.py +0 -0
  127. {axis-71 → axis-73}/axis/stream_manager.py +0 -0
  128. {axis-71 → axis-73}/axis.egg-info/dependency_links.txt +0 -0
  129. {axis-71 → axis-73}/axis.egg-info/top_level.txt +0 -0
  130. {axis-71 → axis-73}/setup.cfg +0 -0
  131. {axis-71 → axis-73}/tests/test_auth_scheme.py +0 -0
  132. {axis-71 → axis-73}/tests/test_configuration.py +0 -0
  133. {axis-71 → axis-73}/tests/test_device.py +0 -0
  134. {axis-71 → axis-73}/tests/test_main_http_client.py +0 -0
  135. {axis-71 → axis-73}/tests/test_rtsp.py +0 -0
  136. {axis-71 → axis-73}/tests/test_stream_manager.py +0 -0
  137. {axis-71 → axis-73}/tests/test_user_groups.py +0 -0
  138. {axis-71 → axis-73}/tests/test_websocket.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: axis
3
- Version: 71
3
+ Version: 73
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
@@ -21,20 +21,24 @@ Requires-Dist: aiohttp>=3.12
21
21
  Requires-Dist: faust-cchardet>=2.1.18
22
22
  Requires-Dist: orjson>3.9
23
23
  Requires-Dist: packaging>23
24
+ Requires-Dist: tomli>=2.0
25
+ Requires-Dist: tomli-w>=1.0
24
26
  Requires-Dist: xmltodict>=0.13.0
27
+ Requires-Dist: zeroconf==0.150.0
25
28
  Provides-Extra: requirements
26
- Requires-Dist: aiohttp==3.13.5; extra == "requirements"
29
+ Requires-Dist: aiohttp==3.14.1; extra == "requirements"
27
30
  Requires-Dist: orjson==3.11.9; extra == "requirements"
28
31
  Requires-Dist: packaging==26.2; extra == "requirements"
29
32
  Requires-Dist: xmltodict==1.0.4; extra == "requirements"
33
+ Requires-Dist: zeroconf==0.150.0; extra == "requirements"
30
34
  Provides-Extra: requirements-test
31
- Requires-Dist: mypy==2.0.0; extra == "requirements-test"
32
- Requires-Dist: pytest==9.0.3; extra == "requirements-test"
33
- Requires-Dist: pytest-aiohttp==1.1.0; extra == "requirements-test"
34
- Requires-Dist: pytest-asyncio==1.3.0; extra == "requirements-test"
35
+ Requires-Dist: mypy==2.1.0; extra == "requirements-test"
36
+ Requires-Dist: pytest==9.1.1; extra == "requirements-test"
37
+ Requires-Dist: pytest-aiohttp==1.1.1; extra == "requirements-test"
38
+ Requires-Dist: pytest-asyncio==1.4.0; extra == "requirements-test"
35
39
  Requires-Dist: pytest-cov==7.1.0; extra == "requirements-test"
36
- Requires-Dist: ruff==0.15.12; extra == "requirements-test"
37
- Requires-Dist: types-xmltodict==v1.0.1.20260508; extra == "requirements-test"
40
+ Requires-Dist: ruff==0.15.20; extra == "requirements-test"
41
+ Requires-Dist: types-xmltodict==v1.0.1.20260518; extra == "requirements-test"
38
42
  Provides-Extra: requirements-dev
39
43
  Requires-Dist: pre-commit==4.6.0; extra == "requirements-dev"
40
44
  Dynamic: license-file
@@ -79,6 +83,22 @@ Initial `ty` support is configured as an opt-in check and does not replace `mypy
79
83
  uvx ty check
80
84
  ```
81
85
 
86
+ ## CLI navigation contract
87
+
88
+ The interactive CLI in `axis/cli/` follows a menu-first model.
89
+ Router runtime is the only interactive execution path.
90
+
91
+ - Canonical route graph: `main -> devices -> device_operations -> {api|events|accounts}`.
92
+ - `device_operations` routes to feature submenus using router navigation, not direct feature command execution.
93
+ - Feature nodes (`api`, `events`, `accounts`) own their local menu layout and selected-device context rendering.
94
+ - `b` always navigates to `parent_id`; `e` always exits.
95
+
96
+ To keep the CLI reusable boundary clear:
97
+
98
+ - CLI concerns (menu keys, prompts, render text, router state) stay in `axis/cli/`.
99
+ - `axis/interfaces/` and `axis/models/` must remain presentation-agnostic and reusable by non-CLI consumers.
100
+ - Domain layers return typed data/errors; CLI adapters map them to user-facing text and flow.
101
+
82
102
  ## Initialization architecture
83
103
 
84
104
  Vapix initialization is phase-based and driven by handler metadata:
@@ -89,7 +109,42 @@ Vapix initialization is phase-based and driven by handler metadata:
89
109
 
90
110
  Handlers declare phase membership through `handler_groups` and may customize phase eligibility through `should_initialize_in_group`.
91
111
 
112
+ ## Request and response typing
113
+
114
+ `Vapix.api_request()` is the single typed request entrypoint.
115
+
116
+ - Request models declare their decode contract with `ApiRequest[ResponseT]`.
117
+ - Every `ApiRequest` subclass must set `response_type` explicitly.
118
+ - Decoded/read requests use their concrete response model as `response_type`.
119
+ - Write-style requests use `BytesResponse` as `response_type`.
120
+
121
+ Examples:
122
+
123
+ ```python
124
+ @dataclass
125
+ class ListApisRequest(ApiRequest[GetAllApisResponse]):
126
+ response_type = GetAllApisResponse
127
+
128
+
129
+ @dataclass
130
+ class SetPortsRequest(ApiRequest[ApiResponse[bytes]]):
131
+ response_type = BytesResponse
132
+ ```
133
+
134
+ Handler methods may unwrap `.data` when they intentionally preserve a bytes-returning boundary.
135
+
92
136
  Example fallback policy:
93
137
 
94
138
  - `LightHandler` participates in both `API_DISCOVERY` and `PARAM_CGI_FALLBACK`.
95
139
  - In `PARAM_CGI_FALLBACK`, it initializes only when not listed in API discovery and listed in parameters.
140
+
141
+ ## Event Instance Model Notes
142
+
143
+ `EventInstance` keeps `name` as the raw device-provided `NiceName` value.
144
+
145
+ `EventInstance.source` and `EventInstance.data` are typed containers (`EventInstanceSource` and `EventInstanceData`) built from `SimpleItemInstance` payloads.
146
+
147
+ For compatibility with integrations that still need the historical raw payload shape, `EventInstance` also exposes:
148
+
149
+ - `raw_source`: returns `{}` or a dict or a list of dicts.
150
+ - `raw_data`: returns `{}` or a dict or a list of dicts.
axis-73/README.md ADDED
@@ -0,0 +1,105 @@
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
+ ## CLI navigation contract
42
+
43
+ The interactive CLI in `axis/cli/` follows a menu-first model.
44
+ Router runtime is the only interactive execution path.
45
+
46
+ - Canonical route graph: `main -> devices -> device_operations -> {api|events|accounts}`.
47
+ - `device_operations` routes to feature submenus using router navigation, not direct feature command execution.
48
+ - Feature nodes (`api`, `events`, `accounts`) own their local menu layout and selected-device context rendering.
49
+ - `b` always navigates to `parent_id`; `e` always exits.
50
+
51
+ To keep the CLI reusable boundary clear:
52
+
53
+ - CLI concerns (menu keys, prompts, render text, router state) stay in `axis/cli/`.
54
+ - `axis/interfaces/` and `axis/models/` must remain presentation-agnostic and reusable by non-CLI consumers.
55
+ - Domain layers return typed data/errors; CLI adapters map them to user-facing text and flow.
56
+
57
+ ## Initialization architecture
58
+
59
+ Vapix initialization is phase-based and driven by handler metadata:
60
+
61
+ - `API_DISCOVERY`: handlers initialized after API discovery.
62
+ - `PARAM_CGI_FALLBACK`: handlers that may initialize from parameter support when not listed in discovery.
63
+ - `APPLICATION`: handlers initialized after applications are loaded.
64
+
65
+ Handlers declare phase membership through `handler_groups` and may customize phase eligibility through `should_initialize_in_group`.
66
+
67
+ ## Request and response typing
68
+
69
+ `Vapix.api_request()` is the single typed request entrypoint.
70
+
71
+ - Request models declare their decode contract with `ApiRequest[ResponseT]`.
72
+ - Every `ApiRequest` subclass must set `response_type` explicitly.
73
+ - Decoded/read requests use their concrete response model as `response_type`.
74
+ - Write-style requests use `BytesResponse` as `response_type`.
75
+
76
+ Examples:
77
+
78
+ ```python
79
+ @dataclass
80
+ class ListApisRequest(ApiRequest[GetAllApisResponse]):
81
+ response_type = GetAllApisResponse
82
+
83
+
84
+ @dataclass
85
+ class SetPortsRequest(ApiRequest[ApiResponse[bytes]]):
86
+ response_type = BytesResponse
87
+ ```
88
+
89
+ Handler methods may unwrap `.data` when they intentionally preserve a bytes-returning boundary.
90
+
91
+ Example fallback policy:
92
+
93
+ - `LightHandler` participates in both `API_DISCOVERY` and `PARAM_CGI_FALLBACK`.
94
+ - In `PARAM_CGI_FALLBACK`, it initializes only when not listed in API discovery and listed in parameters.
95
+
96
+ ## Event Instance Model Notes
97
+
98
+ `EventInstance` keeps `name` as the raw device-provided `NiceName` value.
99
+
100
+ `EventInstance.source` and `EventInstance.data` are typed containers (`EventInstanceSource` and `EventInstanceData`) built from `SimpleItemInstance` payloads.
101
+
102
+ For compatibility with integrations that still need the historical raw payload shape, `EventInstance` also exposes:
103
+
104
+ - `raw_source`: returns `{}` or a dict or a list of dicts.
105
+ - `raw_data`: returns `{}` or a dict or a list of dicts.
@@ -17,7 +17,7 @@ if TYPE_CHECKING:
17
17
  LOGGER = logging.getLogger(__name__)
18
18
 
19
19
 
20
- def event_handler(event: "Event") -> None:
20
+ def event_handler(event: Event) -> None:
21
21
  """Receive and print events from RTSP stream."""
22
22
  LOGGER.info(event)
23
23
 
@@ -61,8 +61,8 @@ async def axis_device(
61
61
  "Connected to device at %s but not registered or user not admin.", host
62
62
  )
63
63
 
64
- except (TimeoutError, axis.RequestError):
65
- LOGGER.error("Error connecting to the Axis device at %s", host)
64
+ except (TimeoutError, axis.RequestError) as err:
65
+ LOGGER.error("Error connecting to the Axis device at %s: %s", host, err)
66
66
 
67
67
  except axis.AxisException:
68
68
  LOGGER.exception("Unknown Axis communication error occurred")
@@ -0,0 +1,4 @@
1
+ """Axis CLI package.
2
+
3
+ This package contains the command-line interface for Axis configuration validation and TOML export.
4
+ """
@@ -0,0 +1 @@
1
+ """Core CLI runtime primitives."""
@@ -0,0 +1,25 @@
1
+ # ruff: noqa: D100,D101,TC003
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+ from typing import TYPE_CHECKING, Any
8
+
9
+ if TYPE_CHECKING:
10
+ from axis.cli.core.gateway import DeviceGateway
11
+ from axis.cli.core.registry import CommandRegistry
12
+ from axis.cli.core.router import CliRouter
13
+
14
+ DeviceEntry = dict[str, Any]
15
+ DeviceStore = dict[str, DeviceEntry]
16
+
17
+
18
+ @dataclass
19
+ class CliContext:
20
+ config_path: Path
21
+ device_gateway: DeviceGateway
22
+ command_registry: CommandRegistry | None = None
23
+ router: CliRouter | None = None
24
+ selected_serial: str | None = None
25
+ selected_device: DeviceEntry | None = None
@@ -0,0 +1,32 @@
1
+ # ruff: noqa: D100,D101,D102
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import TYPE_CHECKING, Literal, Protocol
7
+
8
+ if TYPE_CHECKING:
9
+ from axis.cli.core.context import CliContext
10
+ from axis.cli.core.io import CliIO
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class CommandCapabilities:
15
+ requires_device: bool = False
16
+ destructive: bool = False
17
+ read_only_safe: bool = True
18
+
19
+
20
+ @dataclass
21
+ class CommandResult:
22
+ status: Literal["ok", "error", "cancelled"] = "ok"
23
+ message: str | None = None
24
+ payload: object | None = None
25
+
26
+
27
+ class Command(Protocol):
28
+ id: str
29
+ title: str
30
+ capabilities: CommandCapabilities
31
+
32
+ async def run(self, ctx: CliContext, io: CliIO) -> CommandResult: ...
@@ -0,0 +1,52 @@
1
+ # ruff: noqa: D100,D101,D102
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from aiohttp import ClientSession
8
+
9
+ from axis.device import AxisDevice
10
+ from axis.errors import RequestError
11
+ from axis.models.configuration import Configuration
12
+
13
+ if TYPE_CHECKING:
14
+ from collections.abc import Awaitable, Callable
15
+
16
+ from axis.cli.core.context import DeviceEntry
17
+
18
+
19
+ class DeviceGateway:
20
+ async def run[ReturnT](
21
+ self,
22
+ device_entry: DeviceEntry,
23
+ operation: Callable[[AxisDevice], Awaitable[ReturnT]],
24
+ ) -> ReturnT | None:
25
+ config_data = device_entry.get("config", {})
26
+ if not isinstance(config_data, dict):
27
+ print("Stored device config is incomplete. Please re-add the device.") # noqa: T201
28
+ return None
29
+
30
+ host = str(config_data.get("host", "")).strip()
31
+ username = str(config_data.get("username", "")).strip()
32
+ password = str(config_data.get("password", ""))
33
+ if not host or not username:
34
+ print("Stored device config is incomplete. Please re-add the device.") # noqa: T201
35
+ return None
36
+
37
+ try:
38
+ async with ClientSession() as session:
39
+ config = Configuration(
40
+ session=session,
41
+ host=host,
42
+ username=username,
43
+ password=password,
44
+ )
45
+ device = AxisDevice(config)
46
+ return await operation(device)
47
+ except RequestError as exc:
48
+ print(f"Device request failed: {exc}") # noqa: T201
49
+ except (ValueError, KeyError, OSError) as exc:
50
+ print(f"Failed to connect to selected device: {exc}") # noqa: T201
51
+
52
+ return None
@@ -0,0 +1,25 @@
1
+ # ruff: noqa: D100,D101,D102
2
+
3
+ from __future__ import annotations
4
+
5
+ import getpass
6
+ from typing import Protocol
7
+
8
+
9
+ class CliIO(Protocol):
10
+ def prompt(self, text: str) -> str: ...
11
+
12
+ def prompt_password(self, text: str) -> str: ...
13
+
14
+ def write(self, text: str) -> None: ...
15
+
16
+
17
+ class TerminalIO:
18
+ def prompt(self, text: str) -> str:
19
+ return input(text)
20
+
21
+ def prompt_password(self, text: str) -> str:
22
+ return getpass.getpass(text)
23
+
24
+ def write(self, text: str) -> None:
25
+ print(text) # noqa: T201
@@ -0,0 +1,26 @@
1
+ # ruff: noqa: D100,D101,D102
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import TYPE_CHECKING
7
+
8
+ if TYPE_CHECKING:
9
+ from axis.cli.core.contracts import Command
10
+
11
+
12
+ @dataclass
13
+ class CommandRegistry:
14
+ _commands: dict[str, Command] = field(default_factory=dict)
15
+
16
+ def register_command(self, command: Command) -> None:
17
+ if command.id in self._commands:
18
+ msg = f"Command '{command.id}' is already registered."
19
+ raise ValueError(msg)
20
+ self._commands[command.id] = command
21
+
22
+ def get_command(self, command_id: str) -> Command:
23
+ return self._commands[command_id]
24
+
25
+ def list_commands(self) -> list[Command]:
26
+ return list(self._commands.values())
@@ -0,0 +1,114 @@
1
+ # ruff: noqa: D100,D101,D102
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import TYPE_CHECKING
7
+
8
+ if TYPE_CHECKING:
9
+ from collections.abc import Callable
10
+
11
+ from axis.cli.core.context import CliContext
12
+ from axis.cli.core.io import CliIO
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class MenuItem:
17
+ key: str
18
+ label: str
19
+ action: str
20
+ command_id: str | None = None
21
+ next_node_id: str | None = None
22
+
23
+
24
+ @dataclass
25
+ class MenuNode:
26
+ id: str
27
+ title: str
28
+ items: list[MenuItem]
29
+ parent_id: str | None = None
30
+ render: Callable[[CliContext, CliIO], None] | None = None
31
+
32
+
33
+ @dataclass
34
+ class CliRouter:
35
+ nodes: dict[str, MenuNode] = field(default_factory=dict)
36
+
37
+ def register_node(self, node: MenuNode) -> None:
38
+ if node.id in self.nodes:
39
+ msg = f"Node '{node.id}' is already registered."
40
+ raise ValueError(msg)
41
+ self.nodes[node.id] = node
42
+
43
+ async def run(
44
+ self, ctx: CliContext, io: CliIO, start_node_id: str = "main"
45
+ ) -> None:
46
+ current = start_node_id
47
+ while True:
48
+ node = self.nodes[current]
49
+ if node.render is not None:
50
+ node.render(ctx, io)
51
+ io.write(f"\n{node.title}:")
52
+ for item in node.items:
53
+ io.write(f" {item.key}. {item.label}")
54
+ io.write(" b. Back")
55
+ io.write(" e. Exit")
56
+ selection = io.prompt("Select option: ").strip().lower()
57
+
58
+ if selection == "e":
59
+ io.write("Exiting.")
60
+ raise SystemExit(0)
61
+ if selection == "b":
62
+ if node.parent_id is None:
63
+ continue
64
+ current = node.parent_id
65
+ continue
66
+
67
+ matched = next((item for item in node.items if item.key == selection), None)
68
+ if matched is None:
69
+ io.write("Invalid option.")
70
+ continue
71
+
72
+ if matched.action == "navigate" and matched.next_node_id is not None:
73
+ current = matched.next_node_id
74
+ continue
75
+
76
+ if matched.action == "noop":
77
+ continue
78
+
79
+ if matched.action == "command":
80
+ if matched.command_id is None:
81
+ io.write("Command is not configured.")
82
+ continue
83
+
84
+ registry = ctx.command_registry
85
+ if registry is None:
86
+ io.write("Command registry is unavailable.")
87
+ continue
88
+
89
+ try:
90
+ command = registry.get_command(matched.command_id)
91
+ except KeyError:
92
+ io.write(f"Unknown command: {matched.command_id}")
93
+ continue
94
+
95
+ result = await command.run(ctx, io)
96
+ payload = result.payload
97
+ if result.status == "ok" and isinstance(payload, dict):
98
+ requested_node = payload.get("next_node_id")
99
+ if isinstance(requested_node, str):
100
+ if requested_node in self.nodes:
101
+ if result.message:
102
+ io.write(result.message)
103
+ current = requested_node
104
+ continue
105
+ io.write(f"Unknown node: {requested_node}")
106
+ if result.message:
107
+ io.write(result.message)
108
+ elif result.status == "error":
109
+ io.write("Command failed.")
110
+ elif result.status == "cancelled":
111
+ io.write("Command cancelled.")
112
+ continue
113
+
114
+ io.write("Unsupported action.")