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.
- {axis-71 → axis-73}/PKG-INFO +63 -8
- axis-73/README.md +105 -0
- {axis-71 → axis-73}/axis/__main__.py +3 -3
- axis-73/axis/cli/__init__.py +4 -0
- axis-73/axis/cli/core/__init__.py +1 -0
- axis-73/axis/cli/core/context.py +25 -0
- axis-73/axis/cli/core/contracts.py +32 -0
- axis-73/axis/cli/core/gateway.py +52 -0
- axis-73/axis/cli/core/io.py +25 -0
- axis-73/axis/cli/core/registry.py +26 -0
- axis-73/axis/cli/core/router.py +114 -0
- axis-73/axis/cli/main.py +205 -0
- axis-73/axis/cli/packs/__init__.py +1 -0
- axis-73/axis/cli/packs/accounts.py +292 -0
- axis-73/axis/cli/packs/api.py +429 -0
- axis-73/axis/cli/packs/devices.py +999 -0
- axis-73/axis/cli/packs/events.py +254 -0
- axis-73/axis/cli/packs/navigation.py +290 -0
- {axis-71 → axis-73}/axis/interfaces/aiohttp_digest.py +0 -2
- {axis-71 → axis-73}/axis/interfaces/api_discovery.py +3 -6
- {axis-71 → axis-73}/axis/interfaces/api_handler.py +12 -6
- {axis-71 → axis-73}/axis/interfaces/applications/application_handler.py +2 -2
- {axis-71 → axis-73}/axis/interfaces/applications/applications.py +1 -3
- {axis-71 → axis-73}/axis/interfaces/applications/fence_guard.py +1 -3
- {axis-71 → axis-73}/axis/interfaces/applications/loitering_guard.py +1 -3
- {axis-71 → axis-73}/axis/interfaces/applications/motion_guard.py +1 -3
- {axis-71 → axis-73}/axis/interfaces/applications/object_analytics.py +1 -3
- {axis-71 → axis-73}/axis/interfaces/applications/vmd4.py +1 -3
- {axis-71 → axis-73}/axis/interfaces/basic_device_info.py +2 -6
- axis-73/axis/interfaces/event_instances.py +43 -0
- {axis-71 → axis-73}/axis/interfaces/event_manager.py +13 -4
- {axis-71 → axis-73}/axis/interfaces/light_control.py +28 -37
- {axis-71 → axis-73}/axis/interfaces/mqtt.py +2 -37
- {axis-71 → axis-73}/axis/interfaces/parameters/param_cgi.py +4 -15
- {axis-71 → axis-73}/axis/interfaces/parameters/param_handler.py +2 -2
- {axis-71 → axis-73}/axis/interfaces/pir_sensor_configuration.py +3 -11
- {axis-71 → axis-73}/axis/interfaces/port_management.py +4 -6
- {axis-71 → axis-73}/axis/interfaces/ptz.py +6 -3
- {axis-71 → axis-73}/axis/interfaces/pwdgrp_cgi.py +2 -3
- {axis-71 → axis-73}/axis/interfaces/stream_profiles.py +2 -6
- axis-73/axis/interfaces/temperature_control.py +93 -0
- {axis-71 → axis-73}/axis/interfaces/user_groups.py +3 -3
- {axis-71 → axis-73}/axis/interfaces/vapix.py +102 -60
- {axis-71 → axis-73}/axis/interfaces/view_areas.py +5 -14
- {axis-71 → axis-73}/axis/models/api.py +20 -16
- {axis-71 → axis-73}/axis/models/api_discovery.py +5 -4
- {axis-71 → axis-73}/axis/models/applications/application.py +10 -9
- {axis-71 → axis-73}/axis/models/applications/fence_guard.py +22 -21
- {axis-71 → axis-73}/axis/models/applications/loitering_guard.py +22 -21
- {axis-71 → axis-73}/axis/models/applications/motion_guard.py +22 -21
- {axis-71 → axis-73}/axis/models/applications/object_analytics.py +22 -21
- {axis-71 → axis-73}/axis/models/applications/vmd4.py +22 -21
- {axis-71 → axis-73}/axis/models/basic_device_info.py +5 -4
- {axis-71 → axis-73}/axis/models/configuration.py +5 -3
- {axis-71 → axis-73}/axis/models/event.py +13 -43
- axis-73/axis/models/event_instance.py +420 -0
- {axis-71 → axis-73}/axis/models/light_control.py +175 -151
- {axis-71 → axis-73}/axis/models/mqtt.py +84 -56
- {axis-71 → axis-73}/axis/models/parameters/brand.py +1 -3
- {axis-71 → axis-73}/axis/models/parameters/param_cgi.py +28 -4
- {axis-71 → axis-73}/axis/models/parameters/properties.py +1 -3
- {axis-71 → axis-73}/axis/models/parameters/ptz.py +9 -11
- {axis-71 → axis-73}/axis/models/parameters/stream_profile.py +1 -3
- {axis-71 → axis-73}/axis/models/pir_sensor_configuration.py +31 -28
- {axis-71 → axis-73}/axis/models/port_cgi.py +3 -2
- {axis-71 → axis-73}/axis/models/port_management.py +48 -44
- {axis-71 → axis-73}/axis/models/ptz_cgi.py +9 -5
- {axis-71 → axis-73}/axis/models/pwdgrp_cgi.py +23 -21
- {axis-71 → axis-73}/axis/models/stream_profile.py +26 -25
- axis-73/axis/models/temperature_control.py +216 -0
- {axis-71 → axis-73}/axis/models/user_group.py +10 -9
- {axis-71 → axis-73}/axis/models/view_area.py +30 -27
- {axis-71 → axis-73}/axis/stream_transport.py +0 -2
- {axis-71 → axis-73}/axis/websocket.py +0 -2
- {axis-71 → axis-73}/axis.egg-info/PKG-INFO +63 -8
- {axis-71 → axis-73}/axis.egg-info/SOURCES.txt +20 -0
- {axis-71 → axis-73}/axis.egg-info/entry_points.txt +1 -0
- {axis-71 → axis-73}/axis.egg-info/requires.txt +11 -7
- {axis-71 → axis-73}/pyproject.toml +15 -11
- {axis-71 → axis-73}/tests/test_api_discovery.py +11 -9
- {axis-71 → axis-73}/tests/test_api_handler.py +24 -0
- {axis-71 → axis-73}/tests/test_basic_device_info.py +60 -24
- axis-73/tests/test_conftest.py +448 -0
- {axis-71 → axis-73}/tests/test_event.py +67 -20
- axis-73/tests/test_event_instances.py +519 -0
- axis-73/tests/test_event_instances_protocol_parity.py +115 -0
- {axis-71 → axis-73}/tests/test_event_stream.py +56 -18
- {axis-71 → axis-73}/tests/test_http_client_compat.py +3 -7
- {axis-71 → axis-73}/tests/test_light_control.py +212 -72
- {axis-71 → axis-73}/tests/test_mqtt.py +80 -20
- {axis-71 → axis-73}/tests/test_pir_sensor_configuration.py +46 -39
- {axis-71 → axis-73}/tests/test_port_cgi.py +39 -5
- {axis-71 → axis-73}/tests/test_port_management.py +92 -87
- {axis-71 → axis-73}/tests/test_ptz.py +184 -84
- {axis-71 → axis-73}/tests/test_pwdgrp_cgi.py +41 -17
- axis-73/tests/test_request_response_contracts.py +243 -0
- {axis-71 → axis-73}/tests/test_stream_profiles.py +49 -34
- axis-73/tests/test_temperature_control.py +249 -0
- {axis-71 → axis-73}/tests/test_vapix.py +266 -39
- {axis-71 → axis-73}/tests/test_view_areas.py +62 -32
- axis-71/README.md +0 -54
- axis-71/axis/interfaces/event_instances.py +0 -23
- axis-71/axis/models/event_instance.py +0 -174
- axis-71/tests/test_conftest.py +0 -164
- axis-71/tests/test_event_instances.py +0 -271
- {axis-71 → axis-73}/LICENSE +0 -0
- {axis-71 → axis-73}/axis/__init__.py +0 -0
- {axis-71 → axis-73}/axis/device.py +0 -0
- {axis-71 → axis-73}/axis/errors.py +0 -0
- {axis-71 → axis-73}/axis/interfaces/__init__.py +0 -0
- {axis-71 → axis-73}/axis/interfaces/applications/__init__.py +0 -0
- {axis-71 → axis-73}/axis/interfaces/parameters/__init__.py +0 -0
- {axis-71 → axis-73}/axis/interfaces/parameters/brand.py +0 -0
- {axis-71 → axis-73}/axis/interfaces/parameters/image.py +0 -0
- {axis-71 → axis-73}/axis/interfaces/parameters/io_port.py +0 -0
- {axis-71 → axis-73}/axis/interfaces/parameters/properties.py +0 -0
- {axis-71 → axis-73}/axis/interfaces/parameters/ptz.py +0 -0
- {axis-71 → axis-73}/axis/interfaces/parameters/stream_profile.py +0 -0
- {axis-71 → axis-73}/axis/interfaces/port_cgi.py +0 -0
- {axis-71 → axis-73}/axis/models/__init__.py +0 -0
- {axis-71 → axis-73}/axis/models/applications/__init__.py +0 -0
- {axis-71 → axis-73}/axis/models/parameters/__init__.py +0 -0
- {axis-71 → axis-73}/axis/models/parameters/image.py +0 -0
- {axis-71 → axis-73}/axis/models/parameters/io_port.py +0 -0
- {axis-71 → axis-73}/axis/py.typed +0 -0
- {axis-71 → axis-73}/axis/rtsp.py +0 -0
- {axis-71 → axis-73}/axis/stream_manager.py +0 -0
- {axis-71 → axis-73}/axis.egg-info/dependency_links.txt +0 -0
- {axis-71 → axis-73}/axis.egg-info/top_level.txt +0 -0
- {axis-71 → axis-73}/setup.cfg +0 -0
- {axis-71 → axis-73}/tests/test_auth_scheme.py +0 -0
- {axis-71 → axis-73}/tests/test_configuration.py +0 -0
- {axis-71 → axis-73}/tests/test_device.py +0 -0
- {axis-71 → axis-73}/tests/test_main_http_client.py +0 -0
- {axis-71 → axis-73}/tests/test_rtsp.py +0 -0
- {axis-71 → axis-73}/tests/test_stream_manager.py +0 -0
- {axis-71 → axis-73}/tests/test_user_groups.py +0 -0
- {axis-71 → axis-73}/tests/test_websocket.py +0 -0
{axis-71 → axis-73}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: axis
|
|
3
|
-
Version:
|
|
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.
|
|
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.
|
|
32
|
-
Requires-Dist: pytest==9.
|
|
33
|
-
Requires-Dist: pytest-aiohttp==1.1.
|
|
34
|
-
Requires-Dist: pytest-asyncio==1.
|
|
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.
|
|
37
|
-
Requires-Dist: types-xmltodict==v1.0.1.
|
|
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:
|
|
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 @@
|
|
|
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.")
|