axis 72__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-72 → axis-73}/PKG-INFO +26 -6
- {axis-72 → axis-73}/README.md +16 -0
- 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-72 → axis-73}/axis/interfaces/api_handler.py +4 -0
- axis-73/axis/interfaces/temperature_control.py +93 -0
- {axis-72 → axis-73}/axis/interfaces/vapix.py +82 -2
- {axis-72 → axis-73}/axis/models/configuration.py +4 -0
- {axis-72 → axis-73}/axis/models/parameters/ptz.py +8 -8
- axis-73/axis/models/temperature_control.py +216 -0
- {axis-72 → axis-73}/axis.egg-info/PKG-INFO +26 -6
- {axis-72 → axis-73}/axis.egg-info/SOURCES.txt +18 -0
- {axis-72 → axis-73}/axis.egg-info/entry_points.txt +1 -0
- {axis-72 → axis-73}/axis.egg-info/requires.txt +9 -5
- {axis-72 → axis-73}/pyproject.toml +11 -6
- {axis-72 → axis-73}/tests/test_request_response_contracts.py +2 -0
- axis-73/tests/test_temperature_control.py +249 -0
- {axis-72 → axis-73}/tests/test_vapix.py +87 -2
- {axis-72 → axis-73}/LICENSE +0 -0
- {axis-72 → axis-73}/axis/__init__.py +0 -0
- {axis-72 → axis-73}/axis/__main__.py +0 -0
- {axis-72 → axis-73}/axis/device.py +0 -0
- {axis-72 → axis-73}/axis/errors.py +0 -0
- {axis-72 → axis-73}/axis/interfaces/__init__.py +0 -0
- {axis-72 → axis-73}/axis/interfaces/aiohttp_digest.py +0 -0
- {axis-72 → axis-73}/axis/interfaces/api_discovery.py +0 -0
- {axis-72 → axis-73}/axis/interfaces/applications/__init__.py +0 -0
- {axis-72 → axis-73}/axis/interfaces/applications/application_handler.py +0 -0
- {axis-72 → axis-73}/axis/interfaces/applications/applications.py +0 -0
- {axis-72 → axis-73}/axis/interfaces/applications/fence_guard.py +0 -0
- {axis-72 → axis-73}/axis/interfaces/applications/loitering_guard.py +0 -0
- {axis-72 → axis-73}/axis/interfaces/applications/motion_guard.py +0 -0
- {axis-72 → axis-73}/axis/interfaces/applications/object_analytics.py +0 -0
- {axis-72 → axis-73}/axis/interfaces/applications/vmd4.py +0 -0
- {axis-72 → axis-73}/axis/interfaces/basic_device_info.py +0 -0
- {axis-72 → axis-73}/axis/interfaces/event_instances.py +0 -0
- {axis-72 → axis-73}/axis/interfaces/event_manager.py +0 -0
- {axis-72 → axis-73}/axis/interfaces/light_control.py +0 -0
- {axis-72 → axis-73}/axis/interfaces/mqtt.py +0 -0
- {axis-72 → axis-73}/axis/interfaces/parameters/__init__.py +0 -0
- {axis-72 → axis-73}/axis/interfaces/parameters/brand.py +0 -0
- {axis-72 → axis-73}/axis/interfaces/parameters/image.py +0 -0
- {axis-72 → axis-73}/axis/interfaces/parameters/io_port.py +0 -0
- {axis-72 → axis-73}/axis/interfaces/parameters/param_cgi.py +0 -0
- {axis-72 → axis-73}/axis/interfaces/parameters/param_handler.py +0 -0
- {axis-72 → axis-73}/axis/interfaces/parameters/properties.py +0 -0
- {axis-72 → axis-73}/axis/interfaces/parameters/ptz.py +0 -0
- {axis-72 → axis-73}/axis/interfaces/parameters/stream_profile.py +0 -0
- {axis-72 → axis-73}/axis/interfaces/pir_sensor_configuration.py +0 -0
- {axis-72 → axis-73}/axis/interfaces/port_cgi.py +0 -0
- {axis-72 → axis-73}/axis/interfaces/port_management.py +0 -0
- {axis-72 → axis-73}/axis/interfaces/ptz.py +0 -0
- {axis-72 → axis-73}/axis/interfaces/pwdgrp_cgi.py +0 -0
- {axis-72 → axis-73}/axis/interfaces/stream_profiles.py +0 -0
- {axis-72 → axis-73}/axis/interfaces/user_groups.py +0 -0
- {axis-72 → axis-73}/axis/interfaces/view_areas.py +0 -0
- {axis-72 → axis-73}/axis/models/__init__.py +0 -0
- {axis-72 → axis-73}/axis/models/api.py +0 -0
- {axis-72 → axis-73}/axis/models/api_discovery.py +0 -0
- {axis-72 → axis-73}/axis/models/applications/__init__.py +0 -0
- {axis-72 → axis-73}/axis/models/applications/application.py +0 -0
- {axis-72 → axis-73}/axis/models/applications/fence_guard.py +0 -0
- {axis-72 → axis-73}/axis/models/applications/loitering_guard.py +0 -0
- {axis-72 → axis-73}/axis/models/applications/motion_guard.py +0 -0
- {axis-72 → axis-73}/axis/models/applications/object_analytics.py +0 -0
- {axis-72 → axis-73}/axis/models/applications/vmd4.py +0 -0
- {axis-72 → axis-73}/axis/models/basic_device_info.py +0 -0
- {axis-72 → axis-73}/axis/models/event.py +0 -0
- {axis-72 → axis-73}/axis/models/event_instance.py +0 -0
- {axis-72 → axis-73}/axis/models/light_control.py +0 -0
- {axis-72 → axis-73}/axis/models/mqtt.py +0 -0
- {axis-72 → axis-73}/axis/models/parameters/__init__.py +0 -0
- {axis-72 → axis-73}/axis/models/parameters/brand.py +0 -0
- {axis-72 → axis-73}/axis/models/parameters/image.py +0 -0
- {axis-72 → axis-73}/axis/models/parameters/io_port.py +0 -0
- {axis-72 → axis-73}/axis/models/parameters/param_cgi.py +0 -0
- {axis-72 → axis-73}/axis/models/parameters/properties.py +0 -0
- {axis-72 → axis-73}/axis/models/parameters/stream_profile.py +0 -0
- {axis-72 → axis-73}/axis/models/pir_sensor_configuration.py +0 -0
- {axis-72 → axis-73}/axis/models/port_cgi.py +0 -0
- {axis-72 → axis-73}/axis/models/port_management.py +0 -0
- {axis-72 → axis-73}/axis/models/ptz_cgi.py +0 -0
- {axis-72 → axis-73}/axis/models/pwdgrp_cgi.py +0 -0
- {axis-72 → axis-73}/axis/models/stream_profile.py +0 -0
- {axis-72 → axis-73}/axis/models/user_group.py +0 -0
- {axis-72 → axis-73}/axis/models/view_area.py +0 -0
- {axis-72 → axis-73}/axis/py.typed +0 -0
- {axis-72 → axis-73}/axis/rtsp.py +0 -0
- {axis-72 → axis-73}/axis/stream_manager.py +0 -0
- {axis-72 → axis-73}/axis/stream_transport.py +0 -0
- {axis-72 → axis-73}/axis/websocket.py +0 -0
- {axis-72 → axis-73}/axis.egg-info/dependency_links.txt +0 -0
- {axis-72 → axis-73}/axis.egg-info/top_level.txt +0 -0
- {axis-72 → axis-73}/setup.cfg +0 -0
- {axis-72 → axis-73}/tests/test_api_discovery.py +0 -0
- {axis-72 → axis-73}/tests/test_api_handler.py +0 -0
- {axis-72 → axis-73}/tests/test_auth_scheme.py +0 -0
- {axis-72 → axis-73}/tests/test_basic_device_info.py +0 -0
- {axis-72 → axis-73}/tests/test_configuration.py +0 -0
- {axis-72 → axis-73}/tests/test_conftest.py +0 -0
- {axis-72 → axis-73}/tests/test_device.py +0 -0
- {axis-72 → axis-73}/tests/test_event.py +0 -0
- {axis-72 → axis-73}/tests/test_event_instances.py +0 -0
- {axis-72 → axis-73}/tests/test_event_instances_protocol_parity.py +0 -0
- {axis-72 → axis-73}/tests/test_event_stream.py +0 -0
- {axis-72 → axis-73}/tests/test_http_client_compat.py +0 -0
- {axis-72 → axis-73}/tests/test_light_control.py +0 -0
- {axis-72 → axis-73}/tests/test_main_http_client.py +0 -0
- {axis-72 → axis-73}/tests/test_mqtt.py +0 -0
- {axis-72 → axis-73}/tests/test_pir_sensor_configuration.py +0 -0
- {axis-72 → axis-73}/tests/test_port_cgi.py +0 -0
- {axis-72 → axis-73}/tests/test_port_management.py +0 -0
- {axis-72 → axis-73}/tests/test_ptz.py +0 -0
- {axis-72 → axis-73}/tests/test_pwdgrp_cgi.py +0 -0
- {axis-72 → axis-73}/tests/test_rtsp.py +0 -0
- {axis-72 → axis-73}/tests/test_stream_manager.py +0 -0
- {axis-72 → axis-73}/tests/test_stream_profiles.py +0 -0
- {axis-72 → axis-73}/tests/test_user_groups.py +0 -0
- {axis-72 → axis-73}/tests/test_view_areas.py +0 -0
- {axis-72 → axis-73}/tests/test_websocket.py +0 -0
{axis-72 → 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,19 +21,23 @@ 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
35
|
Requires-Dist: mypy==2.1.0; extra == "requirements-test"
|
|
32
|
-
Requires-Dist: pytest==9.
|
|
33
|
-
Requires-Dist: pytest-aiohttp==1.1.
|
|
34
|
-
Requires-Dist: pytest-asyncio==1.
|
|
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.
|
|
40
|
+
Requires-Dist: ruff==0.15.20; extra == "requirements-test"
|
|
37
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"
|
|
@@ -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:
|
{axis-72 → axis-73}/README.md
RENAMED
|
@@ -38,6 +38,22 @@ Initial `ty` support is configured as an opt-in check and does not replace `mypy
|
|
|
38
38
|
uvx ty check
|
|
39
39
|
```
|
|
40
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
|
+
|
|
41
57
|
## Initialization architecture
|
|
42
58
|
|
|
43
59
|
Vapix initialization is phase-based and driven by handler metadata:
|
|
@@ -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.")
|
axis-73/axis/cli/main.py
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""Axis CLI composition root and compatibility exports.
|
|
2
|
+
|
|
3
|
+
This module serves as the explicit bootstrap and import surface for the
|
|
4
|
+
plugin-pack based CLI architecture.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
# ruff: noqa: RUF022,TC003
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import asyncio
|
|
13
|
+
import logging
|
|
14
|
+
import os
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from axis.cli.core.context import CliContext
|
|
18
|
+
from axis.cli.core.gateway import DeviceGateway
|
|
19
|
+
from axis.cli.core.io import TerminalIO
|
|
20
|
+
from axis.cli.core.registry import CommandRegistry
|
|
21
|
+
from axis.cli.core.router import CliRouter
|
|
22
|
+
from axis.cli.packs import (
|
|
23
|
+
accounts as accounts_pack,
|
|
24
|
+
api as api_pack,
|
|
25
|
+
devices as devices_pack,
|
|
26
|
+
events as events_pack,
|
|
27
|
+
navigation as navigation_pack,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
DeviceEntry = devices_pack.DeviceEntry
|
|
31
|
+
DeviceStore = devices_pack.DeviceStore
|
|
32
|
+
|
|
33
|
+
# Compatibility exports from packs
|
|
34
|
+
get_config_path = devices_pack.get_config_path
|
|
35
|
+
load_devices = devices_pack.load_devices
|
|
36
|
+
save_devices = devices_pack.save_devices
|
|
37
|
+
to_toml_compatible = devices_pack.to_toml_compatible
|
|
38
|
+
fetch_device_serial_and_extra = devices_pack.fetch_device_serial_and_extra
|
|
39
|
+
extract_model_number = devices_pack.extract_model_number
|
|
40
|
+
extract_serial_number = devices_pack.extract_serial_number
|
|
41
|
+
prompt_for_device = devices_pack.prompt_for_device
|
|
42
|
+
config_to_toml_dict = devices_pack.config_to_toml_dict
|
|
43
|
+
_format_device_label = devices_pack._format_device_label
|
|
44
|
+
_format_device_operations_label = devices_pack._format_device_operations_label
|
|
45
|
+
AXIS_OUI = devices_pack.AXIS_OUI
|
|
46
|
+
AXIS_SERVICE_TYPE = devices_pack.AXIS_SERVICE_TYPE
|
|
47
|
+
is_axis_macaddress = devices_pack.is_axis_macaddress
|
|
48
|
+
discover_axis_devices = devices_pack.discover_axis_devices
|
|
49
|
+
select_discovered_device = devices_pack.select_discovered_device
|
|
50
|
+
filter_discovered_devices = devices_pack.filter_discovered_devices
|
|
51
|
+
validate_and_fetch_device = devices_pack.validate_and_fetch_device
|
|
52
|
+
print_registered_devices = devices_pack.print_registered_devices
|
|
53
|
+
migrate_unknown_entry = devices_pack.migrate_unknown_entry
|
|
54
|
+
find_serial_by_host = devices_pack.find_serial_by_host
|
|
55
|
+
select_device = devices_pack.select_device
|
|
56
|
+
get_device_credentials = devices_pack.get_device_credentials
|
|
57
|
+
run_on_selected_device = devices_pack.run_on_selected_device
|
|
58
|
+
register_or_update_device = devices_pack.register_or_update_device
|
|
59
|
+
delete_device = devices_pack.delete_device
|
|
60
|
+
edit_device_credentials = devices_pack.edit_device_credentials
|
|
61
|
+
health_check_device = devices_pack.health_check_device
|
|
62
|
+
HealthCheckResult = devices_pack.HealthCheckResult
|
|
63
|
+
|
|
64
|
+
fetch_supported_apis = api_pack.fetch_supported_apis
|
|
65
|
+
list_supported_apis_async = api_pack.list_supported_apis_async
|
|
66
|
+
run_api_read_action = api_pack.run_api_read_action
|
|
67
|
+
api_drill_down_async = api_pack.api_drill_down_async
|
|
68
|
+
|
|
69
|
+
fetch_event_instances = events_pack.fetch_event_instances
|
|
70
|
+
list_event_instances_async = events_pack.list_event_instances_async
|
|
71
|
+
events_menu_async = events_pack.events_menu_async
|
|
72
|
+
_live_listen_async = events_pack._live_listen_async
|
|
73
|
+
|
|
74
|
+
_validate_username = accounts_pack._validate_username
|
|
75
|
+
_select_privilege = accounts_pack._select_privilege
|
|
76
|
+
_account_init_confirm = accounts_pack._account_init_confirm
|
|
77
|
+
_account_init_operation = accounts_pack._account_init_operation
|
|
78
|
+
account_management_async = accounts_pack.account_management_async
|
|
79
|
+
_list_users_async = accounts_pack._list_users_async
|
|
80
|
+
_create_or_update_user_async = accounts_pack._create_or_update_user_async
|
|
81
|
+
_delete_user_async = accounts_pack._delete_user_async
|
|
82
|
+
|
|
83
|
+
selected_device_operations = navigation_pack.selected_device_operations
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
|
87
|
+
parser = argparse.ArgumentParser(description="Axis interactive CLI")
|
|
88
|
+
parser.add_argument(
|
|
89
|
+
"--debug",
|
|
90
|
+
action="store_true",
|
|
91
|
+
help="Enable verbose debug output for raw device/API responses.",
|
|
92
|
+
)
|
|
93
|
+
return parser.parse_args(argv)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _debug_enabled_from_env() -> bool:
|
|
97
|
+
value = os.getenv("AXIS_CLI_DEBUG", "").strip().lower()
|
|
98
|
+
return value in {"1", "true", "yes", "on"}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _configure_logging(debug_enabled: bool) -> None:
|
|
102
|
+
loglevel = logging.DEBUG if debug_enabled else logging.INFO
|
|
103
|
+
logging.basicConfig(format="%(message)s", level=loglevel, force=True)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def compose_builtin_packs(registry: CommandRegistry, router: CliRouter) -> None:
|
|
107
|
+
"""Register built-in CLI packs explicitly from a single composition root."""
|
|
108
|
+
devices_pack.register(registry, router)
|
|
109
|
+
api_pack.register(registry, router)
|
|
110
|
+
events_pack.register(registry, router)
|
|
111
|
+
accounts_pack.register(registry, router)
|
|
112
|
+
navigation_pack.register(registry, router)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def build_cli_runtime(config_path: Path) -> CliContext:
|
|
116
|
+
"""Create shared runtime context for the CLI process."""
|
|
117
|
+
registry = CommandRegistry()
|
|
118
|
+
router = CliRouter()
|
|
119
|
+
compose_builtin_packs(registry, router)
|
|
120
|
+
return CliContext(
|
|
121
|
+
config_path=config_path,
|
|
122
|
+
device_gateway=DeviceGateway(),
|
|
123
|
+
command_registry=registry,
|
|
124
|
+
router=router,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def main(*, debug: bool | None = None) -> None:
|
|
129
|
+
"""Run the interactive device registry CLI."""
|
|
130
|
+
debug_enabled = _debug_enabled_from_env() if debug is None else debug
|
|
131
|
+
_configure_logging(debug_enabled)
|
|
132
|
+
|
|
133
|
+
if debug_enabled:
|
|
134
|
+
os.environ["AXIS_CLI_DEBUG"] = "1"
|
|
135
|
+
print("Debug mode enabled. Verbose responses will be shown.") # noqa: T201
|
|
136
|
+
|
|
137
|
+
config_path = get_config_path()
|
|
138
|
+
runtime = build_cli_runtime(config_path)
|
|
139
|
+
|
|
140
|
+
if runtime.router is None:
|
|
141
|
+
msg = "Router runtime is unavailable."
|
|
142
|
+
raise RuntimeError(msg)
|
|
143
|
+
|
|
144
|
+
terminal_io = TerminalIO()
|
|
145
|
+
while True:
|
|
146
|
+
try:
|
|
147
|
+
asyncio.run(runtime.router.run(runtime, terminal_io, start_node_id="main"))
|
|
148
|
+
except KeyboardInterrupt:
|
|
149
|
+
print("\nInterrupted. Use 'e' to exit.") # noqa: T201
|
|
150
|
+
continue
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
__all__ = [
|
|
154
|
+
"DeviceEntry",
|
|
155
|
+
"DeviceStore",
|
|
156
|
+
"compose_builtin_packs",
|
|
157
|
+
"build_cli_runtime",
|
|
158
|
+
"main",
|
|
159
|
+
"get_config_path",
|
|
160
|
+
"load_devices",
|
|
161
|
+
"save_devices",
|
|
162
|
+
"to_toml_compatible",
|
|
163
|
+
"fetch_device_serial_and_extra",
|
|
164
|
+
"extract_model_number",
|
|
165
|
+
"extract_serial_number",
|
|
166
|
+
"prompt_for_device",
|
|
167
|
+
"config_to_toml_dict",
|
|
168
|
+
"_format_device_label",
|
|
169
|
+
"AXIS_OUI",
|
|
170
|
+
"AXIS_SERVICE_TYPE",
|
|
171
|
+
"is_axis_macaddress",
|
|
172
|
+
"discover_axis_devices",
|
|
173
|
+
"select_discovered_device",
|
|
174
|
+
"filter_discovered_devices",
|
|
175
|
+
"validate_and_fetch_device",
|
|
176
|
+
"print_registered_devices",
|
|
177
|
+
"migrate_unknown_entry",
|
|
178
|
+
"find_serial_by_host",
|
|
179
|
+
"select_device",
|
|
180
|
+
"get_device_credentials",
|
|
181
|
+
"run_on_selected_device",
|
|
182
|
+
"register_or_update_device",
|
|
183
|
+
"fetch_supported_apis",
|
|
184
|
+
"list_supported_apis_async",
|
|
185
|
+
"run_api_read_action",
|
|
186
|
+
"api_drill_down_async",
|
|
187
|
+
"fetch_event_instances",
|
|
188
|
+
"list_event_instances_async",
|
|
189
|
+
"events_menu_async",
|
|
190
|
+
"_live_listen_async",
|
|
191
|
+
"_validate_username",
|
|
192
|
+
"_select_privilege",
|
|
193
|
+
"_account_init_confirm",
|
|
194
|
+
"_account_init_operation",
|
|
195
|
+
"account_management_async",
|
|
196
|
+
"_list_users_async",
|
|
197
|
+
"_create_or_update_user_async",
|
|
198
|
+
"_delete_user_async",
|
|
199
|
+
"selected_device_operations",
|
|
200
|
+
]
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
if __name__ == "__main__":
|
|
204
|
+
args = _parse_args()
|
|
205
|
+
main(debug=args.debug)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Built-in CLI plugin packs."""
|