diff --git a/.agents/rules/agent-rules-layout.md b/.agents/rules/agent-rules-layout.md new file mode 100644 index 0000000..b43956a --- /dev/null +++ b/.agents/rules/agent-rules-layout.md @@ -0,0 +1,70 @@ +--- +description: Agent rules directory layout and symlink conventions +alwaysApply: false +globs: .agents/rules/**,.claude/rules/**,.cursor/rules/** +paths: + - ".agents/rules/**/*.md" + - ".claude/rules/**/*.md" + - ".cursor/rules/**/*.mdc" +--- + +# Agent rules layout + +This repo stores path-scoped agent rules in a tool-agnostic location and bridges them into Claude Code and Cursor via symlinks. + +## Canonical source + +- **Edit rules here:** `.agents/rules/*.md` +- **Do not** put rule content directly in `.claude/rules/` or `.cursor/rules/` — those files are symlinks. + +## Tool bridges + +Claude Code reads `.claude/rules/*.md`. Cursor requires `.cursor/rules/*.mdc`. Each bridge entry is a symlink to the matching canonical `.md`: + +```text +.agents/rules/my-rule.md ← canonical (commit this) +.claude/rules/my-rule.md ← symlink → ../../.agents/rules/my-rule.md +.cursor/rules/my-rule.mdc ← symlink → ../../.agents/rules/my-rule.md +``` + +## Dual frontmatter + +Every rule file needs frontmatter for both tools: + +```yaml +--- +description: Short summary for Cursor rule picker +alwaysApply: false +globs: path/to/match/** +paths: + - "path/to/match/**" +--- +``` + +- **Cursor** uses `globs`, `alwaysApply`, and `description`. +- **Claude Code** uses `paths`; omit `paths` for always-on rules. +- Each tool ignores the other's keys. + +## Adding a rule + +1. Create `.agents/rules/.md` with dual frontmatter and content. +2. Add the Claude and Cursor symlinks: + +```bash +ln -s ../../.agents/rules/.md .claude/rules/.md +ln -s ../../.agents/rules/.md .cursor/rules/.mdc +``` + +3. List the new rule in the **Agent rules** section of `AGENTS.md`. + +## Removing a rule + +1. Delete `.agents/rules/.md`. +2. Delete `.claude/rules/.md` and `.cursor/rules/.mdc` (the symlinks, not separate copies). +3. Remove its entry from `AGENTS.md`. + +## Do not + +- Symlink an entire bridge directory to another (extension mismatch: `.mdc` vs `.md`). +- Duplicate rule content across directories. +- Commit real files under `.claude/rules/` or `.cursor/rules/` — only symlinks belong there. diff --git a/.agents/rules/python-quality.md b/.agents/rules/python-quality.md new file mode 100644 index 0000000..de5ec19 --- /dev/null +++ b/.agents/rules/python-quality.md @@ -0,0 +1,46 @@ +--- +description: Python code quality conventions for src/ and tests/ +alwaysApply: false +globs: src/**/*.py,tests/**/*.py +paths: + - "src/**/*.py" + - "tests/**/*.py" +--- + +# Python code quality + +## Structured data + +- Use **Pydantic `BaseModel`** for all structured data — API responses, settings, internal records, result types, and domain wrappers. Do not use `@dataclass`. +- Prefer typed models over plain `dict` for structured data. +- Reuse project base models where they exist (e.g. `InspectApiBaseModel` for API shapes; inspect-internal bases for in-memory models). + +## Exceptions + +- Raise **specific, descriptive exceptions** rather than bare `Exception`. +- Follow domain error hierarchies: a base exception class plus typed subclasses with context attributes (see `src/videoipath_automation_tool/apps/inspect/errors.py`). + +## Context managers + +- Use `with` for files, locks, test spies, and connector wrappers. + +## Quality gates + +Before finishing Python changes, run: + +```bash +poetry run ruff check --fix src/ tests/ +poetry run ruff format src/ tests/ +``` + +Pre-commit hooks mirror these commands (see `.pre-commit-config.yaml`). + +## Generated and sensitive code + +- Do **not** hand-edit `src/videoipath_automation_tool/apps/inventory/model/drivers.py` — regenerate with `set-videoipath-version `. +- All committed data must follow the anonymization rules in `AGENTS.md`. + +## Change scope + +- Keep diffs minimal and focused. +- Match surrounding patterns: mixins, module docstrings, `TYPE_CHECKING` imports, and existing naming in the file you edit. diff --git a/.agents/rules/python-style.md b/.agents/rules/python-style.md new file mode 100644 index 0000000..92ac676 --- /dev/null +++ b/.agents/rules/python-style.md @@ -0,0 +1,55 @@ +--- +description: Python coding style for src/ and tests/ +alwaysApply: false +globs: src/**/*.py,tests/**/*.py +paths: + - "src/**/*.py" + - "tests/**/*.py" +--- + +# Python coding style + +## Type hints + +- Annotate all function parameters and return types. +- Use `from __future__ import annotations` in new modules. +- Use `TYPE_CHECKING` blocks for imports needed only for type hints. + +## Formatting and naming + +- Follow PEP 8 with **snake_case** for functions, variables, and modules. +- Max line length is **120** characters (ruff formatter default in this project — not Black's 88). +- Format with **ruff**, not Black: + +```bash +poetry run ruff format src/ tests/ +``` + +## Strings and paths + +- Use **f-strings** for string formatting; avoid `%` formatting and `.format()`. +- Use **`pathlib.Path`** over `os.path` for filesystem operations. + +## Comprehensions and readability + +- Prefer list/dict/set comprehensions over explicit loops when the result stays readable. +- Do not sacrifice clarity for brevity. + +## Layout and whitespace + +- Group **logically related lines** together (e.g. setup, core logic, cleanup). +- Separate groups with a **single blank line**; use an extra blank line between larger sections when it aids scanning. +- Do not sprinkle blank lines randomly, and do not leave long unbroken blocks when a visual break would help. +- Within a function, keep the main path easy to follow: inputs and validation first, then the core work, then return/cleanup. + +## Public before private + +- Place **public** API first so readers see the most relevant surface when scrolling: public classes, methods, functions, and module-level constants. +- Place **private** members after public ones: names prefixed with `_` (attributes, methods, functions, nested helpers) and internal implementation details. +- In classes: public methods first, then `_`-prefixed helpers and internal state accessors. +- In modules: public exports and user-facing functions first; private helpers and module-internal constants at the bottom. +- A short section comment (e.g. `# --- Internal ---`) is fine when a class or module has a large private block. + +## Resources + +- Use **context managers** (`with`) for files, locks, and other resources that need cleanup. diff --git a/.agents/rules/python-testing.md b/.agents/rules/python-testing.md new file mode 100644 index 0000000..cf7f943 --- /dev/null +++ b/.agents/rules/python-testing.md @@ -0,0 +1,81 @@ +--- +description: Python testing conventions for src/ and tests/ +alwaysApply: false +globs: src/**/*.py,tests/**/*.py +paths: + - "src/**/*.py" + - "tests/**/*.py" +--- + +# Python testing + +## Framework and commands + +- Use **pytest** for all tests. +- Prefer the dedicated entry points over bare `pytest`: + +```bash +poetry run test-unit # offline/unit suite (CI default) +poetry run test-e2e # live-server e2e (loads .env) +poetry run test # unit then e2e sequentially + +# Single file or test (extra args pass through to test-unit / test-e2e) +poetry run test-unit tests/validators/test_device_id.py +poetry run test-e2e tests/e2e/inspect/test_e2e_inspect.py::test_name +``` + +- `poetry run pytest` also runs unit tests only (e2e excluded via `addopts` in `pyproject.toml`). + +### VS Code + +Use the launch configs in `.vscode/launch.json`: + +- **Unit Tests** / **Unit Tests (current file)** — offline suite +- **E2E Tests** / **E2E Tests (current file)** — live-server suite + +Or run **Tests** from `.vscode/tasks.json` (`poetry run test`). + +## Unit vs e2e separation + +| Layer | Unit | E2E | +|-------|------|-----| +| Location | `tests/` except `tests/e2e/` | `tests/e2e/` only | +| Marker | unmarked | `@pytest.mark.e2e` | +| Env | `tests/conftest.py` (dummy values) | `.env` (copy from `.env.template`) | +| Run command | `test-unit` / `pytest` | `test-e2e` | +| CI | yes | no | + +Default `addopts` run coverage on `src/` and exclude e2e (`-m "not e2e"`). + +## Assertions + +- Use `pytest.raises(SpecificError)` with the exact exception type. +- Do not catch or assert against bare `Exception` when a domain error exists. + +## Unit and offline tests + +- Mock external I/O with fake connectors and lightweight stand-ins (see `tests/inspect/test_actions.py`). +- Dummy `VIPAT_*` env vars are set in `tests/conftest.py` (autouse fixture; skipped for e2e). +- Load JSON fixtures from `tests//fixtures//` using `pathlib.Path`. +- Put shared fixtures in `conftest.py` at the appropriate directory level. +- Use session-scoped fixtures only when setup is expensive and reuse is intentional. + +## E2E tests + +- Live-server tests live under `tests/e2e/` only. +- Mark with `@pytest.mark.e2e`; they are excluded from the default suite. +- E2e entry points (`poetry run test-e2e`, `poetry run test`, VS Code **E2E Tests**) load `.env` and enable the suite automatically. E2e runs use `--no-cov`. +- Copy `.env.template` to `.env` (gitignored), set connection vars. The e2e conftest loads `.env` automatically when present. +- Run with `poetry run test-e2e` (no extra env vars on the command line). +- Namespace all writes with the `E2E-` label prefix and `vipat-e2e` tag. +- Do not add e2e tests to the default CI/offline run. + +## Test data + +- All fixture and test data must follow anonymization rules in `AGENTS.md`. +- Preserve structure and relationships; replace real hostnames, IPs, and customer identifiers with generic placeholders. + +## Coverage + +- Default runs report coverage on `src/`. +- Do not disable coverage flags without a clear reason. diff --git a/.claude/rules/agent-rules-layout.md b/.claude/rules/agent-rules-layout.md new file mode 120000 index 0000000..4f04229 --- /dev/null +++ b/.claude/rules/agent-rules-layout.md @@ -0,0 +1 @@ +../../.agents/rules/agent-rules-layout.md \ No newline at end of file diff --git a/.claude/rules/python-quality.md b/.claude/rules/python-quality.md new file mode 120000 index 0000000..4a795e4 --- /dev/null +++ b/.claude/rules/python-quality.md @@ -0,0 +1 @@ +../../.agents/rules/python-quality.md \ No newline at end of file diff --git a/.claude/rules/python-style.md b/.claude/rules/python-style.md new file mode 120000 index 0000000..e7ec4f8 --- /dev/null +++ b/.claude/rules/python-style.md @@ -0,0 +1 @@ +../../.agents/rules/python-style.md \ No newline at end of file diff --git a/.claude/rules/python-testing.md b/.claude/rules/python-testing.md new file mode 120000 index 0000000..0a6692e --- /dev/null +++ b/.claude/rules/python-testing.md @@ -0,0 +1 @@ +../../.agents/rules/python-testing.md \ No newline at end of file diff --git a/.cursor/rules/agent-rules-layout.mdc b/.cursor/rules/agent-rules-layout.mdc new file mode 120000 index 0000000..4f04229 --- /dev/null +++ b/.cursor/rules/agent-rules-layout.mdc @@ -0,0 +1 @@ +../../.agents/rules/agent-rules-layout.md \ No newline at end of file diff --git a/.cursor/rules/python-quality.mdc b/.cursor/rules/python-quality.mdc new file mode 120000 index 0000000..4a795e4 --- /dev/null +++ b/.cursor/rules/python-quality.mdc @@ -0,0 +1 @@ +../../.agents/rules/python-quality.md \ No newline at end of file diff --git a/.cursor/rules/python-style.mdc b/.cursor/rules/python-style.mdc new file mode 120000 index 0000000..e7ec4f8 --- /dev/null +++ b/.cursor/rules/python-style.mdc @@ -0,0 +1 @@ +../../.agents/rules/python-style.md \ No newline at end of file diff --git a/.cursor/rules/python-testing.mdc b/.cursor/rules/python-testing.mdc new file mode 120000 index 0000000..0a6692e --- /dev/null +++ b/.cursor/rules/python-testing.mdc @@ -0,0 +1 @@ +../../.agents/rules/python-testing.md \ No newline at end of file diff --git a/.env.example b/.env.example deleted file mode 100644 index 4022041..0000000 --- a/.env.example +++ /dev/null @@ -1,11 +0,0 @@ -VIPAT_ENVIRONMENT=DEV -VIPAT_VIDEOIPATH_SERVER_ADDRESS=vip.company.com -VIPAT_VIDEOIPATH_USERNAME=user_with_api_access -VIPAT_VIDEOIPATH_PASSWORD=veryStrongPassword -VIPAT_USE_HTTPS=true -VIPAT_VERIFY_SSL_CERT=false -VIPAT_LOG_LEVEL=INFO -VIPAT_ADVANCED_DRIVER_SCHEMA_CHECK=true -VIPAT_TIMEOUT_HTTP_GET=10 -VIPAT_TIMEOUT_HTTP_PATCH=10 -VIPAT_TIMEOUT_HTTP_POST=10 \ No newline at end of file diff --git a/.env.template b/.env.template new file mode 100644 index 0000000..9179638 --- /dev/null +++ b/.env.template @@ -0,0 +1,13 @@ +# Copy to .env (gitignored) for local development and live-server e2e tests. + +VIPAT_ENVIRONMENT=DEV +VIPAT_VIDEOIPATH_SERVER_ADDRESS=vip-server.example +VIPAT_VIDEOIPATH_USERNAME=test-user +VIPAT_VIDEOIPATH_PASSWORD=test-password +VIPAT_USE_HTTPS=true +VIPAT_VERIFY_SSL_CERT=false +VIPAT_LOG_LEVEL=INFO +VIPAT_ADVANCED_DRIVER_SCHEMA_CHECK=true +VIPAT_TIMEOUT_HTTP_GET=10 +VIPAT_TIMEOUT_HTTP_PATCH=10 +VIPAT_TIMEOUT_HTTP_POST=10 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5a4e1a5..e989636 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,7 +5,7 @@ on: branches: - main tags: - - 'v*' # Trigger on version tags like v1.2.3 + - "v*" # Trigger on version tags like v1.2.3 pull_request_target: branches: - main @@ -20,7 +20,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.11", "3.12", "3.13"] + python-version: ["3.11", "3.12", "3.13", "3.14"] steps: - name: Checkout repository uses: actions/checkout@v4 @@ -37,7 +37,7 @@ jobs: run: poetry install --with dev,test - name: Run tests - run: poetry run pytest + run: poetry run test-unit # 2️⃣ Release Job (Uses Prebuilt Package) release: @@ -52,7 +52,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v4 with: - python-version: "3.13" + python-version: "3.14" - name: Install Poetry run: pip install poetry diff --git a/.vscode/launch.json b/.vscode/launch.json index 78c16ab..fe83c16 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -2,14 +2,72 @@ "version": "0.2.0", "configurations": [ { - "name": "Tests", + "name": "Unit Tests", "type": "debugpy", "request": "launch", "module": "pytest", + "python": "${workspaceFolder}/.venv/bin/python", + "cwd": "${workspaceFolder}", "args": [ - "-v" + "-m", + "not e2e", + "--ignore=tests/e2e" ], "console": "integratedTerminal" + }, + { + "name": "E2E Tests", + "type": "debugpy", + "request": "launch", + "module": "pytest", + "python": "${workspaceFolder}/.venv/bin/python", + "cwd": "${workspaceFolder}", + "args": [ + "-m", + "e2e", + "tests/e2e", + "--no-cov" + ], + "env": { + "VIPAT_E2E_ENABLED": "1" + }, + "envFile": "${workspaceFolder}/.env", + "console": "integratedTerminal" + }, + { + "name": "Unit Tests (current file)", + "type": "debugpy", + "request": "launch", + "module": "pytest", + "python": "${workspaceFolder}/.venv/bin/python", + "cwd": "${workspaceFolder}", + "args": [ + "-m", + "not e2e", + "--ignore=tests/e2e", + "${file}" + ], + "console": "integratedTerminal" + }, + { + "name": "E2E Tests (current file)", + "type": "debugpy", + "request": "launch", + "module": "pytest", + "python": "${workspaceFolder}/.venv/bin/python", + "cwd": "${workspaceFolder}", + "args": [ + "-m", + "e2e", + "tests/e2e", + "--no-cov", + "${file}" + ], + "env": { + "VIPAT_E2E_ENABLED": "1" + }, + "envFile": "${workspaceFolder}/.env", + "console": "integratedTerminal" } ] -} \ No newline at end of file +} diff --git a/.vscode/settings.json b/.vscode/settings.json index 4f8d0bf..9b87748 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,4 +1,5 @@ { + "python.defaultInterpreterPath": "${workspaceFolder}/.venv/bin/python", "[python]": { "editor.defaultFormatter": "charliermarsh.ruff", "editor.formatOnType": true @@ -21,7 +22,7 @@ "python.analysis.extraPaths": [ "${workspaceFolder}/.venv/*" ], - "python.languageServer": "Pylance", + "python.languageServer": "None", "python.analysis.addImport.exactMatchOnly": true, "python.analysis.autoFormatStrings": true, "python.analysis.fixAll": [ diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 55282d3..2c5cd45 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -1,6 +1,20 @@ { "version": "2.0.0", "tasks": [ + { + "label": "Tests", + "type": "shell", + "command": "poetry", + "args": [ + "run", + "test" + ], + "group": { + "kind": "test", + "isDefault": false + }, + "problemMatcher": [] + }, { "label": "Generate Data Model", "problemMatcher": [], diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..af93963 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,149 @@ +# AGENTS.md + +This file provides guidance for AI coding agents working in this repository. + +## Data anonymization (mandatory) + +**All concrete data committed to this repository must be anonymized.** This applies everywhere: test fixtures, documentation examples, scripts, comments, and any other artifacts that contain VideoIPath or customer-specific information. + +Before adding or modifying data, replace real identifiers with generic placeholders: + +| Category | Do not commit | Use instead | +|---|---|---| +| Hostnames / FQDNs | `vip-prod.example.customer.com` | `vip-server.example` or `device-host-a` | +| IP addresses | Real network addresses | `10.0.0.1`, `192.0.2.1` (RFC 5737 documentation ranges) | +| Usernames / passwords | Real credentials | `test-user`, `test-password` | +| Device / module / port names | Customer site labels | `device-a`, `module-1`, `port-out-1` | +| Service / path / edge labels | Production naming | `service-a`, `path-1`, `edge-a` | +| Organization / site names | Customer or internal names | `example-org`, `site-a` | +| MAC addresses, serial numbers | Real hardware IDs | Synthetic values with no production mapping | + +Rules: + +1. **Never commit live API responses as-is.** Capture from a real server only in a local, untracked workflow; anonymize before staging. +2. **Apply the same rules to docs and inline examples** — a markdown code block with a real hostname is as sensitive as a JSON fixture. +3. **Preserve structure, not identity.** Keep IDs, relationships, and field shapes realistic so tests and docs remain meaningful; only replace identifying values. +4. **Review diffs for leaks.** Scan new files for hostnames, email addresses, customer abbreviations, and internal project codenames. +5. **When in doubt, generalize.** Prefer `device-a` / `port-out-1` style names already used under `tests//fixtures/`. + +Inspect fixtures can be anonymized with `scripts/anonymize_inspect_fixtures.py` when available. + +## Commands + +```bash +# Install all dependencies (including dev and test groups) +poetry install --with dev,test + +# Run unit tests (offline; same as CI default) +poetry run test-unit + +# Run e2e tests against a live server (loads connection vars from .env — see .env.template) +poetry run test-e2e + +# Run unit then e2e sequentially +poetry run test + +# Single file or test (extra args pass through) +poetry run test-unit tests/validators/test_device_id.py +poetry run test-e2e tests/e2e/inspect/test_e2e_inspect.py::test_name + +# Bare pytest also runs unit tests only (e2e excluded via pyproject addopts) +poetry run pytest + +# Lint with auto-fix +poetry run ruff check --fix src/ tests/ + +# Format +poetry run ruff format src/ tests/ + +# Install pre-commit hooks (runs ruff lint+format on commit) +pre-commit install + +# Driver schema CLI tools (after package install) +set-videoipath-version # e.g. 2024.3.3 +get-videoipath-version +list-videoipath-versions +``` + +## Agent rules + +Global repo guidance lives in this file. Path-scoped Python rules are in `.agents/rules/`: + +- `python-style.md` — type hints, ruff formatting, naming, pathlib +- `python-quality.md` — Pydantic, exceptions, lint gates, change scope +- `python-testing.md` — pytest, fixtures, fakes, e2e gating +- `agent-rules-layout.md` — how to add/remove rules and maintain tool symlinks + +Claude Code and Cursor read the same content via symlinks in `.claude/rules/*.md` and `.cursor/rules/*.mdc`. Canonical rule files live in `.agents/rules/`; see `agent-rules-layout.md` when creating or deleting rules. Python rules load when working on files under `src/` or `tests/`. + +## Architecture + +### Three-layer design + +``` +VideoIPathApp (public entry point — src/videoipath_automation_tool/apps/videoipath_app.py) + ├── inventory → InventoryApp + ├── topology → TopologyApp + ├── preferences → PreferencesApp + ├── profile → ProfileApp + └── security → SecurityApp + +Each App: + App class (user-facing methods, business logic) + └── *API class (raw API calls, response parsing) + └── VideoIPathConnector (src/videoipath_automation_tool/connector/) + ├── VideoIPathRestConnector (REST v2 GET/PATCH) + └── VideoIPathRPCConnector (RPC POST) +``` + +`VideoIPathApp` lazily initializes each sub-app on first property access. When `VIPAT_ENVIRONMENT=DEV`, the internal `*_api` objects are also exposed directly on the `VideoIPathApp` instance for easier exploration. + +### Connector layer + +`VideoIPathConnector` (`connector/vip_connector.py`) wraps two low-level connectors for the two VideoIPath API styles: +- **REST connector**: `/rest/v2/data/…` endpoints (GET, PATCH) +- **RPC connector**: RPC POST calls + +Response models live in `connector/models/` as Pydantic models. + +### Inventory app structure + +`InventoryApp` (`apps/inventory/`) uses Python mixins to split its methods across files: +- `app/app.py` — composes `InventoryCreateDeviceMixin`, `InventoryGetDeviceMixin`, etc. +- `inventory_api.py` — raw API methods +- `model/drivers.py` — auto-generated driver schemas; `SELECTED_SCHEMA_VERSION` and `AVAILABLE_SCHEMA_VERSIONS` control which VideoIPath server version is targeted + +### Inspect app (in-progress) + +`apps/inspect/` follows a different, read-only pattern built around `InspectSnapshot`: +- `snapshot.py` — builds in-memory indexes from a bulk API response; domain objects (`InspectDevice`, `InspectPort`, `InspectEdge`, `InspectService`) are created lazily and cached on the snapshot +- `domain/` — thin view objects that hold a back-reference to the snapshot for cross-entity lookups +- `model/` — raw Pydantic models for the API response (`collector.py`, etc.) + +### Driver versioning + +Driver schemas (Pydantic models for device `custom_settings`) are auto-generated from the VideoIPath API's JSON schema and live under `apps/inventory/model/drivers.py`. Run `set-videoipath-version ` to regenerate them for a different server version. The CLI scripts are in `src/vipat_cli_scripts/`. + +### Settings and environment variables + +All configuration is loaded via `Settings` (`settings.py`, backed by `pydantic-settings`). Variables are prefixed `VIPAT_`. Copy `.env.template` to `.env` for local development and e2e tests (gitignored). Unit tests set dummy `VIPAT_*` values in `tests/conftest.py`. + +Key variables: +| Variable | Default | Notes | +|---|---|---| +| `VIPAT_ENVIRONMENT` | `PROD` | `DEV` exposes internal APIs on `VideoIPathApp` | +| `VIPAT_VIDEOIPATH_SERVER_ADDRESS` | — | Required | +| `VIPAT_VIDEOIPATH_USERNAME` | — | Required | +| `VIPAT_VIDEOIPATH_PASSWORD` | — | Required | +| `VIPAT_USE_HTTPS` | `true` | | +| `VIPAT_VERIFY_SSL_CERT` | `true` | | +| `VIPAT_LOG_LEVEL` | _(root logger)_ | `DEBUG`/`INFO`/`WARNING`/`ERROR`/`CRITICAL` | +| `VIPAT_ADVANCED_DRIVER_SCHEMA_CHECK` | `true` | Compares local vs. server driver schema on init | + +## Release process + +1. Update `version` in `pyproject.toml` +2. Create a GitHub Release with a `v*` tag +3. CI builds and publishes to PyPI automatically + +Hotfix branches must use the `hotfix/` prefix. Development builds are published automatically for every commit on `main` or open PRs with a `.dev` suffix. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 0000000..47dc3e3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/README.md b/README.md index 591a6a9..a1abc21 100644 --- a/README.md +++ b/README.md @@ -79,8 +79,9 @@ except Exception as e: ## Documentation - [Getting Started Guide](https://github.com/SWR-MoIP/VideoIPath-Automation-Tool/blob/main/docs/getting-started-guide/README.md) -- [Python Module Architecture](https://github.com/SWR-MoIP/VideoIPath-Automation-Tool/blob/main/docs/python-module-architecture.md) +- [Examples](https://github.com/SWR-MoIP/VideoIPath-Automation-Tool/blob/main/docs/examples/README.md) - [Driver Versioning](https://github.com/SWR-MoIP/VideoIPath-Automation-Tool/blob/main/docs/driver-versioning.md) +- [Python Module Architecture](https://github.com/SWR-MoIP/VideoIPath-Automation-Tool/blob/main/docs/architecture/python-module-architecture.md) - [Development and Release](https://github.com/SWR-MoIP/VideoIPath-Automation-Tool/blob/main/docs/development-and-release.md) ## Feedback & Contributions diff --git a/docs/architecture/future/unified-domain-architecture.md b/docs/architecture/future/unified-domain-architecture.md new file mode 100644 index 0000000..9446664 --- /dev/null +++ b/docs/architecture/future/unified-domain-architecture.md @@ -0,0 +1,573 @@ +# Unified Domain Architecture (proposed) + +> Status: **Draft / Vision** · Last updated: 2026-06-18 +> +> This document proposes a **package-wide** re-think: present **one unified +> domain model** in which the user simply **creates devices, configures them, +> and connects them** — without ever needing to know about the `inventory`, +> `topology`, or `inspect` apps, which VideoIPath (VIP) store or endpoint is +> involved, or how the work is split across planes. +> +> It stands in deliberate contrast to the current +> [`python-module-architecture.md`](../python-module-architecture.md), whose +> stated goal is to *"maintain a structure similar to VideoIPath."* That goal is +> exactly what this proposal revisits. The VIP wire format and per-app research +> this builds on lives in [`inspect-app/`](../inspect-app/README.md) +> (`concepts.md` and the ADRs); those documents remain the **faithful map of the +> server**, while this one is the **map we want to present to users**. +> +> Nothing here is implemented yet. This is the target picture and the decisions +> required to get there. + +## 1. The idea in one sentence + +> The user works with a **network of devices and connections**. *Where* that +> lives in VideoIPath — Inventory onboarding, Topology placement, Inspect +> monitoring, which REST/RPC endpoint, which store — is an **implementation +> detail** the package owns, not the user. + +Today the user must learn the VIP product structure and drive it step by step: + +```python +# Today: the user orchestrates VIP's app/plane split by hand +staged = app.inventory.create_device(driver="com.nevion.NMOS_multidevice-0.1.0") +staged.configuration.address = "10.100.100.1" +device = app.inventory.add_device(staged) # plane 1: Inventory (RPC) + +topo = app.topology.get_device(device.device_id) # plane 2: Topology (nGraphElements) +topo.configuration.position_x = 100 +app.topology.update_device(topo) + +edges = app.topology.create_edges(...) # plane 2/3: edges +# ... monitor via the collector aggregate ... # plane 3: Inspect +``` + +The proposed surface: + +```python +# Proposed: one domain, the package orchestrates the planes +dev = app.add_device(driver="...", address="10.100.100.1", label="Cam-1", position=Position(x=100, y=200)) +app.connect_devices(dev.port("Eth 1.10"), other.port("Eth 1.11"), bandwidth=Gbps(10)) +``` + +## 2. Why a unified domain + +The package today is a **transparent client**: its public surface speaks VIP's +own vocabulary — `BaseDevice`, `IpVertex`, `UnidirectionalEdge`, +`nGraphElements`, `_rev`, `fromId::toId` — and exposes VIP's **app split** +(`inventory` / `topology` / `inspect`). The user must know *which VIP app does +what*: onboard in Inventory → place/connect in Topology → monitor in Inspect, +while Inspect writes land back in `nGraphElements`. + +This is awkward for reasons confirmed against a real server (see +[`inspect-app/concepts.md`](../inspect-app/concepts.md) and the captured +`collector` payload): + +1. **The app split is VIP's internal structure, not the user's mental model.** + "Onboard, then place, then connect, then monitor" is a property of VIP's + architecture. Users think "I have devices and I want to connect them." +2. **VIP already hides its own stores behind facades.** The `collector` + aggregate composes the raw stores server-side. Mirroring the raw stores in + Python re-exposes a detail the vendor themselves chose to hide. +3. **The wire format is unstable and version-gated** (the package version-gates + endpoints and carries many `[VERIFY]` items). A transparent client passes + that instability straight through to users; one domain surface absorbs it in + a single place. +4. **One real device is fragmented across three models today** — + `InventoryDevice`, `TopologyDevice`, and the Inspect node — each a partial + view. A unified `Device` re-assembles the whole. +5. **The same entity has three+ wire representations** — the `collector` read + shape, the `updateTopology` delta, and the `nGraphElements` store. A + user-facing model that picks **one** domain representation and translates + internally is strictly simpler. + +This is a textbook case for an **anti-corruption layer (ACL)**: a stable domain +model with a hard boundary, behind which all VIP-specific translation lives. + +## 3. Verdict & guiding principle + +**Abstract VIP away — but as an explicit layered ACL, not a thin rename.** The +discipline: + +- The **domain layer never imports or returns a wire/app type.** Translation + lives only in the ACL. +- The domain model is **stable**; version churn is absorbed by ACL adapters, + selected by the existing version-check machinery — never by branching in user + code or in the domain types. +- We deliberately decide, per concept, **what to hide vs. what to expose** (§13). + Some semantics (commit, validation, conflict) are first-class domain + behaviour; others (edge pairing, key formats, namespaces, the app split) are + hidden. +- The existing apps remain the documented **escape hatch** for raw access — + keeping the change **additive and non-breaking**. + +> **What an ACL can and cannot hide.** Renaming fields (`unidirectionalEdge` → +> `Connection`) is trivial. Hiding *semantics* is the hard part and must be a +> conscious choice: a connection being two paired unidirectional edges (hide), +> commit validation that can fail after HTTP success (expose), optimistic +> concurrency via revisions (expose, but as an opaque token), driver-specific +> configuration schemas (cannot be hidden — see §12). A good ACL chooses; it +> does not pretend the semantics don't exist. + +## 4. Target architecture + +```text +┌────────────────────────────────────────────────────────────────┐ +│ Unified Domain API (NEW default surface) │ ← what users touch +│ app.network: Device, Port, Connection, Service, Status │ one model, one vocabulary +│ lifecycle: discover → onboard → place → connect → monitor │ stable, version-independent +├────────────────────────────────────────────────────────────────┤ +│ Orchestration + Anti-corruption layer │ ← sequences plane operations, +│ domain ⇄ {inventory RPC, topology PATCH, collector/updateTopo} │ translates & maps ids; no I/O state +├────────────────────────────────────────────────────────────────┤ +│ Existing apps (now the LOW-LEVEL layer / escape hatch) │ ← unchanged, still public +│ app.inventory · app.topology · app.inspect · app.profile · … │ faithful to VIP +├────────────────────────────────────────────────────────────────┤ +│ Connector (REST v2 + RPC) │ +└────────────────────────────────────────────────────────────────┘ +``` + +Key properties: + +- **`app.network` is the new default**; the per-app surfaces are demoted to a + documented **low-level layer / escape hatch**, not removed. +- **Orchestration is a core responsibility**: a single domain call may fan out + to **several planes in sequence** (e.g. `add_device` = Inventory RPC add → + optional Topology placement). +- The ACL is **pure translation** — no I/O, no transport state. It maps between + domain objects and wire DTOs, so the wire models can change shape without + touching the domain types. + +## 5. The device lifecycle + +The hardest part of a package-wide abstraction is owning the **onboarding +lifecycle**, which today is an explicit, ordered prerequisite chain (devices are +onboarded in Inventory first, only then placed/connected). + +Model the lifecycle explicitly on the domain `Device` so the user can reason +about *where* a device is without knowing *which app*: + +| Domain state | Meaning | VIP reality (hidden) | +| ------------ | ------- | -------------------- | +| `Discovered` | Auto-found, not yet managed | Inventory discovered devices | +| `Onboarded` | Known/credentialed, driver bound | Inventory `config/devman/devices` (RPC `/api/updateDevices`) | +| `Placed` | Positioned in the network graph | Topology `baseDevice` in `nGraphElements` | +| `Connected` | Has connections to other devices | `unidirectionalEdge` / `updateTopology` | +| `Monitored` | Live status/services available | `collector` aggregate + WebSocket | + +`add_device(...)` advances a device from nothing → `Onboarded` (and optionally +→ `Placed` in the same call). `connect(...)` advances to `Connected`. The user +sees one object moving through states; the package picks the endpoints. + +> **This is also where the abstraction is hardest** — see §12 (limits). Some +> lifecycle inputs (driver choice, credentials, per-driver custom settings) are +> genuinely device-specific and cannot be fully hidden. + +## 6. The unified domain model + +One `Device` re-assembles what is today three partial models. The whole +vertex/edge/element taxonomy collapses into a handful of nouns. These are +**proposals**, not final signatures: + +```python +# Domain model — intentionally NOT mirroring nGraphElements / collector DTOs. + +class Device: + id: DeviceId # opaque; internally "device34" / "virtual.3" + label: str + driver: DriverRef # required for onboarding (see §12) + connection: ConnectionInfo # address(es), credentials, driver custom settings + state: LifecycleState # §5 + position: Point | None # placement; hides maps[]/meta.coordinates/inspect_app_format + ports: list[Port] # capabilities (merges vertices + collector ports) + status: DeviceStatus # merges inventory status + collector node status + sync: SyncState # from nodeStatus.syncSeverity + tags: list[str] + +class Port: # hides ipVertex/codecVertex/genericVertex + vertexInfo single|double + id: PortId + label: str + direction: Direction # IN | OUT | BIDIRECTIONAL (hides .in/.out vertex split) + kind: PortKind # IP | CODEC | GENERIC + is_endpoint: bool + status: PortStatus + +class Connection: # ONE user-facing connection == the paired unidirectional edges + id: ConnectionId # "deviceA::deviceB" pair identity + a: PortRef + b: PortRef + forward: DirectionState # primary: bandwidth, status + reverse: DirectionState # secondary: bandwidth, status (may differ!) + redundancy: Redundancy + status: ConnectionStatus # rolled up from alarm/bandwidth/maintenance/ptp + +class Service: # a booking/path (collector inspect.paths) + id: ServiceId # "100001" + label: str # "Encoder 1.1 -> Decoder 1.5" + endpoints: tuple[Endpoint, Endpoint] + is_main: bool + status: ServiceStatus +``` + +Design principles: + +- **Opaque ids** (`DeviceId`, `PortId`, `ConnectionId`) are value objects, not + raw strings. They internally carry every wire form (§7). Users never construct + ids by string-mangling. +- **A `Connection` is singular** — the single most valuable abstraction. Hide + that an inter-device connection is two `unidirectionalEdge`s plus internal + fan-out. Carry **per-direction state** because the two directions are + genuinely asymmetric (the capture shows forward `bandwidth: 20.0`, reverse + `0.0`). +- **Status is a first-class, read-only projection**, separate from config. This + matches the config-plane vs. status-plane split + ([ADR-001](../inspect-app/decisions/001-api-paradigm.md)) and keeps the live + story clean. +- **Make illegal states unrepresentable** where cheap (enums for `Direction`, + `PortKind`, `Redundancy`, `LifecycleState`, `SyncState`). + +## 7. Identifiers — the "id zoo" and why ids must be opaque + +A single physical port appears under **four to five** id schemes, all visible in +one collector payload (and inventory adds its own device-id assignment on top). +For device0's "Eth 1.10": + +| Form | Example | Where it appears | +| ---- | ------- | ---------------- | +| Port pid | `device0.dev.1.10` | `nodeStatus` module/port keys, `context.portPid` (note the `.dev.` infix) | +| Vertex id (in/out) | `device0.1.10.in` / `device0.1.10.out` | `vertexInfo.in/out.id`, edge keys (**no** `.dev.`) | +| Resource id | `device:device0.dev.1.10` | `resourceId`, `security.*` | +| Topo endpoint ref | `topo:device0.1.1` | `serviceFields.from` / `.to` | +| Connection pair key | `device0::device1` | `externalEdgesByDeviceKey._id` | +| Edge key | `device0.1.10.out::device1.1.11.in` | edge ids, `replaceEdges` | +| Service id | `100001::main` | `inspect.paths`, `pathDescriptions` keys | + +Translating "the port a user names" → "the vertex id an edge key needs" is a +non-trivial, lossy-if-sloppy transform (`device0.dev.1.10` → +`device0.1.10.{in|out}`). **Therefore ids are value objects that hold all +forms.** String-mangling these in user code — especially the `.dev.` infix — +is the most likely source of subtle bugs. + +## 8. Reads — the collector snapshot as the aggregate root + +The `collector` tree is the aggregate root for reads. Loading follows +[ADR-005](../inspect-app/decisions/005-lazy-snapshot-loading.md): +a **skeleton** query pair fetches the whole graph structure (all devices +without module/port detail + all edges with a lean projection) in one +consistent round, and per-device subtrees are **lazily hydrated** on demand. +The rule: never rebuild the *graph structure* from N per-device fetches — the +skeleton delivers it at once; per-device fetches are only for drill-down +detail. The full `GET …/data/status/collector/**` aggregate remains the eager +mode when a point-in-time view of everything is needed. For onboarding-only +attributes not in the collector (driver, credentials, custom settings), the +ACL supplements with an Inventory read. + +The cost is **heavy denormalization**. A single service (`100001::main`) +appears in at least six places in the snapshot, each with full status +sub-objects: + +- `inspect.paths._items[]` (canonical path + `serviceFields`) +- `externalEdgesByDeviceKey[].primary.data[…].pathDescriptions` +- `nodeStatus` source-endpoint port (`device0.dev.1.1`) +- `nodeStatus` egress port (`device0.dev.1.10`) +- `nodeStatus` ingress port (`device1.dev.1.11`) +- `nodeStatus` sink-endpoint port (`device1.dev.1.5`) + +**Rule:** pick **one canonical source per domain object**; treat the rest as +drill-down breadcrumbs. Building a domain object from each occurrence yields +divergent copies. + +| Domain object | Canonical source | +| ------------- | ---------------- | +| `Device` / `Port` | `inspect.nodeStatus._items[]` → `modules.*.ports.*` (+ inventory for onboarding fields) | +| `Connection` | `externalEdgesByDeviceKey._items[]` | +| `Service` | `inspect.paths._items[]` (+ `serviceFields`) | +| port ↔ service drill-down | `ports.*.pathDescriptions` (reference only — do not re-model) | + +### 8.1 Connections are pre-paired by the server + +`externalEdgesByDeviceKey._items[]` already groups the two unidirectional edges +under one bidirectional key: + +- `_id: "device0::device1"` — the connection identity +- `primary` → `device0.1.10.out::device1.1.11.in` (forward direction) +- `secondary` → `device1.1.11.out::device0.1.10.in` (reverse direction) + +So the `Connection` deduplication problem is solved on the read side: the `a::b` +key is the identity, and `primary` / `secondary` are the two `DirectionState`s. +The directions carry **independent** `bandwidth` / `fromStatus` / `toStatus` / +`status`, so the domain `Connection` must keep both, plus the top-level +aggregate `status` the collector provides. + +## 9. Operation → plane mapping (orchestration) + +Each domain operation expands to an ordered sequence of existing wire +operations. The ACL owns this table; the user never sees it. + +| Domain operation | Orchestrated VIP sequence | +| ---------------- | ------------------------- | +| `network.discover()` | Inventory discovered-devices read | +| `network.add_device(driver, connection, …)` | Inventory RPC `/api/updateDevices` (assigns `deviceN`) → optional placement via `addDevices` network action / `updateTopology` `replaceDevices` | +| `device.configure(...)` | Inventory custom-settings update (RPC) and/or `updateTopology` `replaceVertices` — by field | +| `device.place(x, y)` | `updateTopology` `replaceDevices` (coordinates) | +| `network.connect(a, b, …)` | Resolve port→vertex ids → `updateTopology` `replaceEdges` / `addExternalEdges` (paired edges) | +| `network.get(...)` / `list_*()` | `collector` snapshot read (+ inventory status where needed) | +| `network.disconnect(...)` / `remove_device(...)` | `updateTopology` `remove` and/or Inventory RPC remove | +| `device.refresh()` / `network.refresh()` | Re-fetch `collector` snapshot (§11.3); after own commits: targeted refresh ([ADR-008](../inspect-app/decisions/008-post-commit-snapshot-refresh.md)) | + +All topology-plane operations go through the **Inspect surface only** +(`updateTopology`, collector reads, `addDevices`/`syncDevices`) — never through +`PATCH nGraphElements` +([ADR-006](../inspect-app/decisions/006-collector-only-endpoints.md)). The +legacy Topology path remains available solely via the `app.topology` escape +hatch. + +## 10. The configuration / write interface + +Make the **change set the unit of work** — this is genuinely how the server +behaves for topology/connection edits +([ADR-004](../inspect-app/decisions/004-commit-write-model.md)), so it is a +semantic to **expose**, not hide. Adopt ADR-004 option 3 (explicit change set + +convenience auto-commit), with a context manager as the ergonomic default. +Proposed shape: + +```python +# Batched, atomic — the primary path +with app.network.change_set() as cs: + cs.place(device_id, at=Point(x, y)) + conn = cs.connect(port_a, port_b, bandwidth=Gbps(10), redundancy=Redundancy.ANY) + cs.remove(old_connection_id) + result = cs.validate() # client-side conflict check (ADR-007); server validation runs at commit + if result.ok: + cs.commit() # conflict re-check → one updateTopology POST → targeted refresh (ADR-008) +# on exception → auto-discard; on clean exit without commit → configurable + +# Convenience — single change auto-commits +app.network.connect(port_a, port_b, bandwidth=Gbps(10)) +``` + +What the domain layer owns (and the ACL translates): + +- **Commit ≠ HTTP success.** A captured failed delete returned `header.ok: true` + but `data.res.ok: false` / `data.validation.result.ok: false` (ADR-004). + This must surface as a typed `CommitResult` / `CommitFailed` carrying the + per-entity validation details (`status`, `rev`, `resolvable`, message) — + never a raw envelope. +- **Affected-services check** — already a precedent in + `TopologyApp.list_services_affected_by_device_update`. In the domain model it + becomes `change_set.validate()` returning structured impact. +- **Diffing stays internal.** Users describe *intent* (`connect`, `remove`); + the ACL computes the delta. The existing diff logic becomes an implementation + detail of staging. + +## 11. Writes & freshness across planes + +The Inspect-era findings hold and become **more pronounced** because three +planes are now in play. + +### 11.1 Writes span three concurrency models + +The three planes do **not** share a write/consistency model: + +| Plane | Write mechanism | Concurrency control | +| ----- | --------------- | ------------------- | +| Inventory | RPC `/api/updateDevices` | No revision/strict mode (RPC semantics) | +| Topology (escape hatch only, [ADR-006](../inspect-app/decisions/006-collector-only-endpoints.md)) | `PATCH nGraphElements` | `_rev` optimistic locking, `mode: strict` | +| Inspect | `updateTopology` action | Commit-time validation; **last-writer-wins** (verified 2025.4.9 — `_rev` ignored); client-side compare-and-commit ([ADR-007](../inspect-app/decisions/007-write-consistency.md)) | + +So a single domain write that touches multiple planes has **no single +transaction and no uniform conflict story**. The ACL must define, per +orchestrated operation: ordering, what happens on partial failure midway through +the sequence, and how each plane's conflict surfaces as **one** domain error. +Lean on the change-set/commit model for the topology/inspect portion; onboarding +(Inventory RPC) is a separate step that must be sequenced and compensated +explicitly if a later step fails. + +### 11.2 The collector carries no `_rev` + +Every collector `_items[]` entry has `_id` / `_vid` and **no `_rev`** — it is a +pure status-plane projection. The revisioned source of truth lives only in the +config plane (`nGraphElements`). Consequences: + +1. **Status changes move no token.** Alarms, `ptp` severity, `bandwidth`, + `syncSeverity` are not backed by any revision. There is **no cheap "did + anything change?" probe** for status — the only ways to learn current status + are to re-fetch the collector (whole or subtree) or subscribe via WebSocket + ([ADR-001](../inspect-app/decisions/001-api-paradigm.md)). +2. **Config-plane rev-polling is off the table anyway.** Polling + `nGraphElements .../id,rev` could catch "someone re-wired" (never "a + connection went into alarm"), but the package does not call that surface + ([ADR-006](../inspect-app/decisions/006-collector-only-endpoints.md)) — + and it would still miss the status changes that matter for monitoring. +3. **There is no write token at all on the Inspect surface.** The collector + read is rev-less and `updateTopology` ignores `_rev` (last-writer-wins, + verified 2025.4.9) — so revision-based optimistic writes are impossible, + not merely inconvenient. A domain object built from the collector **cannot + support an optimistic write**; concurrent-edit detection is the change + set's job via stage-time baselines + pre-commit compare + ([ADR-007](../inspect-app/decisions/007-write-consistency.md)). + +### 11.3 Freshness strategy — re-snapshot, not rev-diff + +The collector deliberately trades per-entity revisioning for a single +globally-consistent snapshot — excellent for **read correctness** (no torn +graph), poor for **incremental freshness**. Therefore: + +- The collector snapshot is **replaced wholesale on `refresh()`** — never + patched by diffing. Within its lifetime it *accretes*: skeleton-first, with + lazily hydrated subtrees merged in + ([ADR-005](../inspect-app/decisions/005-lazy-snapshot-loading.md)). +- `refresh()` means "fetch a new snapshot," not "diff revisions." Stamp each + entity/section with a **client-side fetch time** as the freshness marker, + since the server provides no token. +- Projection/filtering on collector sub-paths is **proven** (Inspect UI + WebSocket capture — see + [endpoints.md](../inspect-app/endpoints.md#collector-scoped-queries-captured-from-the-inspect-ui)): + scoped re-snapshots of a subtree are the freshness optimisation — still a + re-snapshot, not a diff. +- **After the package's own commits**, freshness is cheaper: the change set + knows what it touched, so the snapshot is updated by targeted invalidation + + scoped re-fetch instead of a full re-snapshot + ([ADR-008](../inspect-app/decisions/008-post-commit-snapshot-refresh.md)). +- This is the strongest argument for making **WebSocket the real freshness + channel for status**, while config edits keep the rev-based path + ([ADR-001](../inspect-app/decisions/001-api-paradigm.md), + [ADR-005](../inspect-app/decisions/005-lazy-snapshot-loading.md)). + +### 11.4 Separate the read snapshot from the write handle + +Because the snapshot has no revisions (and the write path enforces none), a +domain mutation (`connect`, `remove`) must not pretend a read object can +optimistically write. The clean split: + +- **Read snapshot** — rev-less, replace wholesale on refresh; accretes lazily + hydrated detail within its lifetime (ADR-005); after own commits it is + updated by targeted scoped re-reads + ([ADR-008](../inspect-app/decisions/008-post-commit-snapshot-refresh.md)). +- **Write handle / change set** — fetches stage-time baselines via + Inspect-surface lookups, re-checks them immediately before the + `updateTopology` POST, and owns the commit/validate/discard/conflict + lifecycle ([ADR-007](../inspect-app/decisions/007-write-consistency.md)). + +## 12. Limits of the abstraction + +A package-wide abstraction hits boundaries that a thin rename cannot erase. Name +them explicitly so the abstraction stays trustworthy: + +- **Driver selection is irreducible.** A device *is* a specific driver with a + specific capability/custom-settings schema (NMOS port, "indices in IDs", SNMP + config, …). The package even **generates per-driver models**. The domain can + unify the *lifecycle, identity, connection, and status model*, but + `configure(...)` of driver-specific settings is inherently driver-shaped. + Proposed: a generic `Device.settings` entry point typed by driver, surfaced via + the existing per-driver model generation — abstracted *entry point*, not + abstracted *schema*. +- **Onboarding inputs are real.** Address(es), credentials, and driver choice + must be supplied at `add_device`; they cannot be inferred. +- **Virtual vs. physical devices** differ (virtual id allocation, no driver + contact). The lifecycle must accommodate both. +- **Capabilities come from the driver**, not the user. Ports/vertices are + largely driver-defined; the user configures and connects them but does not + invent them. +- **The escape hatch must stay first-class.** Power users will need raw + `nGraphElement` / `InventoryDevice` access; the low-level apps remain public + and documented for exactly this. + +The honest framing: **unify the lifecycle, identity, connection, and status +model; do not pretend driver-specific configuration is uniform.** + +## 13. Hide vs. expose — the key design ledger + +The single most important artifact of this approach. Proposed starting point +(to be ratified as an ADR): + +| Concept | Decision | Rationale | +| ------- | -------- | --------- | +| VIP app/namespace split (inventory/topology/collector planes) | **Hide** | The whole point of the abstraction | +| Edge pairing (two unidirectional edges → one `Connection`) | **Hide** | Server already groups via `externalEdgesByDeviceKey` | +| Id/key formats (`.dev.` infix, `a::b`, `topo:`/`device:` prefixes) | **Hide** behind opaque id value objects | Pure mechanical detail; error-prone in user code | +| Internal fan-out edges (`capacity: 1`) | **Hide** | Implementation detail of a device's internal wiring | +| Device lifecycle (onboard → place → connect → monitor) | **Expose** as `LifecycleState` | Users must reason about *where* a device is, not *which app* | +| Change set / commit | **Expose** (first-class) | Genuine server semantic; enables atomic multi-edits | +| Commit validation result & affected services | **Expose** (typed result) | `header.ok` lies; users must see real outcome | +| Concurrent-write conflicts | **Expose** as explicit conflict check + typed conflict error ([ADR-007](../inspect-app/decisions/007-write-consistency.md)) | No rev token exists on the Inspect surface (`updateTopology` is last-writer-wins); pretending otherwise would fake a guarantee | +| Multi-dimensional status (`alarm`/`bandwidth`/`maintenance`/`ptp`, `sa`/`severity`) | **Expose** (preserve dimensions + provide rollup) | Lossy to collapse; monitoring users need the detail | +| Snapshot freshness (no `_rev`) | **Expose** via explicit `refresh()` + fetch-time stamp | Honest about the lack of a server token | +| Lazy hydration on property access ([ADR-005](../inspect-app/decisions/005-lazy-snapshot-loading.md)) | **Expose** (documented behaviour) | Getters may perform one fetch and raise connector errors; hiding it would misrepresent cost and failure modes | +| Driver-specific custom settings | **Cannot hide** — abstract the entry point only | A device *is* its driver schema (§12) | + +## 14. Migration & coexistence + +The change can be **additive and non-breaking**: + +1. **Build `app.network` on top of the existing apps.** No removal; the domain + facade calls the current `inventory` / `topology` / `inspect` APIs + internally. Reuse the existing wire models as the ACL's persisted form — e.g. + domain `Connection` → ACL → existing `UnidirectionalEdge` → `updateTopology` + (one wire model, not two). +2. **Ship read-first.** `app.network.get/list_*` over the `collector` snapshot + (+ inventory supplements), returning unified `Device` / `Connection` objects. +3. **Add lifecycle writes incrementally** — `add_device` (onboard [+ place]), + `place`, `connect`/`disconnect`, `configure` — each backed by the + orchestration table (§9) and the change-set commit model. +4. **Keep the low-level apps public** as the escape hatch; document them as such. +5. **Optionally** add async + WebSocket per the existing ADRs once the sync + surface settles. + +Replacing the existing apps outright (a breaking, single-surface package) is the +alternative to the additive approach — see §15. + +## 15. Open decisions + +| Decision | Options | Note | +| -------- | ------- | ---- | +| Anti-corruption layer vs. transparent client | ACL (this doc) **vs.** keep mirroring VIP | The overarching decision; ratify as an ADR | +| Migration style | **Additive `app.network` facade** vs. breaking replacement of the apps | Recommend additive; lowest risk | +| Entry-point name | `app.network` · `app.fabric` · `app` (top-level) | Neutral, non-vendor name preferred | +| Driver-config abstraction | Generic `settings` entry point with per-driver typed schema **vs.** explicit per-driver objects | §12: entry point can unify; schema cannot | +| Multi-plane write semantics | Best-effort sequence + compensation **vs.** topology/inspect change-set only, inventory separate | §11.1; define partial-failure behaviour | +| Lifecycle model surface | Explicit `LifecycleState` enum **vs.** implicit (methods just work) | §5; explicit aids reasoning & errors | +| Completeness bar for v1 | Full coverage **vs.** 80% happy path + escape hatch for the rest | Recommend the latter; lowest cost | +| Hide-vs-expose ledger (§13) | Ratify explicitly | The most consequential artifact | +| Snapshot vs. WS for status freshness | re-snapshot now, WS as the real channel | §11.3; ties into ADR-001 / ADR-005 | +| Read-snapshot ↔ write-handle bridge | ~~How the change set resolves `_rev` at commit~~ **Decided**: stage-time baselines + pre-commit compare ([ADR-007](../inspect-app/decisions/007-write-consistency.md)); post-commit targeted refresh ([ADR-008](../inspect-app/decisions/008-post-commit-snapshot-refresh.md)) | §11.4 | +| Relationship to existing docs | Supersede `python-module-architecture.md` design goal **vs.** coexist as "current vs. target" | Currently coexists as the target picture | + +## 16. Field-mapping reference (wire → domain) + +Indicative mapping from the captured `collector` payload (and Inventory, for +onboarding fields) to the proposed domain types. To be completed during +discovery and turned into fixtures. + +| Domain field | Source | Notes | +| ------------ | ------ | ----- | +| `Device.id` | `nodeStatus._items[]._id` | e.g. `device0` | +| `Device.label` | `nodeStatus[].descriptor.label` | fallback `fDescriptor` if empty | +| `Device.driver` / `Device.connection` | Inventory `config/devman/devices` | onboarding fields, not in collector | +| `Device.state` | derived | from presence across inventory / topology / collector (§5) | +| `Device.position` | `nodeStatus[].meta.coordinates.{x,y}` | float; hides `maps[]`/`inspect_app_format` | +| `Device.sync` | `nodeStatus[].syncSeverity` | map severity → `SyncState` | +| `Device.status` | `nodeStatus[].status.{sa,severity}` + `ptpDeviceStatus` (+ inventory status) | multi-dimensional | +| `Port.id` | module/port key `device0.dev.1.10` | translate to vertex id for edges | +| `Port.label` | `ports.*.descriptor.label` | | +| `Port.direction` | `ports.*.vertexInfo.type/vertexType` | `single`+`In/Out` or `double` → `BIDIRECTIONAL` | +| `Port.is_endpoint` | `ports.*.vertexInfo.fields.isEndpoint` | | +| `Port.status` | `ports.*.status` + `ptpPortStatus` | | +| `Connection.id` | `externalEdgesByDeviceKey[]._id` | `device0::device1` | +| `Connection.forward` | `…primary.data[edgeKey].{bandwidth,status,fromStatus,toStatus}` | | +| `Connection.reverse` | `…secondary.data[edgeKey].{…}` | may differ from forward | +| `Connection.status` | `externalEdgesByDeviceKey[].status` | `{alarm,bandwidth,maintenance,ptp}` | +| `Service.id` | `inspect.paths._items[]._id` | `100001::main` | +| `Service.label` | `…serviceFields.generic.descriptor.label` | | +| `Service.endpoints` | `…serviceFields.{from,fromLabel}` / `{to,toLabel}` | `topo:` refs | +| `Service.status` | `…serviceFields.serviceStatus.{config,total}` | | +| `Service.is_main` | `…serviceFields.isMain` | | + +## 17. Relationship to existing documents + +- [`python-module-architecture.md`](../python-module-architecture.md) — describes + the **current** mirror-VIP architecture. This document proposes the **target**. +- [`inspect-app/`](../inspect-app/README.md) — `concepts.md` (the VIP wire-format + research, including the `collector` aggregate and `nGraphElements` store) and + the ADRs (API paradigm, loading, WebSocket, async, testing, commit model). The + decisions there apply package-wide and are referenced throughout this document. diff --git a/docs/architecture/inspect-app/README.md b/docs/architecture/inspect-app/README.md new file mode 100644 index 0000000..42cb29a --- /dev/null +++ b/docs/architecture/inspect-app/README.md @@ -0,0 +1,45 @@ +# Inspect App — Architecture + +Design record for the VideoIPath **Inspect** app in this package +(`src/videoipath_automation_tool/apps/inspect/`). + +In the VideoIPath product, Inspect replaces the Topology app for building +topologies and connecting devices, and adds service monitoring. It does **not** +replace **Inventory**: devices are still onboarded in Inventory first, then +placed and connected in Inspect. Writes use a **commit-style** model — create / +edit / delete actions are gathered into a change set and committed together. + +In this package, `app.inspect` replaces `app.topology`: `TopologyApp` emits a +deprecation warning on VideoIPath 2025.x and raises on 2026.x+. +`app.inventory` remains unchanged. Offline unit tests live under +`tests/inspect/`; live E2E under `tests/e2e/inspect/`. For usage, see the +[Inspect getting-started page](../../getting-started-guide/03_B_Inspect.md). + +## Reading order + +1. **[concepts.md](./concepts.md)** — what Inspect is, the collector facade, + domain model (including the Inspect-vs-Topology tagging split), and how it + maps onto the package. +2. **[models.md](./models.md)** — transport `InspectApi*` DTOs, `InspectSnapshot`, + and user-facing domain objects (`InspectDevice`, `InspectPort`, …). +3. **[endpoints.md](./endpoints.md)** — anonymized endpoint reference with + concrete request/response shapes (verified on VideoIPath 2025.4.9). +4. **[decisions/](./decisions/)** — architecture decisions. Start with + [the index](./decisions/README.md). + +> **Wider context:** a package-wide re-think that grew out of this work — one +> unified `Device` / `Connection` domain model — lives in +> [`../future/unified-domain-architecture.md`](../future/unified-domain-architecture.md). + +## Decision log + +| Question | Decision | Status | +| -------- | -------- | ------ | +| Data-driven vs. event/action-driven API? | [ADR-001](./decisions/001-api-paradigm.md) | Accepted | +| Make the package async-ready? | [ADR-002](./decisions/002-async-strategy.md) | Accepted | +| How to test E2E? | [ADR-003](./decisions/003-e2e-testing.md) | Accepted | +| How are config writes applied? | [ADR-004](./decisions/004-commit-write-model.md) | Accepted | +| Always sync/load vs. lazy load vs. cached state? | [ADR-005](./decisions/005-lazy-snapshot-loading.md) | Accepted | +| Which API surface may the package call? | [ADR-006](./decisions/006-collector-only-endpoints.md) | Accepted | +| How are concurrent writes detected? | [ADR-007](./decisions/007-write-consistency.md) | Accepted | +| How does the snapshot catch up after a commit? | [ADR-008](./decisions/008-post-commit-snapshot-refresh.md) | Accepted | diff --git a/docs/architecture/inspect-app/concepts.md b/docs/architecture/inspect-app/concepts.md new file mode 100644 index 0000000..00b5694 --- /dev/null +++ b/docs/architecture/inspect-app/concepts.md @@ -0,0 +1,336 @@ +# Inspect App — Concepts & Technical Model + +Design record for the shipped Inspect app. Wire shapes and endpoint details live +in [endpoints.md](./endpoints.md); the official +[VideoIPath Public API 2025 LTS](https://documenter.getpostman.com/view/11222813/2sBXihpCS8#intro) +is a secondary reference for the documented surface. + +## 1. What Inspect is + +Nevion describes Inspect as an *"advanced monitoring application that allows +the operator to perform high-level monitoring of services combined with the +ability to drill-down and inspect details to pinpoint service-affecting +problems."* + +In recent VideoIPath releases the Inspect app **replaces the Topology app** in +the product UI: it becomes the entry point for building the network connectivity +model (vertices, edges, device placement), connecting devices, and watching +operational status. The server exposes live update capabilities, but this +package stays request/response only (see +[ADR-001](./decisions/001-api-paradigm.md)). Inspect applies configuration +changes with a **commit-style** model: create/edit/delete actions are gathered +into a client-side change set and committed together (see +[ADR-004](./decisions/004-commit-write-model.md)). + +Inspect does **not** replace the **Inventory** app. Devices are still onboarded +in Inventory first; only then can they be placed and connected in Inspect. + +In **this package**, `app.inspect` replaces `app.topology`: `TopologyApp` is +deprecated on VideoIPath 2025.x and unsupported on 2026.x+. `app.inventory` +remains unchanged and required for device onboarding. + +## 2. Architecture: the `collector` facade + +Inspect is built around a server-side **`collector` facade** — a distinct REST +v2 API surface under the `status` namespace. The server composes reads from +(and applies writes to) the underlying VideoIPath data store; the **API +contract is mostly net-new** relative to what `app.topology` and +`app.inventory` use today. + +**Reads** — scoped queries against the collector tree +([ADR-005](./decisions/005-lazy-snapshot-loading.md)): + +- The collector sub-paths accept `* where ` filters, `limit N`, and deep + field projections (see + [endpoints.md — Collector Scoped Queries](./endpoints.md#collector-scoped-queries-captured-from-the-inspect-ui)). +- Default loading model: a **skeleton** read (all devices without modules/ports + + all edges with a lean projection) followed by **lazy per-device hydration** + and section-level loads for services. +- `GET /rest/v2/data/status/collector/**` → `data.status.collector` (§3.1) + remains the eager/fallback mode: the whole tree in a single response with + `_items[]` collections. + +**Writes** — one bulk action per commit: + +- `POST /rest/v2/actions/status/collector/updateTopology` + ([ADR-004](./decisions/004-commit-write-model.md)) +- Sends a client-assembled delta: `replaceDevices`, `replaceVertices`, + `replaceEdges`, `replaceResourceTransforms`, `addExternalEdges`, `remove`, + `force`. +- Validation runs at commit time; success/failure is determined by + `data.res.ok` / `data.validation.result.ok`, not `header.ok`. + +**Namespace** — the `collector` API entry points live under `status` +(`data/status/collector` for reads, `actions/status/collector` for writes), but +the **underlying store is the existing config plane**: `updateTopology` mutations +land in `config/network/nGraphElements`. The collector is a **facade**: a +status-namespace read/action surface in front of the revisioned `nGraphElements` +config store (§3.3). + +**Shared with existing apps** — the underlying store and wire conventions, not +model classes: + +- The config store `nGraphElements` is **the same one `app.topology` already + reads and models**. Inspect edits land there, revisioned with `_rev` (§3.3), + but the Inspect package keeps its own `InspectApi*` DTOs and does **not** import + or subclass topology/inventory model classes. +- REST v2 envelope, session/XSRF auth, pid/id formats +- Vertex ids (`device-a.module-1.port-out-1.out`), edge keys (`fromId::toId`) +- `descriptor` / `fDescriptor` objects, `sa` / `severity` status semantics +- Device positions as float coordinates — `meta.coordinates` in the collector + aggregate, `maps[].x/y` in `nGraphElements` + +**Net-new for `app.inspect`:** + +| Layer | Responsibility | +| ----- | -------------- | +| Collector read/parse | Parse `data.status.collector` with `InspectApi*` transport DTOs, then expose user-facing `InspectDevice` / `InspectService` objects via `InspectSnapshot` | +| Change-set / commit write | Assemble `updateTopology` payloads with `InspectApi*` DTOs, handle validation responses | +| Lookup / network actions | Model lookup, add-device, and sync-device action envelopes with `InspectApi*` DTOs | + +Inventory onboarding stays on the existing path (`config/devman/devices`, +`/api/updateDevices`). `app.topology` and `app.inventory` remain unchanged; +`app.inspect` is additive. + +## 3. Domain model + +| Inspect concept | Inspect API (collector) | Existing model / app | +| ---------------------- | ------------------------------------------------------ | --------------------------------------------- | +| Device (inventory) | Prerequisite — not part of collector; onboard via `config/devman/devices` | `InventoryDevice` (`apps/inventory`) | +| Device (topology node) | Read: `collector.inspect.nodeStatus`; stored as `baseDevice` in `nGraphElements` | Store shape overlaps with Topology, but Inspect uses `InspectApiBaseDevice` | +| Vertices / Edges | Read: `nodeStatus` `vertexInfo` / `externalEdgesByDeviceKey`; stored as `ipVertex` / `codecVertex` / `unidirectionalEdge` in `nGraphElements` | Store shape overlaps with Topology, but Inspect uses `InspectApi*` nGraph DTOs | +| Vertex tags | Read: per-port `tagsInfo` on hydrated `nodeStatus`; editable form via `lookupInspectVertexByIds` (`assignedTags`, `fields.tags`, `fields.localAssignedTags`) | Legacy `tags` in `nGraphElements` are separate from the new framework; they are not synchronized or migrated. New-framework bindings live server-side in `videoipath_docs.device_tags`, not the `ngraph` table (§3.4) | +| Change set / commit | `POST …/actions/status/collector/updateTopology` → writes `nGraphElements` ([ADR-004](./decisions/004-commit-write-model.md)) | _commit flow net-new; target store is existing `nGraphElements`_ | +| Services / paths | `collector.inspect.paths`, `pathDescriptions` on nodes/edges | _none — net-new_ | +| Device / edge status | Embedded in collector (`status`, `sa`/`severity`, bandwidth, PTP) | `inventory.model.device_status`, `status/network/*` — partial overlap | +| Sync status | `syncSeverity` on nodeStatus items | `TopologySynchronize` via `status/network/nGraphSyncStatus` | +| Lookup / network actions | `lookupInspectDevice`, `lookupInspectEdgesByIds`, `lookupInspectVertexByIds`, `lookupSyncInfo`, `addDevices`, `syncDevices` | request/response envelopes modelled | +| Connections / Partial connections | `collector.inspect.paths` + `conman.services`; linked via `serviceFields.bid` / `bookingId` in `pathDescriptions` ([endpoints.md](./endpoints.md#get-restv2datastatuscollectorinspectpaths)) | _read via collector + conman; no separate Connections REST on this instance_ | + +### 3.1 Collector aggregate — primary read surface + +| Item | Value | +| ---- | ----- | +| Method / path | `GET /rest/v2/data/status/collector/**` | +| Root | `data.status.collector` | +| List shape | Collections use `_items[]` entries with `_id` / `_vid` | + +Top-level sections under `data.status.collector`: + +| Section | Purpose | +| ------- | ------- | +| `inspect.nodeStatus` | Topology nodes: devices → modules → ports, with live status, `meta.coordinates`, `vertexInfo`, and embedded `pathDescriptions` | +| `inspect.paths` | Service/path list: booking segments, endpoint labels, aggregated `serviceFields` | +| `externalEdgesByDeviceKey` | Inter-device link status grouped by device pair; `primary` / `secondary` each hold edges keyed by `"fromId::toId"` | +| `maintenanceBookings` | Maintenance bookings | +| `security` | Security context for devices, profiles, matrices, … | +| `superProfiles` | Routing profiles | +| `tagInfo` | Tag → profile mappings | + +**ID conventions** (consistent across read and write): + +| Concept | Example | Notes | +| ------- | ------- | ----- | +| Device | `device-a` | `deviceId`, `pid`, `_id` on nodeStatus items | +| Module pid | `device-a.dev.module-1` | Nested under `modules` | +| Port pid | `device-a.dev.module-1.port-out-1` | Nested under `ports` | +| Vertex id | `device-a.module-1.port-out-1.out` | Shorter form in `vertexInfo`; used in edge keys | +| Edge id | `device-a.module-1.port-out-1.out::device-b.module-1.port-in-1.in` | Same key as `replaceEdges` in `updateTopology` | +| Device pair | `device-a::device-b` | Key for `externalEdgesByDeviceKey` items | +| Service / path | `booking-1001::main` | `bookingId` + path role; appears in `pathDescriptions` and `inspect.paths` | +| Resource id | `device:device-a.dev.module-1.port-out-1` | Prefixed resource references | +| Topo endpoint ref | `topo:device-a.module-1.port-out-1` | Used in `serviceFields.from` / `.to` | + +**`vertexInfo`** on ports describes topology vertices: + +- `type: "single"` — one vertex (`id`, `vertexType`: `"In"` / `"Out"`, `fields`: + `isActive`, `isControlled`, `isEndpoint`) +- `type: "double"` — bidirectional port with separate `in` / `out` ids and labels + +**Drill-down / service linkage:** `pathDescriptions` on ports and edges embed +both `deviceLevel` (local hop: input → output within a device) and +`serviceLevel` (end-to-end service: `bookingId`, `serviceLabel`, `fromStatus` / +`toStatus`, `isMain`, `serviceStatus`). + +**Edge live status** (`externalEdgesByDeviceKey`): each edge carries +`bandwidth`, `fromStatus` / `toStatus`, `pathDescriptions`, and aggregate +`status` (`alarm`, `bandwidth`, `maintenance`, `ptp` severities). + +**Node live status** (`inspect.nodeStatus`): hierarchical `status` with +`sa` / `severity` at device, module, port; plus `ptpDeviceStatus`, `syncSeverity`, +`hasEndpoints`, domains, and tags. Device-level tags appear on the node itself +(`tags`, `meta.tags`, `tagsInfo`); **vertex-level tag bindings** appear on +hydrated ports (`tagsInfo` with `assigned` / `inherited` / `local` / `custom` +subtrees — see §3.4). The skeleton projection only carries device-level tagging; +port `tagsInfo` requires per-device hydration (`modules/*`). + +**Package implications:** + +- `app.inspect` reads the collector through **scoped queries** + ([ADR-005](./decisions/005-lazy-snapshot-loading.md)): skeleton first + (`inspect/nodeStatus` with `modules/"_noId"`, `externalEdgesByDeviceKey` + with a lean projection), then per-device hydration (`modules/*` detail + projection) and section-level loads (`inspect/paths`). The full + `GET …/data/status/collector/**` fetch is the eager/fallback mode. +- The collector query language: `* where ` (with `and`/`or`, + `contains()`, `lower()`), `limit N`, field projections with `/.../` + up-navigation, `**` subtrees, and the `"_noId"` expansion-suppressor (see + [endpoints.md](./endpoints.md#collector-scoped-queries-captured-from-the-inspect-ui)). +- Edge keys and vertex ids from reads map directly onto `updateTopology` write + payloads. +- Scoped collector queries work as REST GETs when the URL fits within the server + URI limit. The full UI projection hits **HTTP 414**; use a trimmed skeleton + projection or `/**` fallback. Both `nodeStatus//…` and + `* where deviceId='…' limit 1/…` work for single-device hydration. Omitting + `where` does **not** require `limit`. + +### 3.2 API planes — existing apps vs. Inspect + +The VideoIPath backend separates **config** (mutable, revisioned) and +**status** (read-only, subscription-friendly). Inspect's `collector` API entry +points sit under `status`, but its topology edits resolve to the **config** +plane (`nGraphElements`). Network action endpoints under +`actions/status/network/*` are also relevant for device add/sync workflows: + +| Plane | Used by | Read | Write | +| ----- | ------- | ---- | ----- | +| Config | `app.topology`, `app.inventory`, **Inspect (effective store)** | `GET …/data/config/…` | `PATCH …/data/config/…` (revisioned) or RPC | +| Status | Inventory status reads, Inspect status reads | `GET …/data/status/…` | — | +| Collector (facade) | `app.inspect` | Scoped queries on `…/data/status/collector/…` (skeleton + hydration, ADR-005); `GET …/**` as eager/fallback | `POST …/actions/status/collector/updateTopology` → `nGraphElements` | +| Network actions | `app.inspect` device topology workflows | `GET …/data/status/network/virtualDevices/**`, `…/virtualTemplates/**` | `POST …/actions/status/network/addDevices`, `…/syncDevices`, `…/updateVirtualInstances` (**create** virtual devices), `…/updateVirtualTemplates`, `…/addVirtualTopology`. After create, virtual devices use the same `updateTopology` / write methods as physical devices (`InspectDevice.is_virtual`). | + +So Inspect's read aggregate is status-namespace and net-new in shape, but its +writes are commit-time-validated bulk actions that land in the revisioned +`config/network/nGraphElements` store (§3.3). Staging is client-side until +commit; there is no separate server-side change-set id for the verified +`updateTopology` flow (ADR-004). + +**Endpoint policy** ([ADR-006](./decisions/006-collector-only-endpoints.md)): +the Inspect package calls **only** the Inspect surface — collector data reads, +collector actions, and the network actions (`addDevices`, `syncDevices`, +virtual-device / port-template actions). The config-plane row above is context, +not a call path: the package never issues +`GET`/`PATCH …/config/network/nGraphElements` (that stays `app.topology`'s +surface). Consequence: no `_rev` is available to Inspect, and since +`updateTopology` ignores revisions anyway (last-writer-wins, verified), +concurrent-write detection is client-side compare-and-commit +([ADR-007](./decisions/007-write-consistency.md)); after a commit the +snapshot catches up via targeted scoped re-reads +([ADR-008](./decisions/008-post-commit-snapshot-refresh.md)). + +Fresh status is obtained by explicit re-fetches +([ADR-001](./decisions/001-api-paradigm.md)). WebSocket subscriptions are out +of scope for this package. + +### 3.3 Config store — `nGraphElements` (write target) + +`GET /rest/v2/data/config/network/nGraphElements/**` → +`data.config.network.nGraphElements._items[]`. This is the **revisioned source +of truth** for topology that Inspect's `updateTopology` writes into. Its wire +shape overlaps with the Topology app, but the Inspect package models it with +standalone `InspectApi*` DTOs. + +> Documented here as store/background knowledge only — the package does **not** +> read or write this endpoint at runtime +> ([ADR-006](./decisions/006-collector-only-endpoints.md)). The persisted +> element *shape* still matters: `updateTopology` `replace*` payloads carry it. + +| Field | Notes | +| ----- | ----- | +| `_id` / `_vid` | Element id; edges use the `fromId::toId` key (same as collector and `replaceEdges`) | +| `_rev` | CouchDB-style revision `N-` for optimistic concurrency | +| `type` | Element kind (see below) | +| `descriptor` / `fDescriptor` | User label/desc vs. fallback (device-reported) label/desc | + +Element `type` values: + +| `type` | Represents | Key fields | +| ------ | ---------- | ---------- | +| `baseDevice` | Topology device node | `maps[]` (`cType: "Topology"`, integer `x`/`y`), `iconType`, `sdpStrategy`, `isVirtual`, legacy `tags` | +| `ipVertex` | Ethernet/IP port vertex (`.in` / `.out`) | `vertexType`, `gpid.pointId`, `supports*Cfg` capability flags, legacy `tags` — separate from new-framework tag bindings (§3.4) | +| `codecVertex` | Codec/SDI endpoint vertex | `vertexType` (`In`/`Out`), `codecFormat`, `useAsEndpoint`, `control`, SIPS/SDP fields, legacy `tags` — separate from new-framework tag bindings (§3.4) | +| `unidirectionalEdge` | Directed link/route between vertices | `fromId`, `toId`, `weight`, `capacity`, `bandwidth`, `redundancyMode`, `weightFactors`, `conflictPri`, legacy `tags` | + +**Implication:** Inspect's underlying topology store is `nGraphElements`, but +the package keeps a separate Inspect model namespace (`InspectApiBaseDevice`, +`InspectApiIpVertex`, `InspectApiUnidirectionalEdge`, …). Do not reuse topology +app model classes in Inspect DTOs. `updateTopology` is **last-writer-wins** — a +stale `_rev` in the payload is ignored +([endpoints.md](./endpoints.md#post-restv2actionsstatuscollectorupdatetopology)). + +### 3.4 Tagging — device vs. vertex vs. module (Inspect vs. Topology) + +The new Tags framework (introduced in VideoIPath 2025.3) is centrally managed +in **Settings** under **Tags and Filters**. Its hierarchical **Location**, +**Format**, and **General** categories support tag inheritance, unlike the +flat, local `tags` lists on elements in the legacy Topology app. + +Upgrading to VideoIPath 2025.3+ performs a one-time migration of supported +legacy tags (see Admin Guide). + +From VideoIPath 2025 LTS onward, only new-framework tags are considered across +apps and for UI filtering. They must be linked to devices, modules, and +vertices in the Inspect app. Legacy Topology tags remain local; there is no +runtime synchronization or reconciliation with new-framework bindings. + +The framework supports tags on profiles, devices, modules, vertices, junctions, +and endpoint groups. A tag on a device is inherited by its modules and vertices; a +module tag is inherited by its vertices; and an endpoint-group tag is inherited +by its endpoints. A child tag also implicitly applies its parent tags for +filtering. Format tags can additionally be inherited by vertices whose codec +format is linked to the tag. + +| Scope | What is tagged | Topology / `nGraphElements` | Inspect read surface | Write path | +| ----- | -------------- | --------------------------- | -------------------- | ---------- | +| **Device** | Topology node (`baseDevice`) | Legacy `tags` on the `baseDevice` item | `nodeStatus` `tags` / `meta.tags` / `tagsInfo`; `lookupInspectDevice` | `updateTopology` `replaceDevices` | +| **Vertex** | Individual port vertex (`ipVertex`, `codecVertex`, …) | Legacy `tags`; no new-framework tag binding | Hydrated port `tagsInfo`; editable form in `lookupInspectVertexByIds` | `updateTopology` `replaceVertices` (`localAssignedTags`) | +| **Module** | Device module / slot | Not an `nGraphElements` item; legacy and new-framework tags are separate | Hydrated module `tagsInfo` on `nodeStatus` | `assignTag` / `unassignTag` with `elementIds: ["device:{modulePid}"]` | + +**Implications for the package:** + +- `app.topology` reads/writes legacy per-element tags through + `nGraphElements`. It has no API for new-framework tag bindings to a vertex id + such as `device-a.module-1.port-out-1.out`, nor to module resource ids such + as `device:device-a.dev.0`; the package must not derive those bindings from + legacy tags. +- `app.inspect` must treat vertex tags as a **separate concern** from the + persisted graph element shape. Do not assume a vertex's `tags` array in an + `nGraphElements` `ipVertex` / `codecVertex` item (if present at all) is the + source of truth for tag bindings — confirmed empty in captures while + `lookupInspectVertexByIds` carries `assignedTags` and `fields.tags`. +- Stage-time baselines and compare-and-commit for vertex edits must use + `lookupInspectVertexByIds` for tag fields ([ADR-007](./decisions/007-write-consistency.md)), + not `nGraphElements` or the collector skeleton. +- Module tags are **not** written via `updateTopology`. The Inspect UI uses + `POST …/actions/status/tags/assignTag` and `…/unassignTag` with a single + `tagId` plus `elementIds`. The package diffs the desired local tag list + against `tagsInfo.assigned.local` and issues one call per added/removed tag. + These RPCs are separate from the topology commit (not one atomic server + transaction). +- Collector `tagInfo` provides tag → profile metadata for the aggregate; it does + not replace per-vertex `assignedTags` on the lookup response. + +## 4. How the transport works + +- `connector/` is a thin sync HTTP client built on `requests`, with two + sub-connectors: REST v2 (`GET`/`PATCH`/`POST`) and RPC (`POST /api/*`). Basic + auth, gzip, per-method timeouts. +- Each connector enforces an **allow-list of URL prefixes** (`ALLOWED_URLS` / + `ALLOWED_EXACT_MATCHES`). New Inspect endpoints must be added there — but + only Inspect-surface prefixes; `config/network/nGraphElements` is not added + for the Inspect app ([ADR-006](./decisions/006-collector-only-endpoints.md)). +- Responses are wrapped in a common envelope (`ResponseV2Get`/`…Patch`/`…Post`) + with a `header` (`code`, `auth`, …) and a `data`/`result` body, validated by + Pydantic. +- Apps are **lazy-loaded** off `VideoIPathApp` and are **stateless**: every call + re-fetches from the server (e.g. `topology.get_device` issues several `GET`s + and rebuilds the aggregate each time). Inspect deviates deliberately: state + is **snapshot-scoped** — a skeleton is loaded up front, detail is lazily + hydrated into the same snapshot, and freshness means building a new snapshot + ([ADR-005](./decisions/005-lazy-snapshot-loading.md)). There is still no + cache across snapshots. +- Inspect models live in two layers: + - `apps/inspect/model` — `InspectApi*` transport DTOs for HTTP payloads + - `apps/inspect/domain` and `apps/inspect/snapshot.py` — user-facing read + models backed by a collector snapshot and internal indexes +- App/API methods own fetching, staging, committing, and error handling. diff --git a/docs/architecture/inspect-app/decisions/001-api-paradigm.md b/docs/architecture/inspect-app/decisions/001-api-paradigm.md new file mode 100644 index 0000000..f375be9 --- /dev/null +++ b/docs/architecture/inspect-app/decisions/001-api-paradigm.md @@ -0,0 +1,23 @@ +# ADR-001: API paradigm — data-driven, request/response + +> Status: **Accepted** + +## Decision + +The package stays fully **data-driven and request/response**. Reads fetch +aggregates from the server; writes apply changes via explicit API calls. No +WebSocket subscriptions, no live update layer, no event-driven observation API. + +Status reads use the same request/response model as configuration CRUD. If +freshness is needed, the caller re-fetches explicitly. + +Primary consumers are deterministic pipeline automations — short-lived, scripted +runs that load state, apply changes, and exit. + +## Consequences + +- Consistent with existing apps and the automation/pipeline usage model. +- No WebSocket client, subscription machinery, or dual interaction styles. +- Live monitoring UX (as in the Inspect UI) is out of scope; automations get + predictable, reproducible runs instead. +- See [ADR-002](./002-async-strategy.md) for the related async decision. diff --git a/docs/architecture/inspect-app/decisions/002-async-strategy.md b/docs/architecture/inspect-app/decisions/002-async-strategy.md new file mode 100644 index 0000000..3a616d8 --- /dev/null +++ b/docs/architecture/inspect-app/decisions/002-async-strategy.md @@ -0,0 +1,23 @@ +# ADR-002: Async readiness & migration + +> Status: **Accepted** + +## Decision + +**Stay sync at the package boundary; use internal parallelism only where a +single action needs multiple API requests.** + +The public API remains synchronous — no `async`/`await` surface, no dual-stack +codegen, no migration to `httpx` async clients. When one high-level operation +(e.g. skeleton load of devices + edges, or bulk device preload) requires several +independent `GET`s, those requests may be issued **in parallel internally** +(e.g. thread pool). This is an implementation detail, not a new interaction +model for callers. + +## Consequences + +- Zero breaking change for existing sync users and scripts. +- No async test matrix, no `unasync` tooling, no sync-over-async footguns. +- Performance gains are limited to multi-request reads/writes inside the + library; callers do not manage concurrency themselves. +- A future async public API would require a new ADR; it is not planned now. diff --git a/docs/architecture/inspect-app/decisions/003-e2e-testing.md b/docs/architecture/inspect-app/decisions/003-e2e-testing.md new file mode 100644 index 0000000..967d381 --- /dev/null +++ b/docs/architecture/inspect-app/decisions/003-e2e-testing.md @@ -0,0 +1,25 @@ +# ADR-003: E2E testing strategy + +> Status: **Accepted** + +## Decision + +**Live server E2E only — developer-run, locally, against a real VideoIPath +instance.** Offline unit tests use anonymized fixtures under +`tests/inspect/fixtures/`. + +E2E tests use the Python package against a live server. Credentials and +connection details come from `.env` (see `.env.template`). No recorded HTTP +cassettes, no fake VideoIPath server. + +These tests are **not** required on every CI push; they are run locally when a +developer has an instance available (`poetry run test-e2e`). + +## Consequences + +- Highest confidence: tests exercise the real API, auth, and payload shapes. +- Simple setup: one E2E style, one configuration path, no cassette maintenance. +- Tests are stateful, environment-dependent, and slower — acceptable for the + current team size and Inspect scope. +- Mock/cassette/fake-server layers can be revisited via a new ADR if CI + automation or faster feedback loops become a priority. diff --git a/docs/architecture/inspect-app/decisions/004-commit-write-model.md b/docs/architecture/inspect-app/decisions/004-commit-write-model.md new file mode 100644 index 0000000..8a98b2c --- /dev/null +++ b/docs/architecture/inspect-app/decisions/004-commit-write-model.md @@ -0,0 +1,43 @@ +# ADR-004: Commit-style write model (change sets) + +> Status: **Accepted** +> +> Concurrency: [ADR-007](./007-write-consistency.md). Post-commit refresh: +> [ADR-008](./008-post-commit-snapshot-refresh.md). + +## Decision + +**Hybrid: explicit transaction via context manager, plus direct write +operations on the app.** + +- **Data-only DTOs** — models represent server payloads; no behaviour methods. +- **App-level direct writes** — `place_device` / `update_*` / `connect` / + `disconnect` / `remove_*` on the inspect app each open a single-change + transaction and commit immediately. +- **Optional transaction** — `with app.inspect.transaction() as tx:` stages + multiple actions; call `tx.commit()` explicitly. Exit without commit + discards. + +Staging is **client-side** until `POST …/updateTopology`. There is no separate +server-side change-set id. + +Wire facts (verified 2025.4.9): + +| Field | Shape | +| ----- | ----- | +| `replaceDevices` | `lookupInspectDevice.fields` (`coordinates`, `localAssignedTags` mandatory) | +| `replaceVertices` | `lookupInspectVertexById.fields` — **update-only** | +| `replaceEdges` | Raw persisted edge form, keyed `"fromId::toId"` | +| `remove` / `addExternalEdges` / `force` | id list / edge list / boolean | + +Commit success requires `header.ok and data.res.ok and data.validation.result.ok`. +Apply is reject-before-apply (all-or-nothing). `updateTopology` is +last-writer-wins (ignores `_rev`). + +## Consequences + +- Two usage styles, both mapping to the same `updateTopology` payload. +- Context manager: commit explicitly; exit without commit discards. +- DTOs stay portable; business logic lives in the app/transaction layer. +- Single-change scripts stay ergonomic; multi-element pipeline edits use the + transaction path for atomicity. diff --git a/docs/architecture/inspect-app/decisions/005-lazy-snapshot-loading.md b/docs/architecture/inspect-app/decisions/005-lazy-snapshot-loading.md new file mode 100644 index 0000000..270215d --- /dev/null +++ b/docs/architecture/inspect-app/decisions/005-lazy-snapshot-loading.md @@ -0,0 +1,39 @@ +# ADR-005: Skeleton-first snapshot loading with lazy hydration + +> Status: **Accepted** + +## Decision + +**Skeleton-first snapshot with transparent per-entity lazy hydration.** The +`InspectSnapshot` state is allowed to be partially populated and accretes as +details are fetched. There is **no client-side cache across snapshots** — +fresh data means building a new snapshot. + +- **Skeleton load.** Two parallel scoped GETs (queries in + [endpoints.md](../endpoints.md#collector-scoped-queries-captured-from-the-inspect-ui)): + - *Device skeleton*: `inspect/nodeStatus` with `modules/"_noId"` — identity, + descriptor, coordinates, status, syncSeverity, tags; no modules/ports. + - *Edge skeleton*: `externalEdgesByDeviceKey` lean projection — device pair, + edge ids, endpoint labels/context, status severities. +- **Per-entity hydration.** First access to an unloaded device property + (`ports`, …) fetches that device's `nodeStatus` subtree (`modules/*`). + Hydration is idempotent and cached. +- **Section-level lazy loads.** Services (`inspect/paths`) and alarms + (`status/alarms/current`) load as whole sections on first touch. +- **Eager mode.** `load="full"` → one `GET …/collector/**` for small + environments or point-in-time consistency. Fixture-built snapshots are fully + hydrated with lazy loading inert. + +## Consequences + +- **Hidden HTTP on property access.** Domain getters may perform one hydration + request and can therefore raise connector errors and add latency. Documented, + deliberate behaviour ([models.md](../models.md)). +- **No single point in time.** Skeleton and hydrated subtrees are fetched at + different moments; the snapshot records a fetch timestamp per entity/section. +- **N+1 for detail iteration.** Mitigation: bulk preload helpers + (`app.inspect.preload([...])`, `get_devices(detail=True)`) that parallelize + hydration ([ADR-002](./002-async-strategy.md)); the skeleton alone answers + most bulk questions. +- Scoped queries verified on 2025.4.9; the untrimmed UI projection hits + HTTP 414 — use a trimmed skeleton projection. diff --git a/docs/architecture/inspect-app/decisions/006-collector-only-endpoints.md b/docs/architecture/inspect-app/decisions/006-collector-only-endpoints.md new file mode 100644 index 0000000..1068016 --- /dev/null +++ b/docs/architecture/inspect-app/decisions/006-collector-only-endpoints.md @@ -0,0 +1,37 @@ +# ADR-006: Collector-only endpoint policy (no legacy topology API) + +> Status: **Accepted** + +## Decision + +**The Inspect package uses only the Inspect surface.** Allowed at runtime: + +| Kind | Endpoints | +| ---- | --------- | +| Data reads | `GET /rest/v2/data/status/collector/…` (scoped queries and `/**`) | +| Collector actions | `POST /rest/v2/actions/status/collector/*` (`updateTopology`, lookups, …) | +| Network actions | `POST /rest/v2/actions/status/network/{addDevices,syncDevices,updateVirtualInstances,updateVirtualTemplates,addVirtualTopology}` | +| Tag actions | `POST /rest/v2/actions/status/tags/{assignTag,unassignTag}` | +| Alarm reads | `GET /rest/v2/data/status/alarms/current/…` | +| Virtual reads | `GET /rest/v2/data/status/network/{virtualDevices,virtualTemplates}/**` | +| System probes | `GET /rest/v2/data/status/system/about/…` (version gating) | + +Explicitly **not called** by the package: + +- `GET`/`PATCH /rest/v2/data/config/network/nGraphElements/**` +- `GET /rest/v2/data/status/network/edgesByDevice/**` +- RPC topology calls + +These stay documented in [endpoints.md](../endpoints.md) as store documentation +only. `app.topology` remains the escape hatch for raw, revisioned +`nGraphElements` access. + +## Consequences + +- **No `_rev` is available** to the Inspect package, and the write path + enforces none (last-writer-wins). Write consistency is solved client-side — + [ADR-007](./007-write-consistency.md). +- Persisted forms for `replace*` payloads come from collector-namespace lookups + (`lookupInspectDevice`, `lookupInspectVertexByIds`, + `lookupInspectEdgesByIds`), not from `nGraphElements` reads. +- The connector URL allow-list for Inspect gains only Inspect-surface prefixes. diff --git a/docs/architecture/inspect-app/decisions/007-write-consistency.md b/docs/architecture/inspect-app/decisions/007-write-consistency.md new file mode 100644 index 0000000..992d765 --- /dev/null +++ b/docs/architecture/inspect-app/decisions/007-write-consistency.md @@ -0,0 +1,45 @@ +# ADR-007: Write consistency — client-side compare-and-commit + +> Status: **Accepted** — complements [ADR-004](./004-commit-write-model.md) +> under the [ADR-006](./006-collector-only-endpoints.md) endpoint policy + +## Decision + +**Compare-and-commit, built into the change-set lifecycle** (transaction and +direct-write paths both get it). + +1. **Baseline at staging time.** When an entity is first staged, the change set + fetches and stores its current form via Inspect-surface lookups (verified + 2025.4.9; none exposes a `_rev`): + - edges: `lookupInspectEdgesByIds` — full persisted edge form, batched + - vertices: `lookupInspectVertexByIds` — editable form (incl. tag bindings; + see [concepts.md §3.4](../concepts.md#34-tagging--device-vs-vertex-vs-module-inspect-vs-topology)) + - devices: `lookupInspectDevice` — editable form + + The lookup forms **are** the write shapes — no client-side mapping. + Caller mutations are applied on top of the baseline. + +2. **Pre-commit conflict check.** `commit()` re-fetches the same entities and + deep-compares against the baselines. Any mismatch aborts the whole commit + and raises `InspectCommitConflictError` (entity ids + field diffs). + +3. **Override is explicit.** `commit(check_conflicts=False)` skips the check — + deliberate last-writer-wins. The server's `force` flag is unrelated. + +4. **Commit result still rules.** Compare-and-commit runs *before* the POST; + `data.res.ok` / `data.validation.result.ok` evaluation after the POST is + unchanged (ADR-004). + +## Consequences + +- **Honest guarantee: detection, not enforcement.** The re-read→POST window + (TOCTOU) cannot be closed with the current server. Re-check per server + version whether `updateTopology` gains rev enforcement. +- One extra lookup round per stage and per commit — bounded by change-set size, + batchable via the `…ByIds` actions. +- Snapshot data is **not** used as the baseline; baselines always come from + fresh lookups at stage time. +- Lookups return the **effective** label (persisted `descriptor` merged with + `fDescriptor` fallback). Round-tripping without an explicit label change pins + the fallback into `descriptor` — don't touch label fields unless the caller + set them. diff --git a/docs/architecture/inspect-app/decisions/008-post-commit-snapshot-refresh.md b/docs/architecture/inspect-app/decisions/008-post-commit-snapshot-refresh.md new file mode 100644 index 0000000..88faa02 --- /dev/null +++ b/docs/architecture/inspect-app/decisions/008-post-commit-snapshot-refresh.md @@ -0,0 +1,42 @@ +# ADR-008: Post-commit snapshot maintenance — targeted invalidation + scoped re-fetch + +> Status: **Accepted** — extends [ADR-005](./005-lazy-snapshot-loading.md) +> to the write path ([ADR-004](./004-commit-write-model.md)) + +## Decision + +**Targeted invalidation + scoped re-fetch of affected entities**, with lazy +fallback for sections. After a successful commit: + +1. **Compute the affected set** from the change set and the commit response + `items[]`: devices (replaced/removed + owners of touched vertices/edges) and + edge pairs (`deviceA::deviceB`). +2. **Apply removes locally** — deleted entities leave the indexes immediately. +3. **Invalidate and eagerly re-fetch** remaining affected entities with the + existing scoped queries (`nodeStatus//…`, + `externalEdgesByDeviceKey//…`). Drop cached domain objects; + update per-entity fetch timestamps. +4. **Sections go stale, not eager** — services and alarms re-load lazily on next + access. +5. **No retry window** — on 2025.4.9 the collector projection updates + effectively synchronously with the commit (~25 ms to first-poll visibility). + The targeted re-fetch doubles as the verification read. + +A failed commit changes nothing server-side — the snapshot is left untouched. + +**Extensions:** + +- **Network actions** (`addDevices` / `syncDevices`) call + `apply_network_refresh(device_ids)`: upsert named devices and reconcile + edge pairs from one edge-skeleton read scoped to pairs touching an affected + device. +- **Refresh is resilient.** A failed scoped re-fetch marks just that entity + stale and logs; never propagates. Stale entities self-heal on next access. + +## Consequences + +- Post-commit cost is proportional to **change-set size**, not network size. +- The snapshot stays a pure read model built from collector responses — no + fabricated entries. +- Together with ADR-007: `commit()` = pre-commit conflict check → POST → + result evaluation → targeted refresh. diff --git a/docs/architecture/inspect-app/decisions/README.md b/docs/architecture/inspect-app/decisions/README.md new file mode 100644 index 0000000..23b5693 --- /dev/null +++ b/docs/architecture/inspect-app/decisions/README.md @@ -0,0 +1,16 @@ +# Architecture Decision Records — Inspect App + +Each ADR captures one architectural decision and its consequences. + +## Index + +| ADR | Title | Status | +| --- | ----- | ------ | +| [001](./001-api-paradigm.md) | API paradigm: data-driven | Accepted | +| [002](./002-async-strategy.md) | Async readiness & migration | Accepted | +| [003](./003-e2e-testing.md) | E2E testing strategy | Accepted | +| [004](./004-commit-write-model.md) | Commit-style write model (change sets) | Accepted | +| [005](./005-lazy-snapshot-loading.md) | Skeleton-first loading & lazy hydration | Accepted | +| [006](./006-collector-only-endpoints.md) | Collector-only endpoint policy | Accepted | +| [007](./007-write-consistency.md) | Write consistency (compare-and-commit) | Accepted | +| [008](./008-post-commit-snapshot-refresh.md) | Post-commit snapshot refresh | Accepted | diff --git a/docs/architecture/inspect-app/endpoints.md b/docs/architecture/inspect-app/endpoints.md new file mode 100644 index 0000000..0cd939f --- /dev/null +++ b/docs/architecture/inspect-app/endpoints.md @@ -0,0 +1,1819 @@ +# Inspect App Endpoint Reference + +Captured against a local VideoIPath test instance. All hostnames, usernames, +device IDs, booking IDs, labels, endpoint IDs, IP addresses, multicast +addresses, UUIDs, and revisions in this document are anonymized examples. Only +read-only requests and one empty no-op `updateTopology` POST were executed. + +The Inspect package scope follows the accepted ADRs: + +- Request/response only; no WebSocket subscription API + ([ADR-001](./decisions/001-api-paradigm.md)). Subscription *captures* are + still used as an endpoint-discovery source — see + [Collector Scoped Queries](#collector-scoped-queries-captured-from-the-inspect-ui). +- Snapshot-scoped reads: a snapshot loads a skeleton first and lazily hydrates + detail; fresh status means building a new snapshot + ([ADR-005](./decisions/005-lazy-snapshot-loading.md)). +- Data-only DTOs; write/commit behaviour lives in the app/transaction layer + (`transaction.py`), not in the model classes. + +## Common Envelope + +All observed REST v2 responses use the standard envelope: + +```json +{ + "data": {}, + "header": { + "auth": true, + "caption": "Operation Successful", + "code": "OK", + "errorCodes": [], + "errorDetails": [], + "id": "", + "msg": [], + "ok": true, + "user": "api-user" + } +} +``` + +`header.ok` only describes transport/envelope success. For `updateTopology`, +commit success must also check `data.res.ok` and +`data.validation.result.ok`. + +## `GET /rest/v2/data/status/system/about/version` + +Purpose: identify the server version used for endpoint and payload capture. + +Request: + +```http +GET /rest/v2/data/status/system/about/version +Authorization: Basic +Accept: application/json +``` + +Response example: + +```json +{ + "data": { + "status": { + "system": { + "about": { + "version": "2025.4.x" + } + } + } + }, + "header": { + "auth": true, + "caption": "Operation Successful", + "code": "OK", + "errorCodes": [], + "errorDetails": [], + "id": "", + "msg": [], + "ok": true, + "user": "api-user" + } +} +``` + +## `GET /rest/v2/data/status/collector/**` + +Purpose: full Inspect read aggregate — services, path drill-down, external edge +status, and topology node status in one response. + +> Since [ADR-005](./decisions/005-lazy-snapshot-loading.md) this full fetch is +> the **eager/fallback mode** only. The default read path uses the scoped +> queries documented in +> [Collector Scoped Queries](#collector-scoped-queries-captured-from-the-inspect-ui), +> which is also how the vendor's Inspect UI loads its data. + +Observed top-level sections: + +- `inspect.nodeStatus` +- `inspect.paths` +- `externalEdgesByDeviceKey` +- `maintenanceBookings` +- `superProfiles` +- `tagInfo` + +`security/**` returned an empty `collector` object on this instance. + +Request: + +```http +GET /rest/v2/data/status/collector/** +Authorization: Basic +Accept: application/json +``` + +Response shape example: + +```json +{ + "data": { + "status": { + "collector": { + "externalEdgesByDeviceKey": { "_items": [] }, + "inspect": { + "nodeStatus": { "_items": [] }, + "paths": { "_items": [] } + }, + "maintenanceBookings": { "_items": [] }, + "superProfiles": { "_items": [] }, + "tagInfo": { "_items": [] } + } + } + }, + "header": { + "auth": true, + "caption": "Operation Successful", + "code": "OK", + "errorCodes": [], + "errorDetails": [], + "id": "", + "msg": [], + "ok": true, + "user": "api-user" + } +} +``` + +## Collector Scoped Queries (captured from the Inspect UI) + +Source: a browser WebSocket capture of the Inspect app's **initial load** +(2026-07-08, VideoIPath 2025.4.x test instance, 27 devices / 40 edge pairs). +The UI never fetches `/status/collector/**`. It opens ~8 **parallel scoped +subscriptions**, each addressing a collector sub-path with a filter and a deep +field projection. These queries are the concrete basis for the skeleton + +lazy-hydration loading model ([ADR-005](./decisions/005-lazy-snapshot-loading.md)). + +All ids, labels, and addresses below are anonymized. Decoded paths are shown +with URL encoding removed (`%20` → space, `%22` → `"`, `%2C` → `,`, +`%3D` → `=`, `%3C` → `<`). + +### Observed subscription transport (reference only — out of package scope) + +Each subscription is one WebSocket message; the `path` addresses the same data +tree as REST v2: + +```json +{"channel": "subsc", "messageId": 14, "path": "/status/collector/externalEdgesByDeviceKey/", "id": ""} +``` + +The initial reply carries the full query result; the `data` object mirrors the +REST v2 `data` tree exactly (`_items[]`, `_id`, `_vid`): + +```json +{"channel": "subsc", "id": "", "messageId": 14, "payload": {"_id": "", "_ver": [1, 1], "data": {"status": {"collector": {"externalEdgesByDeviceKey": {"_items": ["..."]}}}}}} +``` + +Protocol details (verified by live connection and the UI bundle on 2025.4.9): + +| Item | Value | +| ---- | ----- | +| URL | `{ws\|wss}:///rest/v2/sessions/me/ws?exclusive=false` | +| Auth | Session cookies on the WebSocket handshake (same as REST) | +| Subscribe | `{"channel":"subsc","messageId":N,"path":"","id":""}` | +| Initial reply | `{"channel":"subsc","id":"…","messageId":N,"payload":{"_id":"…","_ver":[1,1],"data":{…}}}` | +| Unsubscribe | `{"channel":"unsubsc","messageId":N,"id":""}` | +| Delta frames | `payload.data._e` (observed in the UI decoder) | +| Reconnect | UI re-opens when the socket is gone and the tab is visible; no dedicated heartbeat channel | + +The package stays request/response ([ADR-001](./decisions/001-api-paradigm.md)): +the same query paths work as `GET /rest/v2/data`. **Confirmed** on +VideoIPath 2025.4.9 for practical query lengths; URL encoding of spaces (`%20`), +quotes (`%22`), parentheses (`%28`/`%29`), and `=` (`%3D`) works as captured. The +**full** UI projection below returns **HTTP 414** (URI Too Long) as a REST GET — +a proxy/URL-length constraint, not a server bug — so the package uses a trimmed +skeleton projection or the `/**` fallback. + +Measured payload sizes (2025.4.9 instance, 30 devices / 40 edge pairs): + +| Query | Size | +| ----- | ---- | +| `GET …/status/collector/**` (eager fallback) | **19.3 MB** | +| Trimmed device skeleton + edge skeleton | **~92 KB** | +| Minimal `nodeStatus` projection with `"_noId"` (all 30 devices) | **8 KB** | +| Single device `/**` (hydration) | **21 KB** | + +### Query language observed on collector paths + +| Construct | Example | Meaning | +| --------- | ------- | ------- | +| `*` | `nodeStatus/*` | Select all items of a collection | +| `""` | `fromStatus/generic/alias/"a0"` | Select one child by literal key | +| `"_noId"` | `modules/"_noId"` | Subtree expansion suppressor — collection queries return `"modules": {}`; single-device queries omit the `modules` key. Also used on `inspect/paths/"_noId"/…` | +| `* where ` | `* where syncSeverity=2` | Filter items; operators seen: `=`, `<=`, `and`, `or`, parentheses, `contains(,'')`, `lower()` | +| `limit N` | `* where … limit 1000000` | Cap the number of returned items (UI uses `1000` for side lists, `1000000` for the full topology) | +| `order by asc(en) alphanum` | `panelConfigs/* order by label asc(en) alphanum` | Server-side sort (seen outside the collector) | +| `/field1,field2/` | `/deviceId,resourceId/` | Project only the listed fields at the current level | +| `/.../` | `…/coordinates/x,y/.../...` | Pop one level back up in the projection tree | +| `/**` | `status,syncSeverity/**` | Include the full subtree below the selected fields | +| *(no projection)* | `externalEdgesByDeviceKey` | **No expansion**: the bare collection root returns no items (observed, msg 15) | + +### Device skeleton — `nodeStatus` without modules (UI main topology load) + +The UI's primary topology query. Note `modules/"_noId"`: **the vendor UI itself +loads devices without module/port detail**. All 27 captured items came back +with `modules: {}`. + +Decoded subscription path (one line, msg 13): + +```text +/status/collector/inspect/nodeStatus/* where ((syncSeverity=0) or (syncSeverity=1)) or (syncSeverity=3) limit 1000000/deviceId,resourceId/.../context/devicePid,modulePid,portPid/.../.../descriptor/desc,label/.../.../meta/hwPanelType,isCore,isVirtual,siteId/.../coordinates/x,y/.../.../iconSize,iconType,sdpStrategy/**/.../.../tags/*/.../.../.../relatedNodeTags/*/.../.../status,syncSeverity/**/.../.../tags/*/.../.../modules/"_noId"/resourceId/.../context/devicePid,modulePid,portPid/.../.../descriptor/desc,label/.../.../ptpStatus,status/**/.../.../relatedNodeTags/*/.../.../tags/*/.../.../ports/*/pid,resourceId/.../context/devicePid,modulePid,portPid/.../.../descriptor/desc,label/.../.../relatedNodeTags/*/.../.../status/**/.../.../tags/*/.../.../vertexInfo/id,type,vertexType/.../fields/isActive,isControlled,isEndpoint/.../.../in,out/id,label/.../.../.../ptpPortStatus/info/clockType,domain,identity/.../.../status/**/.../.../.../tagsInfo/assigned/inheritedConflict/.../all/*/.../.../inherited/*/label/.../ancestors/*/.../.../path/*/.../.../.../.../local/*/label/.../path/*/.../.../.../.../.../custom/*/.../.../.../pathDescriptions/*/deviceLevel/deviceId,deviceLabel,devicePid,expectConfig/.../inputStatus,outputStatus/label,pid/.../context/devicePid,modulePid,portPid/.../.../status/**/.../.../.../moduleAndDeviceStatus/**/.../.../.../serviceLevel/bookingId,isMain,serviceLabel/.../fromStatus,toStatus/label,pid/.../context/devicePid,modulePid,portPid/.../.../status/**/.../.../.../serviceStatus/config,total/**/.../.../.../.../.../.../.../.../tagsInfo/assigned/inheritedConflict/.../all/*/.../.../inherited/*/label/.../ancestors/*/.../.../path/*/.../.../.../.../local/*/label/.../path/*/.../.../.../.../.../custom/*/.../.../.../.../.../ptpDeviceStatus/info/clockType,domain,identity/.../.../status/**/.../.../.../tagsInfo/assigned/inheritedConflict/.../all/*/.../.../inherited/*/label/.../ancestors/*/.../.../path/*/.../.../.../.../local/*/label/.../path/*/.../.../.../.../.../custom/* +``` + +Effective skeleton selection per device (the module subtree is projected in +full but suppressed by `"_noId"`): + +- identity: `deviceId`, `resourceId`, `context{devicePid,modulePid,portPid}` +- display: `descriptor{desc,label}`, + `meta{hwPanelType,isCore,isVirtual,siteId,coordinates{x,y},iconSize,iconType,sdpStrategy,tags}` +- state: `status/**`, `syncSeverity`, `ptpDeviceStatus{info,status}` +- tagging: `tags`, `relatedNodeTags`, `tagsInfo{assigned,custom}` + +The UI splits `nodeStatus` by sync state into three subscriptions with the same +projection: this one (`syncSeverity` 0/1/3, topology map), a sync-pending list +(msg 9, below), and a label-search variant +(`* where (syncSeverity=3) and (contains(lower(descriptor.label),'')) limit 1000`, +msg 10). A package skeleton load can drop the `where` clause and fetch all +devices in one query. **Confirmed:** omitting `where` does **not** require +`limit` (30 devices returned with or without `limit 1000000` on 2025.4.9). + +Anonymized response item (skeleton shape — note `modules: {}`): + +```json +{ + "_id": "device-a", + "_vid": "device-a", + "context": { "devicePid": "device-a", "modulePid": null, "portPid": null }, + "descriptor": { + "desc": "Type: example_multidevice\nIP: ", + "label": "Example Device A" + }, + "deviceId": "device-a", + "meta": { + "coordinates": { "x": 1600.0, "y": 9050.0 }, + "hwPanelType": null, + "iconSize": "medium", + "iconType": "default", + "isCore": false, + "isVirtual": false, + "sdpStrategy": "always", + "siteId": null, + "tags": [] + }, + "modules": {}, + "ptpDeviceStatus": { + "info": null, + "status": { "sa": 2, "severity": 6 } + }, + "relatedNodeTags": ["Format~~example"], + "resourceId": "device:device-a", + "status": { "sa": 2, "severity": 6 }, + "syncSeverity": 0, + "tags": [], + "tagsInfo": { + "assigned": { "all": [], "inherited": {}, "inheritedConflict": false, "local": {} }, + "custom": [] + } +} +``` + +### Device detail — `nodeStatus` with `modules/*` (hydration template) + +The sync-pending subscription (msg 9) is **byte-identical** to the skeleton +query except for two segments: + +```text +- * where ((syncSeverity=0) or (syncSeverity=1)) or (syncSeverity=3) limit 1000000 ++ * where syncSeverity=2 limit 1000 +- modules/"_noId" ++ modules/* +``` + +With `modules/*` the projection expands modules → ports → `vertexInfo`, +`ptpPortStatus`, per-port `status/**`, `tagsInfo`, and `pathDescriptions` +(`deviceLevel` + `serviceLevel`) — the full drill-down detail. + +This is the template for **per-device lazy hydration** +([ADR-005](./decisions/005-lazy-snapshot-loading.md)): reuse the detail +projection but scope it to one device. **Confirmed** on 2025.4.9 — both work; +prefer the shorter direct-id form: + +```http +GET /rest/v2/data/status/collector/inspect/nodeStatus//modules/*/… +GET /rest/v2/data/status/collector/inspect/nodeStatus/* where deviceId='' limit 1/modules/*/… +``` + +**Confirmed** populated `modules/*` on synced devices (`syncSeverity` 0/1/3): +module keys map to child objects whose expansion follows the projection depth +(e.g. `…/modules/*/ports/*/pid,descriptor/label,status` yields ports with +`pid`, `descriptor.label`, and `status`). Devices with `syncSeverity=2` +(sync-pending) return `"modules": {}` even with `modules/*` — modules populate +only after sync completes; the earlier capture was not a projection bug. +`GET …/nodeStatus//**` returns the full subtree (~21 KB for one +device) — fixture: +`tests/inspect/fixtures/2025.4.9/device_hydration_modules_ports.json`. + +### Edge skeleton — `externalEdgesByDeviceKey` lean projection + +The UI loads **all** edge pairs with a lean projection (msg 14): endpoint +device pids/labels, edge ids, endpoint port labels + `context`, and the +pair-level status severities — **no `pathDescriptions`, no bandwidth numbers** +(only the `bandwidth` *severity* inside `status`). + +Decoded subscription path (one line, msg 14): + +```text +/status/collector/externalEdgesByDeviceKey/* limit 1000000/primary,secondary/devicePid,label/.../.../status/alarm,bandwidth,maintenance,ptp/.../.../primary/data/*/id/.../fromStatus,toStatus/label/.../context/devicePid,modulePid,portPid/.../.../.../.../.../.../secondary/data/*/id/.../fromStatus,toStatus/label/.../context/devicePid,modulePid,portPid +``` + +Anonymized response item: + +```json +{ + "_id": "device-a::device-b", + "_vid": "device-a::device-b", + "primary": { + "data": { + "edge-uuid-0001": { + "id": "edge-uuid-0001", + "fromStatus": { + "context": { + "devicePid": "device-a", + "modulePid": "device-a.dev.module-1", + "portPid": "device-a.dev.module-1.port-out-1" + }, + "label": "Port A (out)" + }, + "toStatus": { + "context": { + "devicePid": "device-b", + "modulePid": "device-b.dev.module-1", + "portPid": "device-b.dev.module-1.port-in-1" + }, + "label": "Port B (in)" + } + } + }, + "devicePid": "device-a", + "label": "Example Device A" + }, + "secondary": { + "data": {}, + "devicePid": "device-b", + "label": "Example Device B" + }, + "status": { "alarm": 1, "bandwidth": null, "maintenance": null, "ptp": 1 } +} +``` + +A parallel subscription to the **bare collection root** +(`/status/collector/externalEdgesByDeviceKey`, msg 15) returned `_items: []` +while the projected query returned 40 items — without a projection or `/**` +there is no expansion. Edge detail (bandwidth values, `pathDescriptions`) stays +in the full per-pair shape documented under +[`GET …/externalEdgesByDeviceKey/**`](#get-restv2datastatuscollectorexternaledgesbydevicekey). + +### Service list — `inspect/paths` projection + +The UI subscribes to `inspect/paths` with a projection covering `serviceFields` +and the per-hop `path` structure (msg 12; the capture returned no items — no +active services at capture time; the item shape is documented under +[`GET …/inspect/paths/**`](#get-restv2datastatuscollectorinspectpaths)): + +```text +/status/collector/inspect/paths/"_noId"/serviceFields/bid,from,fromLabel,isMain,to,toLabel/.../fromStatus,toStatus/**/.../.../generic/descriptor/desc,label/.../.../.../serviceStatus/config,total/**/.../.../.../.../path/*/bid,ipDesc/.../structure/deviceId,deviceLabel,devicePid,expectConfig/.../inputStatus,outputStatus/label,pid/.../context/devicePid,modulePid,portPid/.../.../status/**/.../.../.../moduleAndDeviceStatus/** +``` + +Selected: `serviceFields{bid,from,fromLabel,isMain,to,toLabel,fromStatus, +toStatus,generic.descriptor,serviceStatus{config,total}}` plus +`path[]{bid,ipDesc,structure{deviceId,deviceLabel,devicePid,expectConfig, +inputStatus,outputStatus,moduleAndDeviceStatus}}` — everything the snapshot's +service index needs, without the full raw records. + +### Auxiliary section queries + +Also part of the UI's initial load (initial replies at capture time in +parentheses): + +| Sub-path (decoded) | Purpose | msg | +| ------------------ | ------- | --- | +| `/status/collector/maintenanceBookings/* where ((contains(lower(generic.descriptor.label),'')) or (contains(tags,''))) and (generic.state<=1)/` | Active maintenance bookings (empty) | 16 | +| `/status/collector/superProfiles` *(projection not captured; reply held profile records)* | Routing profiles (populated) | 3 | +| `/status/collector/tagInfo` *(projection not captured; reply held `profileTags._items[]`)* | Tag → profile mappings (populated) | 4 | +| `/status/conman/services/"_noId" where connection.generic.state<=1/` | Rich service list for the Services panel — **not** a collector path; reference only (empty) | 11 | +| `/status/system/about/copyright,gitHead,version` | Version probe | 17 | +| `/status/system/status/serverState/broadcastAddress,hostname,mode,role` | Server role/state | 19 | + +## `GET /rest/v2/data/status/collector/inspect/paths/**` + +Purpose: list service/path records with endpoint labels, service state, and +per-hop path structures. + +Request: + +```http +GET /rest/v2/data/status/collector/inspect/paths/** +Authorization: Basic +Accept: application/json +``` + +Response item example: + +```json +{ + "_id": "booking-1001::main", + "_vid": "_:booking-1001::main", + "serviceFields": { + "bid": "booking-1001", + "ctype": 2, + "formatSubState": 0, + "from": "topo:device-a.module-1.port-out-1", + "fromLabel": "Source Endpoint A", + "fromPid": "device-a.dev.module-1.port-out-1", + "fromStatus": { "sa": 0, "severity": 1 }, + "generic": { + "allocationState": 0, + "cancelTime": null, + "descriptor": { + "desc": "", + "label": "Source Endpoint A -> Destination Endpoint B" + }, + "locked": false, + "state": 1, + "tags": [] + }, + "isMain": true, + "serviceStatus": { + "config": { "sa": 0, "severity": 1 }, + "total": { "sa": 0, "severity": 1 } + }, + "to": "topo:device-b.module-2.port-in-1", + "toLabel": "Destination Endpoint B", + "toPid": "device-b.dev.module-2.port-in-1", + "toStatus": { "sa": 0, "severity": 1 } + }, + "path": [ + { + "bid": "booking-1001", + "ipDesc": ":", + "structure": { + "deviceId": "device-a", + "deviceLabel": "Example Source Device", + "devicePid": "device-a", + "expectConfig": true, + "inputStatus": { + "context": { + "devicePid": "device-a", + "modulePid": "device-a.dev.module-1", + "portPid": "device-a.dev.module-1.port-out-1" + }, + "label": "Source Endpoint A", + "pid": "device-a.dev.module-1.port-out-1", + "status": { "sa": 0, "severity": 1 } + }, + "moduleAndDeviceStatus": { "sa": 0, "severity": 1 }, + "outputStatus": { + "context": { + "devicePid": "device-a", + "modulePid": "device-a.dev.network-module", + "portPid": "device-a.dev.network-module.port-1" + }, + "label": "Network Port A", + "pid": "device-a.dev.network-module.port-1", + "status": { "sa": 0, "severity": 1 } + } + } + } + ] +} +``` + +## `GET /rest/v2/data/status/collector/externalEdgesByDeviceKey/**` + +Purpose: live inter-device link status grouped by device pair. + +Request: + +```http +GET /rest/v2/data/status/collector/externalEdgesByDeviceKey/** +Authorization: Basic +Accept: application/json +``` + +Response item example: + +```json +{ + "_id": "device-a::device-b", + "_vid": "device-a::device-b", + "status": { + "alarm": 1, + "bandwidth": null, + "maintenance": null, + "ptp": 1 + }, + "primary": { + "devicePid": "device-a", + "label": "Example Source Device", + "data": { + "edge-uuid-0001": { + "bandwidth": 0.0, + "fromStatus": { + "context": { + "devicePid": "device-a", + "modulePid": "device-a.dev.module-1", + "portPid": "device-a.dev.module-1.port-out-1" + }, + "label": "Port A (out)", + "pid": "device-a.dev.module-1.port-out-1", + "status": { "sa": 0, "severity": 1 } + }, + "id": "edge-uuid-0001", + "maxBandwidth": null, + "pathDescriptions": {}, + "ratio": 0.0, + "status": { + "alarm": 1, + "bandwidth": null, + "maintenance": null, + "ptp": 1 + }, + "toStatus": { + "context": { + "devicePid": "device-b", + "modulePid": "device-b.dev.module-1", + "portPid": "device-b.dev.module-1.port-in-1" + }, + "label": "Port B (in)", + "pid": "device-b.dev.module-1.port-in-1", + "status": { "sa": 0, "severity": 1 } + } + } + } + }, + "secondary": { + "devicePid": "device-b", + "label": "Example Destination Device", + "data": {} + } +} +``` + +## `GET /rest/v2/data/status/collector/inspect/nodeStatus/**` + +Purpose: topology node/device status, including modules, ports, +`vertexInfo`, coordinates, statuses, and embedded path descriptions when the +server/user exposes them. + +In the sanitized capture, the endpoint was valid but returned no items: + +```json +{ + "data": { + "status": { + "collector": { + "inspect": { + "nodeStatus": { + "_items": [] + } + } + } + } + }, + "header": { + "auth": true, + "caption": "Operation Successful", + "code": "OK", + "errorCodes": [], + "errorDetails": [], + "id": "", + "msg": [], + "ok": true, + "user": "api-user" + } +} +``` + +The model still includes `InspectApiNodeStatusItem` based on the accepted concept +document and the known Inspect UI payload shape. + +## `GET /rest/v2/data/status/collector/maintenanceBookings/**` + +Purpose: maintenance booking status relevant to Inspect service/link display. + +Observed response on this instance: + +```json +{ + "data": { + "status": { + "collector": { + "maintenanceBookings": { + "_items": [] + } + } + } + } +} +``` + +## `GET /rest/v2/data/status/collector/superProfiles/**` + +Purpose: routing/super-profile status data used by the Inspect aggregate. + +Observed response on this instance: + +```json +{ + "data": { + "status": { + "collector": { + "superProfiles": { + "_items": [] + } + } + } + } +} +``` + +## `GET /rest/v2/data/status/collector/tagInfo/**` + +Purpose: tag/profile metadata used by the Inspect aggregate. + +Observed response on this instance: + +```json +{ + "data": { + "status": { + "collector": { + "tagInfo": { + "_items": [] + } + } + } + } +} +``` + +## `GET /rest/v2/data/status/network/edgesByDevice/**` + +> **Reference only — not called by the package** +> ([ADR-006](./decisions/006-collector-only-endpoints.md)). + +Purpose: existing status-plane edge view. This is not the collector facade, but +it is useful for cross-checking edge payload fields during discovery when +`config/network/nGraphElements` is unavailable or permission-filtered. + +Request: + +```http +GET /rest/v2/data/status/network/edgesByDevice/** +Authorization: Basic +Accept: application/json +``` + +Response fragment: + +```json +{ + "data": { + "status": { + "network": { + "edgesByDevice": { + "_items": [ + { + "_id": "device-a", + "_vid": "device-a", + "edge-uuid-0001": { + "active": true, + "bandwidth": -1.0, + "capacity": 65535, + "conflictPri": 0, + "descriptor": { "desc": "", "label": "" }, + "excludeFormats": [], + "fromId": "device-a.module-1.port-out-1", + "includeFormats": [], + "redundancyMode": "Any", + "tags": [], + "toId": "device-b.module-1.port-in-1", + "type": "unidirectionalEdge", + "weight": 1, + "weightFactors": { + "bandwidth": { "weight": 0 }, + "service": { "max": 100, "weight": 0 } + } + } + } + ] + } + } + } + } +} +``` + +## `GET /rest/v2/data/config/network/nGraphElements/**` + +> **Reference only — not called by the package** +> ([ADR-006](./decisions/006-collector-only-endpoints.md)). This is +> `app.topology`'s surface; it is documented here because `updateTopology` +> persists into it and the `replace*` payloads carry the persisted element +> shape. Its `_rev` is irrelevant to Inspect writes — `updateTopology` ignores +> revisions (last-writer-wins; see +> [ADR-007](./decisions/007-write-consistency.md)). + +Purpose: revisioned config store that `updateTopology` persists into. Inspect +models include independent `InspectApi*` nGraph DTOs for this persisted shape, but +they do not import or subclass topology app models. + +> **Vertex tags are not stored here.** Device-level tags appear on `baseDevice` +> items, but tag bindings on individual vertices live in +> `videoipath_docs.device_tags` (separate from the `ngraph` table). Inspect +> surfaces vertex tags via `lookupInspectVertexByIds` and hydrated port +> `tagsInfo` in `nodeStatus` — see +> [concepts.md §3.4](./concepts.md#34-tagging--device-vs-vertex-vs-module-inspect-vs-topology). +> `app.topology` only knows the `nGraphElements` shape and therefore has no +> vertex-tag API. + +In the sanitized capture, the endpoint was valid but returned no items: + +```json +{ + "data": { + "config": { + "network": { + "nGraphElements": { + "_items": [] + } + } + } + }, + "header": { + "auth": true, + "caption": "Operation Successful", + "code": "OK", + "errorCodes": [], + "errorDetails": [], + "id": "", + "msg": [], + "ok": true, + "user": "api-user" + } +} +``` + +Relevant type-filtered forms: + +```http +GET /rest/v2/data/config/network/nGraphElements/* where type='baseDevice' /** +GET /rest/v2/data/config/network/nGraphElements/* where type='ipVertex' /** +GET /rest/v2/data/config/network/nGraphElements/* where type='codecVertex' /** +GET /rest/v2/data/config/network/nGraphElements/* where type='genericVertex' /** +GET /rest/v2/data/config/network/nGraphElements/* where type='unidirectionalEdge' /** +GET /rest/v2/data/config/network/nGraphElements/* where type='nGraphResourceTransform' /** +``` + +## Action Endpoints + +Anonymized request/response shapes for Inspect collector and network actions. + +### `POST /rest/v2/actions/status/collector/lookupInspectEdgesByIds` + +**Verified 2025.4.9.** The Inspect-surface source of an edge's **full persisted +form** — every field a `replaceEdges` payload needs (`weight`, `capacity`, +`bandwidth`, `redundancyMode`, `weightFactors`, `descriptor`, `fDescriptor`, +`tags`, `conflictPri`, `includeFormats`, `excludeFormats`). Batched by design. +This is the stage-time baseline read for compare-and-commit +([ADR-007](./decisions/007-write-consistency.md)). The UI bundle's edge edit +flow calls it with **both directions of a connection** +(`[edgeId, pairedEdgeId]`) before opening the dialog. **No `_rev` anywhere in +the response.** + +> A `lookupGraphElement` action does **not** exist on 2025.4.9 (POST → +> `No action node in request`); earlier references to it were wrong. + +Request: + +```json +{ + "header": { "id": 0 }, + "data": ["device-a.module-1.port-out-1.out::device-b.module-1.port-in-1.in"] +} +``` + +Response (keyed by requested edge id): + +```json +{ + "data": { + "device-a.module-1.port-out-1.out::device-b.module-1.port-in-1.in": { + "edge": { + "active": true, + "bandwidth": -1.0, + "capacity": 65535, + "conflictPri": 0, + "descriptor": { "desc": "", "label": "" }, + "excludeFormats": [], + "fDescriptor": { "desc": "", "label": "" }, + "fromId": "device-a.module-1.port-out-1.out", + "includeFormats": [], + "redundancyMode": "Any", + "tags": [], + "toId": "device-b.module-1.port-in-1.in", + "weight": 1, + "weightFactors": { + "bandwidth": { "weight": 0 }, + "service": { "max": 100, "weight": 0 } + } + }, + "fromDevice": "device-a", + "toDevice": "device-b" + } + }, + "header": { "ok": true, "code": "OK" } +} +``` + +Fixture: `tests/inspect/fixtures/2025.4.9/lookup_inspect_edges_by_ids.json`. + +### `POST /rest/v2/actions/status/collector/lookupInspectVertexById` / `…ByIds` + +**Verified 2025.4.9.** Editable vertex form for one id (`data: ""`) +or batched (`…ByIds`, `data: ["", …]`, response keyed by id). The UI bundle +uses only the batched form. **No `_rev`.** Together with +`lookupInspectDevice` (devices, above) and `lookupInspectEdgesByIds` this +covers all three `replace*` element kinds for stage-time baselines +(ADR-007). + +> **Vertex tags.** Tag bindings on a vertex are **not** part of the topology +> `nGraphElements` store (`app.topology` has no equivalent). Server-side they +> live in `videoipath_docs.device_tags`, separate from the `ngraph` table. +> This lookup is the Inspect-surface source for vertex tag state: +> `assignedTags` (with `all`, `inherited`, `local`, `inheritedConflict`) and +> `fields.tags` / `fields.localAssignedTags`. Hydrated `nodeStatus` ports carry +> the same bindings under `tagsInfo` for read-side display +> ([concepts.md §3.4](./concepts.md#34-tagging--device-vs-vertex-vs-module-inspect-vs-topology)). + +Response (single form; `…ByIds` nests this per id): + +```json +{ + "data": { + "assignedTags": { "all": [], "inherited": {}, "inheritedConflict": false, "local": {} }, + "context": { + "devicePid": "device-a", + "modulePid": "device-a.dev.module-1", + "portPid": "device-a.dev.module-1.port-out-1" + }, + "customSchemas": {}, + "fields": { + "active": true, + "controlProps": null, + "custom": {}, + "desc": "", + "destinationMonitorLeader": false, + "extraAlertFilters": [], + "label": "Port A (out)", + "localAssignedTags": [], + "queueable": false, + "sipsMode": "NONE", + "tags": [], + "typeFields": { + "ipAddress": null, + "ipNetmask": null, + "public": false, + "supportsCpipeCfg": false, + "supportsIgmpCfg": false, + "supportsMacForwardingCfg": false, + "supportsNsoCfg": false, + "supportsOpenflowCfg": false, + "supportsStaticIgmpCfg": false, + "supportsVlanCfg": false, + "supportsVplsCfg": false, + "type": "ip", + "vlanId": null, + "vrfId": null + }, + "useAsEndpoint": false + }, + "id": "device-a.module-1.port-out-1.out", + "isVirtual": false, + "vertexType": "Out" + }, + "header": { "ok": true, "code": "OK" } +} +``` + +Fixture: `tests/inspect/fixtures/2025.4.9/lookup_inspect_vertex_by_id.json`. + +The `fields` object is **exactly the accepted `replaceVertices` payload shape** +(verified 2025.4.9 — see +[`updateTopology`](#post-restv2actionsstatuscollectorupdatetopology); +update-only). The same effective-label caveat as `lookupInspectDevice` +applies: `label` here can carry the `fDescriptor` fallback while the persisted +`descriptor.label` is empty. + +### `POST /rest/v2/actions/status/collector/lookupNodeInfo` / `…/lookupEdgeInfo` / `…/lookupDeviceVertices` + +**Verified 2025.4.9 — display-oriented**, not baselines. `lookupNodeInfo` +(`data: ""`) and `lookupEdgeInfo` (`data: ""`) return +info-panel section lists (`[{header, content: [{label, field: {type, value}, +meta}]}]`) — the drill-down side panels, with port `context` pids in `meta`. +`lookupDeviceVertices` takes `{"primary": "", "secondary": +""}` and returns the connectable vertices per side in the same +label/value style (the connect dialog's port lists). + +### `POST /rest/v2/actions/status/collector/lookupInspectDevice` + +Purpose: collector lookup for one Inspect device/topology node. + +Request: + +```http +POST /rest/v2/actions/status/collector/lookupInspectDevice +Authorization: Basic +Content-Type: application/json +Accept: application/json +``` + +```json +{ + "header": { "id": 0 }, + "data": "device-a" +} +``` + +Response example: + +```json +{ + "data": { + "assignedTags": { + "all": [], + "inherited": {}, + "inheritedConflict": false, + "local": {} + }, + "fields": { + "coordinates": { + "x": 500, + "y": 8150 + }, + "descriptor": { + "desc": "Example device description", + "label": "Example Source Device" + }, + "iconSize": "medium", + "iconType": "gateway", + "localAssignedTags": [], + "sdpStrategy": "always", + "siteId": null, + "tags": ["#example-tag-a", "#example-tag-b"], + "virtualDeviceFields": null + } + }, + "header": { + "auth": true, + "caption": "Operation Successful", + "code": "OK", + "errorCodes": [], + "errorDetails": [], + "id": "0", + "msg": [], + "ok": true, + "user": "api-user" + } +} +``` + +Model coverage: + +- `InspectApiLookupInspectDeviceRequest` +- `InspectApiLookupInspectDeviceResponse` +- `InspectApiAssignedTags` +- `InspectApiLookupInspectDeviceFields` + +The `fields` object is **exactly the accepted `replaceDevices` payload shape** +(verified 2025.4.9 — see +[`updateTopology`](#post-restv2actionsstatuscollectorupdatetopology)). Note +that `descriptor` here carries the *effective* label (persisted `descriptor` +merged with the `fDescriptor` fallback); committing it verbatim pins the +fallback label into the persisted `descriptor`. + +Invalid object-style request example: + +```json +{ + "header": { + "auth": true, + "caption": "Invalid Request", + "code": "INVALID_REQUEST", + "errorDetails": [ + { + "msg": "Can't convert { object } to String", + "path": [], + "type": "conversionError" + } + ], + "id": "0", + "msg": ["Can't convert { object } to String"], + "ok": false, + "user": "api-user" + } +} +``` + +### `POST /rest/v2/actions/status/collector/lookupSyncInfo` + +Purpose: collector lookup for synchronization information. + +Request: + +```http +POST /rest/v2/actions/status/collector/lookupSyncInfo +Authorization: Basic +Content-Type: application/json +Accept: application/json +``` + +```json +{ + "header": { "id": 0 }, + "data": ["device-a"] +} +``` + +Response example: + +```json +{ + "data": { + "device-a": { + "add": {}, + "label": "Example Source Device", + "remove": {}, + "severity": 0, + "update": {} + } + }, + "header": { + "auth": true, + "caption": "Operation Successful", + "code": "OK", + "errorCodes": [], + "errorDetails": [], + "id": "0", + "msg": [], + "ok": true, + "user": "api-user" + } +} +``` + +Model coverage: + +- `InspectApiLookupSyncInfoRequest` +- `InspectApiLookupSyncInfoResponse` +- `InspectApiLookupSyncInfoItem` + +The device list must be non-empty. An empty list produced: + +```json +{ + "header": { + "auth": true, + "caption": "Invalid Request", + "code": "INVALID_REQUEST", + "errorDetails": [ + { + "msg": "Cannot create NonEmptyList from empty list", + "path": [], + "type": "conversionError" + } + ], + "id": "0", + "msg": ["Cannot create NonEmptyList from empty list"], + "ok": false, + "user": "api-user" + } +} +``` + +### `POST /rest/v2/actions/status/network/addDevices` + +Purpose: network action used by Inspect topology workflows to add devices. + +Request shape: + +```http +POST /rest/v2/actions/status/network/addDevices +Authorization: Basic +Content-Type: application/json +Accept: application/json +``` + +```json +{ + "header": { "id": 0 }, + "data": [ + { + "id": "device-a", + "x": 500, + "y": 8150 + } + ] +} +``` + +No-op request used for safe capture: + +```json +{ + "header": { "id": 0 }, + "data": [] +} +``` + +No-op response: + +```json +{ + "data": { + "msg": [], + "ok": true + }, + "header": { + "auth": true, + "caption": "Operation Successful", + "code": "OK", + "errorCodes": [], + "errorDetails": [], + "id": "0", + "msg": [], + "ok": true, + "user": "api-user" + } +} +``` + +Model coverage: + +- `InspectApiAddDevicesRequest` +- `InspectApiAddDevicesItem` +- `InspectApiSimpleActionResponse` +- `InspectApiActionValidationErrorResponse` + +Non-empty request against a device outside the caller's writable domains returned +an HTTP 422 validation envelope and did not create any `nGraphElements`: + +```json +{ + "header": { + "auth": true, + "caption": "The request could not be processed due to e.g. validation errors", + "code": "VALIDATION_ERROR", + "errorCodes": [], + "errorDetails": [ + { + "cause": "general", + "msg": "Operation 'update' not allowed for resource type 'device' in domain 'GroupDomainId(example-domain)'", + "path": ["device-a"], + "type": "validationError" + } + ], + "id": "", + "msg": [ + "Operation 'update' not allowed for resource type 'device' in domain 'GroupDomainId(example-domain)'" + ], + "ok": false, + "user": "api-user" + } +} +``` + +Non-empty request against a disposable, non-driver device ID returned the normal +action response envelope with `data.ok: false` and did not create any +`nGraphElements`: + +```json +{ + "data": { + "msg": ["No topology reported by the device"], + "ok": false + }, + "header": { + "auth": true, + "caption": "Operation Successful", + "code": "OK", + "errorCodes": [], + "errorDetails": [], + "id": "0", + "msg": [], + "ok": true, + "user": "api-user" + } +} +``` + +### `POST /rest/v2/actions/status/network/syncDevices` + +Purpose: network action used by Inspect topology workflows to synchronize +devices. + +Request: + +```http +POST /rest/v2/actions/status/network/syncDevices +Authorization: Basic +Content-Type: application/json +Accept: application/json +``` + +```json +{ + "header": { "id": 0 }, + "data": { + "ids": ["device-a"], + "addOnly": true, + "conflictStrategy": 0 + } +} +``` + +`conflictStrategy` values observed in the frontend bundle: + +| Value | Meaning | +| --- | --- | +| `0` | Strict | +| `1` | Invalidate services | +| `2` | Cancel services | + +No-op request used for safe capture: + +```json +{ + "header": { "id": 0 }, + "data": { + "ids": [], + "addOnly": true, + "conflictStrategy": 0 + } +} +``` + +No-op response: + +```json +{ + "data": { + "msg": [], + "ok": true + }, + "header": { + "auth": true, + "caption": "Operation Successful", + "code": "OK", + "errorCodes": [], + "errorDetails": [], + "id": "0", + "msg": [], + "ok": true, + "user": "api-user" + } +} +``` + +Model coverage: + +- `InspectApiSyncDevicesRequest` +- `InspectApiSyncDevicesRequestData` +- `InspectApiSimpleActionResponse` + +Non-empty request against a disposable, non-driver device ID returned: + +```json +{ + "data": { + "msg": [ + "A device sync requires the device to be present both in the graph and in the driver" + ], + "ok": false + }, + "header": { + "auth": true, + "caption": "Operation Successful", + "code": "OK", + "errorCodes": [], + "errorDetails": [], + "id": "0", + "msg": [], + "ok": true, + "user": "api-user" + } +} +``` + +## Virtual devices and port templates (verified 2025.4.9) + +Topology virtual devices (`virtual.N`) behave like normal Inspect devices after +creation: placement, labels, tags, icons, edges, vertex edits, and removal all +use the same `updateTopology` / write-mixin / transaction path as physical +devices. `InspectDevice.is_virtual` distinguishes them. **Create** is the only +dedicated public op (`create_virtual_device(s)` → `updateVirtualInstances` add); +port-template management and adding ports from templates are the other build +helpers. + +Verified 2025.4.9: `updateTopology` with `remove: ["virtual.N"]` fully removes +the node and the `virtualDevices` definition (no special delete routing). + +UI naming uses **port templates**; the API uses `virtualTemplates` / +`templateId`. + +### `GET /rest/v2/data/status/network/virtualTemplates/**` + +Purpose: list port templates available in the Create Virtual Devices dialog. + +Response example (anonymized): + +```json +{ + "data": { + "status": { + "network": { + "virtualTemplates": { + "_items": [ + { + "_id": "generic_bidir", + "_vid": "generic_bidir", + "label": "Generic bidir", + "vertex": { + "type": "genericVertex", + "vertexType": "BiDirectional", + "isVirtual": true + } + } + ] + } + } + } + }, + "header": { "ok": true, "code": "OK" } +} +``` + +Fixture: `tests/inspect/fixtures/2025.4.9/virtual_templates.json`. + +### `GET /rest/v2/data/status/network/virtualDevices/**` + +Purpose: list virtual device module/port definitions (wire/status). The Inspect +package does not wrap this as a domain list — after create, use +``InspectDevice`` (and ``lookupInspectDevice.fields.virtualDeviceFields`` when +needed). Available on ``InspectAPI.get_virtual_devices`` for low-level access. + +Response example (anonymized): + +```json +{ + "data": { + "status": { + "network": { + "virtualDevices": { + "_items": [ + { + "_id": "virtual.1", + "_vid": "virtual.1", + "modules": [ + { + "moduleNumber": 0, + "vertices": [ + { "count": 1, "templateId": "ip_in" }, + { "count": 1, "templateId": "ip_out" } + ] + } + ] + } + ] + } + } + } + }, + "header": { "ok": true, "code": "OK" } +} +``` + +Fixture: `tests/inspect/fixtures/2025.4.9/virtual_devices.json`. + +### `POST /rest/v2/actions/status/network/updateVirtualInstances` + +Purpose: create (and, at the wire level, update/remove) virtual device +definitions (UI: Create Virtual Devices). The Inspect package's public surface +uses this action for **create** only; metadata edits and removal go through +`updateTopology` like any other device. Server allocates `virtual.N` ids and +labels. + +Request: + +```json +{ + "header": { "id": 0 }, + "data": { + "add": [ + { + "modules": [ + { + "moduleNumber": null, + "vertices": [{ "templateId": "generic_bidir", "count": 1 }] + } + ] + } + ], + "update": {}, + "remove": [], + "force": false + } +} +``` + +Success response: + +```json +{ + "data": { + "addedDeviceLabels": { "virtual.1": "Virtual Device 1" }, + "res": { "msg": [], "ok": true }, + "validation": { + "createIds": [], + "details": {}, + "result": { "msg": [], "ok": true } + } + }, + "header": { "ok": true, "code": "OK" } +} +``` + +Update uses `update: { "virtual.1": { "modules": [...] } }`. Remove uses +`remove: ["virtual.1"]` (typically with `force: true`). The Inspect package +exposes **create** via this action only; metadata edits and device removal use +`updateTopology`. Created devices start with `coordinates: null` until placed +via `place_device` / `update_device`. + +Fixture: `tests/inspect/fixtures/2025.4.9/update_virtual_instances_create.json`. + +`lookupInspectDevice` for a virtual device exposes the same module list under +`fields.virtualDeviceFields`: + +```json +{ + "virtualDeviceFields": { + "dynamic": [ + { + "moduleNumber": 0, + "vertices": [{ "count": 1, "templateId": "generic_bidir" }] + } + ], + "manual": [] + } +} +``` + +Fixture: `tests/inspect/fixtures/2025.4.9/lookup_inspect_virtual_device.json`. + +### `POST /rest/v2/actions/status/network/updateVirtualTemplates` + +Purpose: add or remove port templates (UI: Manage port templates). Template ids +are client-chosen map keys. + +Request: + +```json +{ + "header": { "id": 0 }, + "data": { + "add": { + "example_tpl": { + "label": "Example template", + "vertex": { "type": "genericVertex", "vertexType": "BiDirectional" } + } + }, + "remove": [], + "force": false + } +} +``` + +Response uses the simple action shape (`data.ok` / `data.msg`). + +### `POST /rest/v2/actions/status/network/addVirtualTopology` + +Purpose: add ports from templates onto an existing virtual-device module +(UI: Add ports on a virtual device). + +Request: + +```json +{ + "header": { "id": 0 }, + "data": { + "deviceId": "virtual.1", + "moduleId": 0, + "countByVertexTemplate": { "ip_out": 1 } + } +} +``` + +Response uses the simple action shape (`data.ok` / `data.msg`). + +## `POST /rest/v2/actions/status/collector/updateTopology` + + +Purpose: Inspect commit endpoint. The client sends a full change set; empty +maps/lists mean no changes in that category. Staging is client-side until this +POST. + +No-op request used for this capture: + +```http +POST /rest/v2/actions/status/collector/updateTopology +Authorization: Basic +Content-Type: application/json +Accept: application/json +``` + +```json +{ + "header": { "id": 0 }, + "data": { + "replaceDevices": {}, + "replaceVertices": {}, + "replaceEdges": {}, + "replaceResourceTransforms": {}, + "addExternalEdges": [], + "remove": [], + "force": false + } +} +``` + +No-op response: + +```json +{ + "data": { + "items": [], + "res": { + "msg": [], + "ok": true + }, + "validation": { + "createIds": [], + "details": {}, + "result": { + "msg": [], + "ok": true + } + } + }, + "header": { + "auth": true, + "caption": "Operation Successful", + "code": "OK", + "errorCodes": [], + "errorDetails": [], + "id": "0", + "msg": [], + "ok": true, + "user": "api-user" + } +} +``` + +Successful commit detection: + +```python +committed = response.header.ok and response.data.res.ok and response.data.validation.result.ok +``` + +Concurrency: the action performs **no revision check** — a stale `_rev` in +`replace*` payloads is ignored (last-writer-wins, verified 2025.4.9). Conflict +detection is client-side compare-and-commit +([ADR-007](./decisions/007-write-consistency.md)); after a successful commit +the snapshot is refreshed with targeted scoped reads +([ADR-008](./decisions/008-post-commit-snapshot-refresh.md)). + +Failure modes (verified 2025.4.9): + +| Failure mode | `data.res.ok` | `validation.result.ok` | Example | +| ------------ | ------------- | ---------------------- | ------- | +| Validation gate | `false` | `false` | Booking-blocked device remove (`status: -22`, `resolvable: false`) | +| Apply gate | `false` | `true` | `remove: ["unknown-id"]` → *Cannot remove non-existent object* | +| Bad edge reference | `false` | `true` | `replaceEdges` with non-existent `fromId` | + +- **No partial apply**: mixing a valid `replaceEdges` with an invalid `remove` + in one commit fails entirely (`items: []`) — reject-before-apply. +- **`force: true` does not bypass apply-gate errors** (`res.ok` stays `false` + for non-existent remove keys). +- **`replaceDevices` takes the edit form, not the persisted element** + (verified 2025.4.9 via a device coordinate commit + revert): sending the raw + `baseDevice` element (`maps[]`, `fDescriptor`, `type`, …) is rejected with + HTTP 400 conversion errors — `coordinates` and `localAssignedTags` are + **mandatory**. The accepted shape is exactly `lookupInspectDevice`'s + `fields` object; the server maps `coordinates` → `maps[]` itself: + + ```json + { + "replaceDevices": { + "device-a": { + "coordinates": { "x": 1600.0, "y": 9050.0 }, + "descriptor": { "desc": "", "label": "" }, + "iconSize": "medium", + "iconType": "default", + "localAssignedTags": [], + "sdpStrategy": "always", + "siteId": null, + "tags": [], + "virtualDeviceFields": null + } + } + } + ``` + + `descriptor` is stored **verbatim** (an empty persisted descriptor stayed + empty across commit + revert; the element round-tripped byte-identical + except `_rev`). Caveat: the lookups return the *effective* label (persisted + `descriptor` merged with the `fDescriptor` fallback) — a client that + round-trips them unchanged pins the fallback label into `descriptor`. + `replaceEdges` takes the raw persisted edge form (captured UI commit + + verified apply). +- **`replaceVertices` takes the vertex edit form and is update-only** + (verified 2025.4.9 via a `desc` round-trip on a live vertex, byte-identical + revert): the accepted shape is exactly `lookupInspectVertexById`'s `fields` + object. Committing a **new** vertex id passes schema conversion but fails + validation with *"Vertex with id … was not found in graph"* — standalone + vertices cannot be created through `updateTopology`; they originate from + device sync (`syncDevices`) or virtual-device definitions. +- **Collector propagation**: a committed change is visible in collector reads + ~25 ms after the POST returns (three samples, first poll each time) — the + projection updates synchronously with the commit for practical purposes. +- Writes appear in `nGraphElements` under the composite `fromId::toId` key with + a bumped `_rev`; a parallel UUID-keyed document may also exist for the same + edge. +- `resolvable: true` has not been observed on 2025.4.9. +- Fixtures: `tests/inspect/fixtures/2025.4.9/update_topology_success.json`, + `…/update_topology_replace_devices.json` (edit-form request, success + response, and the raw-element rejection error), + `…/update_topology_replace_vertices.json` (vertex edit-form request, + success response, and the update-only validation failure), + `…/update_topology_fail_remove.json`, `…/update_topology_fail_booking.json`. + +Applied-change response (verified on 2025.4.9 — edge `weight` change): + +```json +{ + "data": { + "items": [ + { + "external": null, + "id": "device-a.dev.module-1.port-out-1.out::device-b.dev.module-1.port-in-1.in", + "idx": 0, + "res": { "msg": [""], "ok": true } + } + ], + "res": { "msg": [], "ok": true }, + "validation": { + "createIds": [], + "details": {}, + "result": { "msg": [], "ok": true } + } + }, + "header": { "ok": true, "code": "OK" } +} +``` + +Known failure shape from the accepted ADR (booking-blocked device delete, +verified 2025.4.9): + +```json +{ + "data": { + "items": [], + "res": { "msg": ["Validation failed"], "ok": false }, + "validation": { + "details": { + "booking-1001": { + "isCancel": false, + "isProduct": false, + "resolvable": false, + "rev": "2-2026-06-10T19:54:01.297948842Z[UTC]", + "status": -22, + "type": "generic" + } + }, + "result": { + "msg": ["A required edge was not found. (main); A required edge was not found. (redundant)"], + "ok": false + } + } + } +} +``` + +## `POST /rest/v2/actions/status/tags/assignTag` / `…/unassignTag` + +Module (and other resource) tag bindings that are **not** carried on +`updateTopology` edit forms. Captured from the Inspect UI (2025.4.x). + +Both actions share the same request body shape: + +```http +POST /rest/v2/actions/status/tags/assignTag +POST /rest/v2/actions/status/tags/unassignTag +``` + +```json +{ + "header": { "id": 0 }, + "data": { + "tagId": "Format~~V_720p60", + "elementIds": ["device:device-a.dev.0"] + } +} +``` + +| Field | Notes | +| ----- | ----- | +| `tagId` | Catalog tag id (`Category~~name`) | +| `elementIds` | Resource ids — for modules, `device:{modulePid}` (e.g. `device:device-a.dev.0`) | + +One tag per call; the package batches multiple modules that share the same +`tagId` into a single `elementIds` list. Assign adds a local binding; unassign +removes it. These calls are **not** part of the `updateTopology` atomic apply +— a mixed topology + module-tag commit runs topology first, then tag RPCs. + +Success responses often return ``data: null`` with ``header.ok == true`` (verified +live); the package treats a successful header as enough when ``data`` is absent. + +## Action registration discovery + +`GET /rest/v2/actions/status/collector/` returns whether an action +is registered on the server (verified 2025.4.9): + +```http +GET /rest/v2/actions/status/collector/updateTopology +``` + +```json +{ + "actions": { + "status": { + "collector": { + "updateTopology": { "desc": "", "label": "UpdateTopology" } + } + } + }, + "header": { "ok": true, "code": "OK" } +} +``` + +The complete registered set (`GET /rest/v2/actions/status/collector/**`, +enumerated live on 2025.4.9): + +```text +findContext lookupInspectDevice lookupPathHistoryTimes +lookupConfigDesc lookupInspectEdgesByIds lookupPathNodeAlarms +lookupDeviceAlarms lookupInspectVertexById lookupResourceSummary +lookupDeviceVertices lookupInspectVertexByIds lookupResourceTransformInfo +lookupEdgeInfo lookupInstancesStatus lookupServiceInfo + lookupNodeInfo lookupSyncInfo + lookupPathHistory lookupVertexAlarms + restoreHistoricalPath + updateTopology +``` + +Unregistered actions return an empty `collector` object; POST to those URLs +responds with `No action node in request` regardless of payload. On 2025.4.9 +`lookupGraphElement`, `validateTopology`, `discardTopology`, `importTopology`, +`exportTopology`, and `importExport/{import,export}` are **not registered** +(27+ POST payload variants tried for `validateTopology`; the UI bundle +references none of them; `importExport` data namespaces are empty under +`status`/`config`/`experimental`). These are unregistered server stubs — no +further payload probing is warranted unless a future version registers them +(re-check the GET schema after upgrades). Fixture: +`tests/inspect/fixtures/2025.4.9/action_schema_collector.json`. + +Cross-check against the UI bundle (`/assets/index-*.js`, 2025.4.9): it +references `updateTopology`, `addDevices`, `syncDevices`, `lookupSyncInfo`, +`lookupInspectEdgesByIds`, `lookupInspectVertexByIds`, `lookupEdgeInfo`, +`lookupNodeInfo`, `lookupConfigDesc`, `updateVirtualInstances`, +`updateVirtualTemplates`, and `addVirtualTopology` — and contains **zero +references to `nGraphElements`** +([ADR-006](./decisions/006-collector-only-endpoints.md)). diff --git a/docs/architecture/inspect-app/models.md b/docs/architecture/inspect-app/models.md new file mode 100644 index 0000000..87ba262 --- /dev/null +++ b/docs/architecture/inspect-app/models.md @@ -0,0 +1,847 @@ +# Inspect App Data Model + +This document describes Inspect data as package users should think about it: +services, devices, ports, edges, status, and topology changes. + +The implementation uses two layers: + +- **Transport DTOs** in `src/videoipath_automation_tool/apps/inspect/model/` mirror + HTTP request and response payloads. These classes are prefixed with `InspectApi` + and are intended for direct API communication and parsing. +- **Domain models** in `src/videoipath_automation_tool/apps/inspect/domain/` are + the objects package users work with: `InspectDevice`, `InspectPort`, + `InspectEdge`, and `InspectService`. They are built from an `InspectSnapshot` + and resolve relations from internal indexes instead of making extra HTTP calls. + +`InspectSnapshot` is an **internal** component: `InspectApp` owns a single instance, +builds it lazily on the first read, and keeps it current across writes. Users never +construct or hold it — all reads and writes go through `app.inspect` (`get_device`, +`devices`, `edges`, `services`, `refresh`, …), the same way as the other apps. + +Wire-shape examples and endpoint references live in +[endpoints.md](./endpoints.md). All examples below are anonymized. + +## Two Layers + +```mermaid +flowchart LR + Skeleton[Skeleton fetch: devices + edges] --> Snapshot[InspectSnapshot] + Snapshot --> Device[InspectDevice] + Device -- unloaded property --> Hydrate[Per-device detail fetch] + Hydrate -- merged into state --> Snapshot + Device --> Ports[InspectPort] + Device --> Edges[InspectEdge] + Device --> Services[InspectService] +``` + +A snapshot is built **skeleton-first** +([ADR-005](./decisions/005-lazy-snapshot-loading.md)): two parallel scoped +collector queries load all devices (without modules/ports) and all external +edges (lean projection). Detail is **lazily hydrated** — accessing an unloaded +property fetches that one device's full `nodeStatus` subtree (or, for +services, the `inspect/paths` section once) and merges it into the snapshot's +internal state. The concrete queries are documented in +[endpoints.md](./endpoints.md#collector-scoped-queries-captured-from-the-inspect-ui). + +Typical read flow: + +```python +device = app.inspect.get_device("device-a") # loads the internal view lazily; skeleton-backed +ports = device.ports # first access: hydrates device-a, then local +edges = device.edges # local: edge skeleton +services = device.services # first access: loads the paths section, then local +linked = device.linked_devices + +port = ports[0] +if port.edge is not None: + peer = port.edge.to_device + peer_port = port.edge.to_port +``` + +Relation getters resolve full domain objects from snapshot indexes. Repeated +access returns the same cached instance for a given device, port, edge, or +service. + +The loading contract: + +- A getter performs **at most one hydration fetch** per entity (device + subtree) or section (services, maintenance bookings, …); after that, access + is local. Hydrated data is merged into the same snapshot state and indexes. +- Because hydration is HTTP, touching an unloaded property can add latency and + raise connector errors — this is deliberate, documented behaviour. +- Iterating detail over many devices hydrates one device per iteration (N+1); + use bulk preload helpers (e.g. `app.inspect.preload(...)` / + `get_devices(detail=True)`) for that pattern. +- The snapshot is **not** a single point in time: skeleton and hydrated + subtrees carry their own fetch timestamps. + +Refresh data by building a new snapshot; the state accretes within one +snapshot's lifetime but is never reused across snapshots. + +## Mental Model + +Inspect is built from two data surfaces: + +- A **collector snapshot** describes what the Inspect UI can show right now: + services, path hops, devices, ports, external edges, sync hints, and status. +- A **topology change set** describes what the client wants to commit to the + persisted graph store. + +```mermaid +flowchart LR + Service[Service booking] --> Path[Path hops] + Path --> DeviceA[Device A] + Path --> DeviceB[Device B] + DeviceA --> PortA[Input/output ports] + DeviceB --> PortB[Input/output ports] + PortA --> Edge[External edge status] + Edge --> PortB + DeviceA --> Sync[Sync information] + ChangeSet[Topology change set] --> Graph[nGraphElements store] +``` + +## Collector Snapshot + +The collector response is a REST v2 envelope with a `header` and a `data` object. +The useful Inspect content is under `data.status.collector`. + +```json +{ + "data": { + "status": { + "collector": { + "inspect": { + "paths": { "_items": [] }, + "nodeStatus": { "_items": [] } + }, + "externalEdgesByDeviceKey": { "_items": [] }, + "maintenanceBookings": { "_items": [] }, + "superProfiles": { "_items": [] }, + "tagInfo": { "_items": [] } + } + } + }, + "header": { + "auth": true, + "caption": "Operation Successful", + "code": "OK", + "id": "", + "msg": [], + "ok": true, + "user": "api-user" + } +} +``` + +VideoIPath collections use `_items`. Transport DTOs preserve that wire shape. +`InspectSnapshot` indexes the skeleton data at construction and extends its +indexes incrementally as entities and sections are hydrated. Scoped queries +return the same wire shapes as the full aggregate, just filtered/projected — +one set of DTOs covers both (skeleton items simply have `modules: {}` and +omitted fields). + +## Loaded vs. Unloaded State + +The snapshot tracks, per device and per section, whether detail has been +hydrated and when it was fetched. + +| Data | Backing | Loaded when | +| --- | --- | --- | +| Device identity, `label`, `pid`, `coordinates`, icon/meta, `status`, `sync_severity`, `tags` | Device skeleton query | Snapshot construction | +| Edge connectivity, endpoint ports/labels, status severities | Edge skeleton query | Snapshot construction | +| Device `ports` (modules, port status, `vertexInfo`, port `tagsInfo`, PTP), port-level path drill-down | Per-device `nodeStatus` subtree (`modules/*` projection) | First access on that device | +| `services`, service path structures | `inspect/paths` section query | First access to any service data | +| Active alarms (`.alarms`, `status_message`) | `status/alarms/current` section query | First access to any alarm data | +| Maintenance bookings, super profiles, tag info | Section queries | First access, if exposed | +| Edge bandwidth values, edge `pathDescriptions` | Full per-pair edge shape | Not in the skeleton; loaded with the owning section/entity detail | + +An eager snapshot (`load="full"`, one `GET …/collector/**`) starts fully +hydrated; fixture-built snapshots used in offline tests behave the same, with +lazy loading inert. + +## User-Facing Domain Objects + +These are the classes package users should prefer for read-side workflows. + +### Status severity (`InspectSeverity`) + +Status and alarm severity fields (`status.severity`, `status.sa`, `sync_severity`, +edge live `alarm` / `ptp` / `maintenance` / `bandwidth`) are mapped to +`InspectSeverity`, an `IntEnum` so both the label and the wire int remain usable: + +| Value | Label | +| --- | --- | +| `0` | None | +| `1` | OK | +| `2` | Notice | +| `3` | Warning | +| `4` | Minor | +| `5` | Major | +| `6` | Critical | + +```python +device.status.severity # InspectSeverity.OK +str(device.status.severity) # "OK" +int(device.status.severity) # 1 +device.status.severity == 1 # True +``` + +Unknown wire codes pass through as raw `int` / `str` (never crash). + +### Active alarms (`InspectAlarm`) + +Per-resource alarm messages come from `status/alarms/current` (loaded lazily as a +snapshot section). Each of `device` / `module` / `port` / `edge` / `service` exposes +`.alarms` — a list of `InspectAlarm` sorted worst-severity first. Device also has +`status_message` (the worst alarm's text). + +| Field / property | Meaning | +| --- | --- | +| `message` | Alarm text (e.g. `"Mock driver in use"`, `"Loss of protection"`) | +| `severity` / `sa` | Mapped `InspectSeverity` | +| `acknowledged` / `hidden` | Alarm acknowledgement flags | +| `point_labels` | Human labels for the alarm's point path | +| `alert_id` / `component` | Alarm identity | + +### `InspectDevice` + +Represents one topology device/node with status, sync hints, and relations. + +| Field / property | Meaning | +| --- | --- | +| `id` | Device identifier, for example `device-a` | +| `label` | Display label | +| `status` | Device status summary (`sa` / `severity` as `InspectSeverity`) | +| `sync_severity` | Sync severity from node status (`InspectSeverity`) | +| `alarms` | Active alarms on this device (worst first) | +| `status_message` | Message of the worst active alarm, if any | +| `tags` | Assigned tags | +| `coordinates` | Topology map position when available | +| `ports` | Ports on this device | +| `edges` | External edges touching this device | +| `services` | Services whose path includes this device | +| `linked_devices` | Neighbour devices via edges or shared service paths | + +Lookup: + +```python +device = app.inspect.get_device("device-a") +matches = app.inspect.find_devices_by_label("Example Device A") +print(device.status.severity, device.status_message) +for alarm in device.alarms: + print(alarm.severity, alarm.message) +``` + +### `InspectModule` + +Represents one device module / slot. Modules own ports (and therefore vertices). +Editable attributes (e.g. tags) stage pending intents on the snapshot; flush with +`app.inspect.update(module)` or `tx.update(...)`. Module tags commit via +`assignTag` / `unassignTag`, not `updateTopology`. + +| Field / property | Meaning | +| --- | --- | +| `id` | Module pid | +| `label` / `description` | Display fields | +| `device` | Owning `InspectDevice` | +| `status` | Module status summary (`InspectSeverity` fields) | +| `alarms` | Active alarms correlated to this module | +| `tags` | Locally assigned tags (writable) | +| `ports` | Ports on this module | + +### `InspectPort` + +Represents one module port with status, optional vertex linkage, and an optional +external edge to another device. + +| Field / property | Meaning | +| --- | --- | +| `id` | Port pid | +| `label` | Display label | +| `device` | Owning `InspectDevice` | +| `module_id` | Owning module | +| `status` | Port status summary (`InspectSeverity` fields) | +| `alarms` | Active alarms correlated to this port | +| `vertex_id` | Linked topology vertex when available | +| `tags` | Vertex tag bindings when hydrated (`tagsInfo` from `nodeStatus`) | +| `edge` | External edge when this port connects to another device; otherwise `None` | + +### `InspectVertex` + +Base read/write view of a single topology vertex (one directed endpoint of a +port). Concrete subclasses expose kind-specific fields: + +- `InspectIpVertex` — IP config (address, netmask, VLAN, VRF, support flags) +- `InspectCodecVertex` — codec config (`typeFields.generic` / `typeFields.specific`) +- `InspectGenericVertex` / `InspectResourceTransformVertex` — base fields only + +Built by `InspectSnapshot.get_vertex`, which picks the subclass from the edit +form's `typeFields.type`. Editable attributes stage pending intents; flush with +`app.inspect.update(vertex)` or `tx.update(...)`. + +| Field / property | Meaning | +| --- | --- | +| `id` | Vertex id (e.g. `device-a.module-1.port-out-1.out`) | +| `label` / `description` | Display fields (writable) | +| `vertex_type` | Direction: `"In"` / `"Out"` / … | +| `is_active` / `is_controlled` / `is_endpoint` | Offline status flags from port `vertexInfo` | +| `tags` | Locally assigned vertex tags (writable via `localAssignedTags`) | +| `use_as_endpoint` | Whether usable as a service endpoint | + +### `InspectEdge` + +Represents one external edge status entry between two endpoint devices/ports. + +| Field / property | Meaning | +| --- | --- | +| `id` | Edge identifier | +| `from_device` / `to_device` | Endpoint devices | +| `from_port` / `to_port` | Endpoint ports | +| `bandwidth` / `max_bandwidth` | Bandwidth values | +| `status` | Alarm, bandwidth, maintenance, and PTP summary (`InspectSeverity`) | +| `alarms` | Active alarms correlated to this edge / pair | +| `services` | Services touching either endpoint device | + +### `InspectService` + +Represents one service/booking path across devices and ports. + +| Field / property | Meaning | +| --- | --- | +| `booking_id` | Booking identifier | +| `label` | Service label when available | +| `source` / `destination` | Endpoint labels | +| `source_device` / `destination_device` | Endpoint devices | +| `source_port` / `destination_port` | Endpoint ports | +| `status` | Service status summary (`InspectSeverity` fields) | +| `alarms` | Active alarms correlated to this booking | +| `path_devices` | Ordered devices in the path | +| `path_ports` | Ports encountered in the path | + +Lookup: + +```python +service = app.inspect.get_service_by_booking_id("booking-1001") +all_services = app.inspect.services +``` + +## Transport DTO Examples + +A path item links one service or booking to the devices and ports used to carry +it. This is the best starting point when a caller wants to answer: "Which devices +does this service traverse?" + +```json +{ + "_id": "booking-1001::main", + "_vid": "_:booking-1001::main", + "serviceFields": { + "bid": "booking-1001", + "from": "endpoint-a", + "fromLabel": "Example Source", + "fromPid": "endpoint-a-pid", + "to": "endpoint-b", + "toLabel": "Example Destination", + "toPid": "endpoint-b-pid", + "isMain": true, + "serviceStatus": { + "config": { "sa": 0, "severity": 0 }, + "total": { "sa": 0, "severity": 0 } + } + }, + "path": [ + { + "bid": "booking-1001", + "ipDesc": ":", + "structure": { + "deviceId": "device-a", + "devicePid": "device-a", + "deviceLabel": "Example Device A", + "expectConfig": true, + "inputStatus": { + "pid": "port-a-in", + "label": "Input Port", + "context": { + "devicePid": "device-a", + "modulePid": "module-a", + "portPid": "port-a-in" + }, + "status": { "sa": 0, "severity": 0 } + }, + "outputStatus": { + "pid": "port-a-out", + "label": "Output Port", + "context": { + "devicePid": "device-a", + "modulePid": "module-a", + "portPid": "port-a-out" + }, + "status": { "sa": 0, "severity": 0 } + }, + "moduleAndDeviceStatus": { "sa": 0, "severity": 0 } + } + } + ] +} +``` + +In Python this maps to `InspectApiPathItem`, `InspectApiPathServiceFields`, +`InspectApiPathSegment`, and `InspectApiPathStructure`. + +## Devices, Modules, And Ports + +Node status describes a device-like node as the Inspect UI sees it. It can +include coordinates, modules, ports, status, tags, sync severity, and embedded +references back to service paths. + +```json +{ + "_id": "node-device-a", + "_vid": "_:node-device-a", + "deviceId": "device-a", + "pid": "device-a", + "label": "Example Device A", + "meta": { + "coordinates": { "x": 500, "y": 8150 } + }, + "status": { "sa": 0, "severity": 0 }, + "syncSeverity": 0, + "tags": ["#example-tag"], + "modules": { + "module-a": { + "_id": "module-a", + "pid": "module-a", + "label": "Example Module", + "status": { "sa": 0, "severity": 0 }, + "ports": { + "port-a-out": { + "_id": "port-a-out", + "pid": "port-a-out", + "label": "Output Port", + "status": { "sa": 0, "severity": 0 }, + "vertexInfo": { + "type": "single", + "id": "vertex-a-out", + "label": "Output Vertex", + "vertexType": "ip", + "fields": { + "isActive": true, + "isControlled": true, + "isEndpoint": false + } + } + } + } + } + } +} +``` + +In Python this maps to `InspectApiNodeStatusItem`, `InspectApiModuleStatus`, +`InspectApiPortStatus`, `InspectApiSingleVertexInfo`, and `InspectApiDoubleVertexInfo`. + +## External Edges + +External edge status groups live connectivity between two devices. Each group has +two sides, and each side can contain one or more edge entries keyed by edge ID. + +```json +{ + "_id": "device-a::device-b", + "_vid": "device-a::device-b", + "primary": { + "devicePid": "device-a", + "label": "Example Device A", + "data": { + "edge-a-b-0001": { + "id": "edge-a-b-0001", + "bandwidth": 1000, + "maxBandwidth": 10000, + "ratio": 0.1, + "fromStatus": { + "pid": "port-a-out", + "label": "Output Port", + "status": { "sa": 0, "severity": 0 } + }, + "toStatus": { + "pid": "port-b-in", + "label": "Input Port", + "status": { "sa": 0, "severity": 0 } + }, + "status": { + "alarm": 0, + "bandwidth": 0, + "maintenance": 0, + "ptp": 0 + } + } + } + }, + "secondary": { + "devicePid": "device-b", + "label": "Example Device B", + "data": {} + }, + "status": { + "alarm": 0, + "bandwidth": 0, + "maintenance": 0, + "ptp": 0 + } +} +``` + +In Python this maps to `InspectApiExternalEdgesByDeviceKeyItem`, +`InspectApiExternalEdgeSide`, `InspectApiExternalEdgeStatus`, and +`InspectApiExternalEdgeLiveStatus`. + +## Lookup And Sync Actions + +Action endpoints return focused views for UI workflows. + +`lookupInspectDevice` returns editable/display fields for one device: + +```json +{ + "data": { + "assignedTags": { + "all": [], + "inherited": {}, + "inheritedConflict": false, + "local": {} + }, + "fields": { + "coordinates": { "x": 500, "y": 8150 }, + "descriptor": { + "desc": "Example device description", + "label": "Example Device A" + }, + "iconSize": "medium", + "iconType": "gateway", + "localAssignedTags": [], + "sdpStrategy": "always", + "siteId": null, + "tags": ["#example-tag"], + "virtualDeviceFields": null + } + } +} +``` + +`lookupInspectVertexByIds` returns the editable vertex form, including **vertex +tag bindings** (not available from `nGraphElements` or `app.topology`): + +```json +{ + "data": { + "device-a.module-1.port-out-1.out": { + "assignedTags": { + "all": ["#example-tag"], + "inherited": {}, + "inheritedConflict": false, + "local": { "#example-tag": { "label": "#example-tag", "path": [] } } + }, + "context": { + "devicePid": "device-a", + "modulePid": "device-a.dev.module-1", + "portPid": "device-a.dev.module-1.port-out-1" + }, + "fields": { + "label": "Port A (out)", + "localAssignedTags": ["#example-tag"], + "tags": ["#example-tag"], + "typeFields": { "type": "ip" } + }, + "id": "device-a.module-1.port-out-1.out", + "vertexType": "Out" + } + } +} +``` + +This is the stage-time baseline for vertex tag fields in compare-and-commit +([ADR-007](./decisions/007-write-consistency.md)). + +`lookupSyncInfo` returns per-device sync differences: + +```json +{ + "data": { + "device-a": { + "add": {}, + "label": "Example Device A", + "remove": {}, + "severity": 0, + "update": {} + } + } +} +``` + +`addDevices` and `syncDevices` use the same normal action-result shape when the +action runs: + +```json +{ + "data": { + "msg": [], + "ok": true + }, + "header": { + "auth": true, + "caption": "Operation Successful", + "code": "OK", + "id": "0", + "msg": [], + "ok": true, + "user": "api-user" + } +} +``` + +Some invalid `addDevices` requests can be rejected before an action result is +returned. Those responses contain only the REST header with validation details. + +## Persisted Topology Graph + +The collector snapshot is a status view. Committed Inspect topology data is stored +in `config.network.nGraphElements._items[]`. Each item has an ID, optional +revision, display descriptors, and a `type` value that tells callers which +shape to parse. + +> **Vertex tags are not persisted here.** Device-level tags live on `baseDevice` +> items, but bindings of tags to individual vertices (`ipVertex`, `codecVertex`, +> …) are stored server-side in `videoipath_docs.device_tags`, not in the +> `ngraph` / `nGraphElements` store. `app.topology` therefore has no vertex-tag +> concept. Inspect reads vertex tags from hydrated port `tagsInfo` in +> `nodeStatus` and from `lookupInspectVertexByIds` (`assignedTags`, +> `fields.tags`, `fields.localAssignedTags`) — see +> [concepts.md §3.4](./concepts.md#34-tagging--device-vs-vertex-vs-module-inspect-vs-topology). +> A `tags` field may appear on vertex elements in `nGraphElements` wire examples +> but is not the authoritative store for vertex tag bindings. + +```mermaid +flowchart LR + BaseDevice[baseDevice: device node] + IpVertex[ipVertex: IP port/vertex] + CodecVertex[codecVertex: media endpoint vertex] + GenericVertex[genericVertex: generic vertex] + Edge[unidirectionalEdge: connection] + Transform[nGraphResourceTransform: resource mapping] + + BaseDevice --> IpVertex + BaseDevice --> CodecVertex + BaseDevice --> GenericVertex + IpVertex --> Edge + Edge --> IpVertex + Transform --> Edge +``` + +Example persisted graph elements: + +```json +[ + { + "_id": "device-a", + "_vid": "device-a", + "_rev": "1-example-revision", + "type": "baseDevice", + "descriptor": { + "label": "Example Device A", + "desc": "Example device description" + }, + "fDescriptor": { + "label": "Example Device A", + "desc": "Example device description" + }, + "iconSize": "medium", + "iconType": "gateway", + "isVirtual": false, + "maps": [], + "sdpStrategy": "always", + "siteId": null, + "tags": ["#example-tag"] + }, + { + "_id": "vertex-a-out", + "_vid": "vertex-a-out", + "type": "ipVertex", + "deviceId": "device-a", + "descriptor": { "label": "Output Vertex", "desc": "" }, + "fDescriptor": { "label": "Output Vertex", "desc": "" }, + "gpid": { + "component": 1, + "pointId": ["device-a", "module-a", "port-a-out"] + }, + "ipAddress": "198.51.100.10", + "ipNetmask": "255.255.255.0", + "supportsIgmpCfg": true, + "tags": [] + }, + { + "_id": "edge-a-b-0001", + "_vid": "edge-a-b-0001", + "type": "unidirectionalEdge", + "fromId": "vertex-a-out", + "toId": "vertex-b-in", + "active": true, + "bandwidth": -1, + "capacity": 65535, + "redundancyMode": "Any", + "weight": 0, + "weightFactors": { + "bandwidth": { "weight": 0 }, + "service": { "max": 100, "weight": 0 } + }, + "tags": [] + } +] +``` + +In Python these shapes live in `ngraph.py`. + +## Committing Changes + +`updateTopology` sends the whole change set in one request. Empty maps/lists are a +valid no-op. Non-empty maps upsert graph elements by ID, `addExternalEdges` adds +edge objects, and `remove` deletes graph elements by ID. + +The per-kind payload shapes differ (all verified 2025.4.9, +[endpoints.md](./endpoints.md#post-restv2actionsstatuscollectorupdatetopology)): +**devices use the edit form** (`lookupInspectDevice.fields`; `coordinates` and +`localAssignedTags` are mandatory — the raw persisted `baseDevice` element is +rejected), **vertices use the edit form** (`lookupInspectVertexById.fields`) +and are **update-only** (unknown ids fail validation — vertices come from +device sync, not commits), and **edges use the raw persisted edge form** +(`lookupInspectEdgesByIds` returns it directly). + +```json +{ + "header": { "id": 0 }, + "data": { + "replaceDevices": { + "device-a": { + "coordinates": { "x": 1600.0, "y": 9050.0 }, + "descriptor": { "label": "", "desc": "" }, + "iconSize": "medium", + "iconType": "default", + "localAssignedTags": [], + "sdpStrategy": "always", + "siteId": null, + "tags": [], + "virtualDeviceFields": null + } + }, + "replaceVertices": {}, + "replaceEdges": { + "device-a.module-1.port-out-1.out::device-b.module-1.port-in-1.in": { + "active": true, + "bandwidth": -1.0, + "capacity": 65535, + "conflictPri": 0, + "descriptor": { "label": "", "desc": "" }, + "excludeFormats": [], + "fDescriptor": { "label": "", "desc": "" }, + "fromId": "device-a.module-1.port-out-1.out", + "includeFormats": [], + "redundancyMode": "Any", + "tags": [], + "toId": "device-b.module-1.port-in-1.in", + "weight": 1, + "weightFactors": { + "bandwidth": { "weight": 0 }, + "service": { "max": 100, "weight": 0 } + } + } + }, + "replaceResourceTransforms": {}, + "addExternalEdges": [], + "remove": [], + "force": false + } +} +``` + +A commit is successful only when all three success flags are true: + +```python +response.header.ok and response.data.res.ok and response.data.validation.result.ok +``` + +Validation failures can still arrive with `header.ok == true`, so callers must +inspect `data.res`, `data.validation.result`, and `data.validation.details`. + +### Consistency Against Concurrent Writers + +`updateTopology` enforces no revisions (last-writer-wins), `replace*` entries +are full-object upserts, and collector reads carry no `_rev` — so the change +set protects callers itself +([ADR-007](./decisions/007-write-consistency.md)): + +- **Staging an entity fetches its baseline**: the current form, read via + Inspect-surface lookups (`lookupInspectDevice` for devices, + `lookupInspectVertexByIds` for vertices, `lookupInspectEdgesByIds` for edges + — the latter returns the full persisted edge form, batched; see + [endpoints.md](./endpoints.md#post-restv2actionsstatuscollectorlookupinspectedgesbyids)). + The caller's mutations are applied on top of the baseline, so the committed + payload never clobbers fields built from stale state. +- **`commit()` re-checks before posting**: the touched entities are re-read + and compared against their baselines; any third-party change aborts the whole + commit with a typed conflict error (entity ids + field diffs). Skipping the + check is an explicit opt-in (deliberate last-writer-wins). +- The check is **detection, not enforcement** — a small race window between + re-read and POST remains; the server offers nothing stronger on the Inspect + surface. + +Snapshot data is never used as a baseline — it is a read projection without +revisions, possibly stale by design (lazy hydration). + +### Snapshot Refresh After a Commit + +A successful commit leaves the caller's `InspectSnapshot` stale exactly where +the change set touched it. Instead of a full re-snapshot (~MBs), the snapshot +catches up with **targeted scoped re-reads** +([ADR-008](./decisions/008-post-commit-snapshot-refresh.md)): + +- removed entities are dropped from the indexes locally; +- affected devices and edge pairs (derived from the change-set keys and the + commit response `items[]`) are re-fetched with the same per-device / + per-pair queries the lazy-hydration path uses, replacing their records and + fetch timestamps; +- loaded sections (e.g. services) are marked stale and re-load lazily on next + access; +- the collector projection updates effectively synchronously with the commit + (measured ~25 ms to visibility on 2025.4.9), so the targeted re-read doubles + as the verification — no retry loop. + +A failed commit changes nothing server-side (reject-before-apply), so the +snapshot is left untouched. + +## Python Module Layout + +Transport DTOs are split by payload area under `apps/inspect/model/`: + +- `common.py` — shared envelopes, descriptors, status summaries, action wrappers +- `collector.py` — collector snapshot wire models +- `ngraph.py` — persisted `nGraphElements` wire models +- `actions.py` — lookup/add/sync action request and response DTOs +- `update_topology.py` — `updateTopology` change-set and commit response DTOs +- `alarms.py` — current-alarm wire models (`status/alarms/current`) +- `tags.py` — `assignTag` / `unassignTag` request DTOs +- `virtual.py` — virtual-device and port-template wire models + +User-facing domain models and the snapshot: + +- `snapshot.py` — `InspectSnapshot` and internal indexes +- `transaction.py` — `InspectTransaction` (commit-style writes) +- `domain/device.py` — `InspectDevice` +- `domain/module.py` — `InspectModule` +- `domain/port.py` — `InspectPort` +- `domain/vertex.py` — `InspectVertex` and subclasses +- `domain/edge.py` — `InspectEdge` +- `domain/service.py` — `InspectService` +- `domain/alarm.py` — `InspectAlarm` + +When adding transport fields, prefer extending the nearest existing `InspectApi*` +DTO. When adding user-facing behaviour, extend the domain layer and snapshot +indexes instead of exposing raw HTTP nesting to callers. diff --git a/docs/python-module-architecture.md b/docs/architecture/python-module-architecture.md similarity index 96% rename from docs/python-module-architecture.md rename to docs/architecture/python-module-architecture.md index b09c591..cdff12d 100644 --- a/docs/python-module-architecture.md +++ b/docs/architecture/python-module-architecture.md @@ -6,7 +6,7 @@ The architecture of this Python module is designed for **maintainability** and * The module consists of multiple layers, as visualized in the diagram below: -![Module Architecture](images/module-architecture.svg) +![Module Architecture](../images/module-architecture.svg) ### Business Logic Layer diff --git a/docs/development-and-release.md b/docs/development-and-release.md index 0077964..c0840ce 100644 --- a/docs/development-and-release.md +++ b/docs/development-and-release.md @@ -52,6 +52,59 @@ gitGraph commit ``` +## Testing + +Tests use **pytest**. Install dev and test dependencies first: + +```bash +poetry install --with dev,test +``` + +### Unit and e2e suites + +The suite is split into offline **unit tests** and developer-run **e2e tests** against a live VideoIPath instance: + +| | Unit | E2E | +|---|---|---| +| Location | `tests/` (except `tests/e2e/`) | `tests/e2e/` | +| Marker | _(none)_ | `@pytest.mark.e2e` | +| Environment | Dummy `VIPAT_*` values in `tests/conftest.py` | Project root `.env` (see `.env.template`) | +| CI / default run | yes | no | + +**Commands** (prefer these over bare `pytest`): + +```bash +poetry run test-unit # offline suite; same as CI +poetry run test-e2e # live-server suite +poetry run test # unit, then e2e (stops on first failure) + +# Single file or test — extra args pass through +poetry run test-unit tests/validators/test_device_id.py +poetry run test-e2e tests/e2e/inspect/test_e2e_inspect.py::test_name +``` + +`poetry run pytest` also runs unit tests only (e2e is excluded via `addopts` in `pyproject.toml`). All default runs report coverage on `src/`. + +### E2E setup + +1. Copy `.env.template` to `.env` (gitignored) and set your server connection variables (`VIPAT_VIDEOIPATH_SERVER_ADDRESS`, credentials, etc.). +2. Run `poetry run test-e2e` or `poetry run test` — connection vars are loaded from `.env` automatically; no extra env vars on the command line. + +E2E writes are namespaced (`E2E-` label prefix, `vipat-e2e` tag) so a shared dev instance stays safe. + +### VS Code + +Launch configs in `.vscode/launch.json`: + +- **Unit Tests** / **Unit Tests (current file)** — offline suite +- **E2E Tests** / **E2E Tests (current file)** — live-server suite (loads `.env`, uses the project `.venv`) + +Or run **Tests** from `.vscode/tasks.json` (`poetry run test`). + +### Test data + +Committed fixtures and examples must be anonymized — see `AGENTS.md` for placeholder conventions. + ## Publishing / Releasing new Package Versions In order to publish a new version of the package, update the version in the `pyproject.toml` and create a new GitHub Release associated with a version tag. The release notes should contain all relevant changes, especially breaking changes, features & bug fixes. diff --git a/docs/examples/01_setup/01_connect_and_check.py b/docs/examples/01_setup/01_connect_and_check.py new file mode 100644 index 0000000..8153afa --- /dev/null +++ b/docs/examples/01_setup/01_connect_and_check.py @@ -0,0 +1,69 @@ +"""Connect to a VideoIPath server and verify the connection. + +Description +----------- +The entry point to the whole package is a single ``VideoIPathApp`` object. This example shows the two +ways to construct it (explicit arguments vs. ``VIPAT_*`` environment variables / a ``.env`` file), +how to verify connectivity, read the server version, and how the sub-apps +(``inventory``, ``topology``, ``inspect``, ``preferences``, ``profile``, ``security``) hang off it. + +Every other example in this folder assumes this connection boilerplate and jumps straight into the +relevant sub-app. + +Prerequisites +------------- +- A reachable VideoIPath server and valid credentials. +- videoipath-automation-tool installed (``pip install videoipath-automation-tool``). + +Related examples +---------------- +- 02_inventory/01_create_and_add_device.py +- 04_inspect/01_explore_topology_read_only.py +""" + +from __future__ import annotations + +from videoipath_automation_tool import VideoIPathApp + +# Placeholder values — replace with your environment's data. +SERVER_ADDRESS = "" +USERNAME = "" +PASSWORD = "" + + +def main() -> None: + # --- 1. Connect with explicit arguments ----------------------------------- + # `use_https`/`verify_ssl_cert` default to True; they are relaxed here for a lab server. + app = VideoIPathApp( + server_address=SERVER_ADDRESS, + username=USERNAME, + password=PASSWORD, + use_https=False, + verify_ssl_cert=False, + ) + + # --- 2. Alternative: connect from environment / .env ---------------------- + # With VIPAT_VIDEOIPATH_SERVER_ADDRESS, VIPAT_VIDEOIPATH_USERNAME, VIPAT_VIDEOIPATH_PASSWORD + # (and optional VIPAT_USE_HTTPS / VIPAT_VERIFY_SSL_CERT) set, no arguments are needed: + # + # app = VideoIPathApp() + + # --- 3. Verify the connection --------------------------------------------- + app.check_connection() # raises ConnectionError if the server is unreachable / credentials fail + print("Connected to", SERVER_ADDRESS) + # > Connected to 192.0.2.10 + + print("Server version:", app.get_server_version()) + # > Server version: 2025.4.9 + + # --- 4. Sub-apps are lazily initialized on first access ------------------- + # Each sub-app is the namespace for one area of the API. + print("Inventory devices:", len(app.inventory.list_device_ids_by_driver("com.nevion.NMOS_multidevice-0.1.0"))) + # > Inventory devices: 3 + + print("Topology devices in Inspect:", len(app.inspect.devices)) + # > Topology devices in Inspect: 12 + + +if __name__ == "__main__": + main() diff --git a/docs/examples/02_inventory/01_create_and_add_device.py b/docs/examples/02_inventory/01_create_and_add_device.py new file mode 100644 index 0000000..144cfab --- /dev/null +++ b/docs/examples/02_inventory/01_create_and_add_device.py @@ -0,0 +1,63 @@ +"""Create a device and add it to the inventory. + +Description +----------- +Onboarding a device into VideoIPath starts in the inventory: you create a staged device from a +driver id, fill in its address, label, and driver-specific ``custom_settings``, then add it. The +server assigns the device id, which is only known after ``add_device`` returns. + +The ``custom_settings`` object is a typed model chosen by the driver id, so your editor offers +completion for exactly the fields that driver supports. + +Prerequisites +------------- +- A reachable VideoIPath server and credentials with inventory write access. + +Related examples +---------------- +- 02_inventory/02_get_update_and_diff_device.py +- 02_inventory/05_driver_settings_and_queries.py +""" + +from __future__ import annotations + +from videoipath_automation_tool import VideoIPathApp + +SERVER_ADDRESS = "" +USERNAME = "" +PASSWORD = "" + +DRIVER = "com.nevion.NMOS_multidevice-0.1.0" + + +def main() -> None: + # --- 1. Connect ----------------------------------------------------------- + app = VideoIPathApp(server_address=SERVER_ADDRESS, username=USERNAME, password=PASSWORD, use_https=False) + + # --- 2. Create a staged device from a driver ------------------------------ + device = app.inventory.create_device(driver=DRIVER) + device.configuration.label = "device-a" + device.configuration.address = "192.0.2.20" + device.configuration.description = "Example media node" + + # Driver-specific settings are typed for the chosen driver (IntelliSense-friendly). + device.configuration.custom_settings.port = 8080 + device.configuration.custom_settings.indices_in_ids = False + + # --- 3. Add it to the inventory ------------------------------------------- + # `label_check`/`address_check` (both True by default) raise if a duplicate already exists. + online_device = app.inventory.add_device(device=device) + + print(f"Added '{online_device.configuration.label}' as {online_device.configuration.device_id}") + # > Added 'device-a' as device34 + + # --- 4. Re-adding the same label raises ----------------------------------- + try: + app.inventory.add_device(device=device) + except ValueError as error: + print("Rejected:", error) + # > Rejected: Device with label 'device-a' already exists in Inventory: ['device34'] + + +if __name__ == "__main__": + main() diff --git a/docs/examples/02_inventory/02_get_update_and_diff_device.py b/docs/examples/02_inventory/02_get_update_and_diff_device.py new file mode 100644 index 0000000..9242f47 --- /dev/null +++ b/docs/examples/02_inventory/02_get_update_and_diff_device.py @@ -0,0 +1,77 @@ +"""Fetch, diff, and update a device (idempotent writes). + +Description +----------- +This example shows the read-modify-write cycle for an inventory device and the "diff before write" +pattern that makes automation idempotent: stage the desired configuration, compare it against what is +already on the server, and only call ``update_device`` when something actually changed. Re-running the +same script is then a no-op. + +It also covers fetching a device by id, label, or address, and refreshing its live status. + +Prerequisites +------------- +- A reachable VideoIPath server; a device named ``device-a`` already in the inventory + (see 02_inventory/01_create_and_add_device.py). + +Related examples +---------------- +- 02_inventory/01_create_and_add_device.py +- 06_workflows/01_full_onboarding_pipeline.py +""" + +from __future__ import annotations + +from videoipath_automation_tool import VideoIPathApp + +SERVER_ADDRESS = "" +USERNAME = "" +PASSWORD = "" + +DRIVER = "com.nevion.NMOS_multidevice-0.1.0" + + +def main() -> None: + # --- 1. Connect ----------------------------------------------------------- + app = VideoIPathApp(server_address=SERVER_ADDRESS, username=USERNAME, password=PASSWORD, use_https=False) + + # --- 2. Fetch a device (by label, id, or address) ------------------------- + # Passing `custom_settings_type` gives typed access to the driver settings. + device = app.inventory.get_device( + label="device-a", + label_search_mode="user_defined_label_only", + custom_settings_type=DRIVER, + ) + print(device.configuration.device_id, device.configuration.address) + # > device34 192.0.2.20 + + # Equivalent lookups: + # app.inventory.get_device(device_id="device34") + # app.inventory.get_device(address="192.0.2.20") + + # --- 3. Stage the desired configuration ----------------------------------- + reference = app.inventory.get_device(device_id=device.configuration.device_id) + device.configuration.description = "Primary media node" + device.configuration.custom_settings.port = 8080 + + # --- 4. Diff, then write only when there is a change ----------------------- + diff = app.inventory.diff_device_configuration(reference_device=reference, staged_device=device) + changes = diff.configuration_diff + if changes.added or changes.changed or changes.removed: + app.inventory.update_device(device=device) + print("Updated device (changes applied).") + # > Updated device (changes applied). + else: + print("No changes — nothing to write.") + # > No changes — nothing to write. + + # --- 5. Refresh the live status ------------------------------------------- + # Device status is not updated automatically; refresh it explicitly. + app.inventory.refresh_device_status(device=device) + if device.status: + print("Reachable:", device.status.reachable) + # > Reachable: True + + +if __name__ == "__main__": + main() diff --git a/docs/examples/02_inventory/03_discovery_onboarding.py b/docs/examples/02_inventory/03_discovery_onboarding.py new file mode 100644 index 0000000..8d2e2a2 --- /dev/null +++ b/docs/examples/02_inventory/03_discovery_onboarding.py @@ -0,0 +1,67 @@ +"""Onboard auto-discovered devices. + +Description +----------- +VideoIPath discovers devices on the network before they are added to the inventory. This example +lists the discovered devices, builds an inventory device from a discovery entry's suggested +configuration, adds it, and shows how to enable/disable a device afterward. + +Prerequisites +------------- +- A reachable VideoIPath server with at least one discovered (but not yet onboarded) device. + +Related examples +---------------- +- 02_inventory/01_create_and_add_device.py +""" + +from __future__ import annotations + +from videoipath_automation_tool import VideoIPathApp + +SERVER_ADDRESS = "" +USERNAME = "" +PASSWORD = "" + + +def main() -> None: + # --- 1. Connect ----------------------------------------------------------- + app = VideoIPathApp(server_address=SERVER_ADDRESS, username=USERNAME, password=PASSWORD, use_https=False) + + # --- 2. List discovered devices ------------------------------------------- + discovered = app.inventory.get_discovered_devices() + for entry in discovered: + suggested_driver = entry.suggestedConfigs[0].driver if entry.suggestedConfigs else None + print(f"{entry.id} | already onboarded as: {entry.exists} | driver: {suggested_driver}") + # > OF-00:00:00:00:00:01 | already onboarded as: [] | driver: name='openflow' organization='com.nevion' ... + + # --- 3. Onboard the first not-yet-existing discovered device -------------- + candidate = next((entry for entry in discovered if not entry.exists), None) + if candidate is None: + print("Nothing new to onboard.") + # > Nothing new to onboard. + return + + device = app.inventory.create_device_from_discovered_device( + discovered_device_id=candidate.id, + driver="com.nevion.openflow-0.0.1", + ) + device.configuration.label = "device-b" + + online_device = app.inventory.add_device(device) + device_id = online_device.configuration.device_id + print(f"Onboarded {candidate.id} as {device_id}") + # > Onboarded OF-00:00:00:00:00:01 as device35 + + # --- 4. Enable / disable a device ----------------------------------------- + disabled = app.inventory.disable_device(device_id) + print("Active after disable:", disabled.configuration.active) + # > Active after disable: False + + enabled = app.inventory.enable_device(device_id) + print("Active after enable:", enabled.configuration.active) + # > Active after enable: True + + +if __name__ == "__main__": + main() diff --git a/docs/examples/02_inventory/04_backup_restore_clone.py b/docs/examples/02_inventory/04_backup_restore_clone.py new file mode 100644 index 0000000..8ffd8e8 --- /dev/null +++ b/docs/examples/02_inventory/04_backup_restore_clone.py @@ -0,0 +1,74 @@ +"""Back up, restore, and clone device configurations. + +Description +----------- +A device configuration can be dumped to a plain dict (easily serialized to JSON) for backup, then +parsed back into a device object to restore or compare it. The same mechanism clones a device: strip +its device id and add it again — either on the same server (a copy) or on a second server (migration). + +Prerequisites +------------- +- A reachable VideoIPath server; a device named ``device-a`` in the inventory. +- For the cross-server clone: a second reachable server. + +Related examples +---------------- +- 02_inventory/02_get_update_and_diff_device.py +""" + +from __future__ import annotations + +import json +from pathlib import Path + +from videoipath_automation_tool import VideoIPathApp + +SERVER_ADDRESS = "" +USERNAME = "" +PASSWORD = "" + +DRIVER = "com.nevion.NMOS_multidevice-0.1.0" +BACKUP_FILE = Path("device-a.backup.json") + + +def main() -> None: + # --- 1. Connect ----------------------------------------------------------- + app = VideoIPathApp(server_address=SERVER_ADDRESS, username=USERNAME, password=PASSWORD, use_https=False) + + device = app.inventory.get_device( + label="device-a", label_search_mode="user_defined_label_only", custom_settings_type=DRIVER + ) + + # --- 2. Back up the configuration to a JSON file -------------------------- + config_dump = app.inventory.dump_configuration(device) + BACKUP_FILE.write_text(json.dumps(config_dump, indent=4)) + print("Backed up to", BACKUP_FILE) + # > Backed up to device-a.backup.json + + # --- 3. Restore: load the backup and diff against the live config --------- + restored = app.inventory.parse_configuration(json.loads(BACKUP_FILE.read_text())) + live = app.inventory.get_device(device_id=restored.configuration.device_id) + diff = app.inventory.diff_device_configuration(reference_device=restored, staged_device=live) + if diff.configuration_diff.changed: + print("Live config drifted from the backup; run update_device(restored) to restore it.") + # > Live config drifted from the backup; run update_device(restored) to restore it. + + # --- 4. Clone on the same server ------------------------------------------ + # Removing the device id turns the object into a fresh "staged" device. + clone = app.inventory.parse_configuration(config_dump) + clone.configuration.label = "device-a-clone" + clone.configuration.address = "192.0.2.21" + clone.remove_device_id() + cloned_device = app.inventory.add_device(clone) + print("Cloned as", cloned_device.configuration.device_id) + # > Cloned as device36 + + # --- 5. Clone onto a second server (migration) ---------------------------- + # migration = app.inventory.parse_configuration(config_dump) + # migration.remove_device_id() + # prod_app = VideoIPathApp(server_address="198.51.100.10", username=USERNAME, password=PASSWORD, use_https=False) + # prod_app.inventory.add_device(migration) + + +if __name__ == "__main__": + main() diff --git a/docs/examples/02_inventory/05_driver_settings_and_queries.py b/docs/examples/02_inventory/05_driver_settings_and_queries.py new file mode 100644 index 0000000..9b1b131 --- /dev/null +++ b/docs/examples/02_inventory/05_driver_settings_and_queries.py @@ -0,0 +1,64 @@ +"""Driver-specific settings and inventory queries. + +Description +----------- +Every driver exposes its own typed ``custom_settings`` model, so the fields you can set depend on the +driver id you pass to ``create_device``. This example inspects and edits those settings, then shows +the common inventory lookup helpers (all device ids for a driver, id-by-label) and a short global SNMP +configuration section. + +Prerequisites +------------- +- A reachable VideoIPath server with devices in the inventory. + +Related examples +---------------- +- 02_inventory/01_create_and_add_device.py +""" + +from __future__ import annotations + +from videoipath_automation_tool import VideoIPathApp + +SERVER_ADDRESS = "" +USERNAME = "" +PASSWORD = "" + +DRIVER = "com.nevion.NMOS_multidevice-0.1.0" + + +def main() -> None: + # --- 1. Connect ----------------------------------------------------------- + app = VideoIPathApp(server_address=SERVER_ADDRESS, username=USERNAME, password=PASSWORD, use_https=False) + + # --- 2. Inspect and edit typed driver settings ---------------------------- + device = app.inventory.create_device(driver=DRIVER) + settings = device.configuration.custom_settings + + # The available fields are specific to this driver; read defaults, then change them. + print("Default NMOS port:", settings.port) + # > Default NMOS port: 8080 + settings.port = 8000 + settings.indices_in_ids = True + + # --- 3. Query the inventory ----------------------------------------------- + device_ids = app.inventory.list_device_ids_by_driver(DRIVER) + print(f"{len(device_ids)} device(s) use this driver: {device_ids}") + # > 2 device(s) use this driver: ['device34', 'device36'] + + device_id = app.inventory.find_device_id_by_label("device-a", label_search_mode="user_defined_label_only") + print("device-a ->", device_id) + # > device-a -> device34 + + # --- 4. Global SNMP configurations ---------------------------------------- + snmp_configs = app.inventory.get_all_global_snmp_config_ids() + print("Global SNMP configs:", snmp_configs) + # > Global SNMP configs: {'default': 'default'} + + default_snmp = app.inventory.get_global_snmp_config("default") + print("Default SNMP read community:", default_snmp.security.read.community) + # > Default SNMP read community: public + + +if __name__ == "__main__": + main() diff --git a/docs/examples/03_topology_and_inspect/01_device_lifecycle_inspect.py b/docs/examples/03_topology_and_inspect/01_device_lifecycle_inspect.py new file mode 100644 index 0000000..bbc336d --- /dev/null +++ b/docs/examples/03_topology_and_inspect/01_device_lifecycle_inspect.py @@ -0,0 +1,82 @@ +"""Device lifecycle in the topology (Inspect app): add, configure, remove. + +Description +----------- +Once a device exists in the inventory it can be placed into the topology graph, given display metadata +(label, description, tags, icon, coordinates), and later removed. This is the Inspect-app variant; the +paired ``01_device_lifecycle_topology.py`` implements the same scenario with the classic Topology app. + +The recommended write style is: edit the domain object's properties and flush them with +``app.inspect.update(obj)`` (a unit of work that commits all pending edits). The keyword-argument +methods (``app.inspect.update_device(...)``) do the same thing in one call and are shown at the end as +an alternative. + +Prerequisites +------------- +- A reachable VideoIPath server, VideoIPath >= 2025.4. +- A device named ``device-a`` already in the inventory (see 02_inventory/01_create_and_add_device.py). + +Related examples +---------------- +- 03_topology_and_inspect/01_device_lifecycle_topology.py +- 04_inspect/01_explore_topology_read_only.py +""" + +from __future__ import annotations + +from videoipath_automation_tool import VideoIPathApp + +SERVER_ADDRESS = "" +USERNAME = "" +PASSWORD = "" + + +def main() -> None: + # --- 1. Connect ----------------------------------------------------------- + app = VideoIPathApp(server_address=SERVER_ADDRESS, username=USERNAME, password=PASSWORD, use_https=False) + + device_id = app.inventory.find_device_id_by_label("device-a", label_search_mode="user_defined_label_only") + assert isinstance(device_id, str) + + # --- 2. Add the device to the topology graph ------------------------------ + # Places at (x, y) and syncs driver-reported ports/vertices (sync=True by default). + app.inspect.add_devices_to_topology([(device_id, 1000, 500)]) + + # --- 3. Apply base configuration via property setters (recommended) ------- + device = app.inspect.get_device(device_id) + assert device is not None + device.label = "leaf-1" + device.description = "Top-of-rack leaf switch" + device.tags = ["site-a", "leaf"] + device.icon_type = "ipSwitchRouter" + device.coordinates = {"x": 1000, "y": 500} + app.inspect.update(device) # commits every pending edit on the device in one transaction + print("Configured", device.label) + # > Configured leaf-1 + + # --- 4. Remove the device from the topology (guarded) --------------------- + # Refuse to remove a device that still carries booked services. + affected = app.inspect.get_services_for_device(device_id) + if affected: + print(f"Skipping removal: {len(affected)} service(s) still use this device.") + # > Skipping removal: 2 service(s) still use this device. + else: + app.inspect.remove_device_from_topology(device_id) + print("Removed from topology.") + # > Removed from topology. + + # --- 5. Alternative: keyword-style update in a single call ---------------- + # Equivalent to the setter block in step 3, without holding a domain object: + # + # app.inspect.update_device( + # device_id, + # label="leaf-1", + # description="Top-of-rack leaf switch", + # tags=["site-a", "leaf"], + # icon_type="ipSwitchRouter", + # coordinates={"x": 1000, "y": 500}, + # ) + + +if __name__ == "__main__": + main() diff --git a/docs/examples/03_topology_and_inspect/01_device_lifecycle_topology.py b/docs/examples/03_topology_and_inspect/01_device_lifecycle_topology.py new file mode 100644 index 0000000..e4095ff --- /dev/null +++ b/docs/examples/03_topology_and_inspect/01_device_lifecycle_topology.py @@ -0,0 +1,71 @@ +"""Device lifecycle in the topology (Topology app): add, configure, remove. + +Description +----------- +Once a device exists in the inventory it can be placed into the topology graph, given display metadata +(label, description, tags, icon, coordinates), and later removed. This is the classic Topology-app +variant; the paired ``01_device_lifecycle_inspect.py`` implements the same scenario with the +forward-looking Inspect app. + +With the Topology app you fetch a ``TopologyDevice``, mutate its nested ``configuration`` object, and +push the whole device with ``update_device`` (which diffs and writes only the changed graph elements). + +Prerequisites +------------- +- A reachable VideoIPath server; a device named ``device-a`` in the inventory. +- NOTE: the Topology app is deprecated on VideoIPath 2025.x and unsupported on 2026.x, where its + constructor raises ``TopologyUnsupportedError``. On modern servers prefer the paired + ``01_device_lifecycle_inspect.py``. + +Related examples +---------------- +- 03_topology_and_inspect/01_device_lifecycle_inspect.py +""" + +from __future__ import annotations + +from videoipath_automation_tool import VideoIPathApp + +SERVER_ADDRESS = "" +USERNAME = "" +PASSWORD = "" + + +def main() -> None: + # --- 1. Connect ----------------------------------------------------------- + app = VideoIPathApp(server_address=SERVER_ADDRESS, username=USERNAME, password=PASSWORD, use_https=False) + + device_id = app.inventory.find_device_id_by_label("device-a", label_search_mode="user_defined_label_only") + assert isinstance(device_id, str) + + # --- 2. Fetch the device's driver-generated graph ------------------------- + # get_device synthesizes the device from the driver if it is not yet placed in the topology. + device = app.topology.get_device(device_id=device_id) + + # --- 3. Apply base configuration via the configuration object ------------- + config = device.configuration + config.label = "leaf-1" + config.description = "Top-of-rack leaf switch" + config.tags = ["site-a", "leaf"] + config.icon_type = "ipSwitchRouter" + config.position_x = 1000 + config.position_y = 500 + + # update_device places the device if new, and writes only the changed graph elements. + updated = app.topology.update_device(device) + print("Configured", updated.configuration.label) + # > Configured leaf-1 + + # --- 4. Remove the device from the topology (guarded) --------------------- + affected = app.topology.list_services_affected_by_device_remove(device) + if affected: + print(f"Skipping removal: {len(affected)} service(s) still use this device.") + # > Skipping removal: 2 service(s) still use this device. + else: + app.topology.remove_device_by_id(device_id=device_id) + print("Removed from topology.") + # > Removed from topology. + + +if __name__ == "__main__": + main() diff --git a/docs/examples/03_topology_and_inspect/02_configure_vertices_inspect.py b/docs/examples/03_topology_and_inspect/02_configure_vertices_inspect.py new file mode 100644 index 0000000..4a03d07 --- /dev/null +++ b/docs/examples/03_topology_and_inspect/02_configure_vertices_inspect.py @@ -0,0 +1,73 @@ +"""Configure device vertices (Inspect app): endpoints, SIPS, media tags. + +Description +----------- +A device's vertices describe its media inputs/outputs (codec vertices) and network interfaces (IP +vertices). This example marks the codec vertices as usable endpoints, sets their SIPS mode, and tags +them with media profiles pulled from the profile app — the same pattern a vendor "vertex processor" +uses. The paired ``02_configure_vertices_topology.py`` does the same with the Topology app. + +The recommended write style edits each vertex's properties, collects the dirty objects, and flushes +them together with a single ``app.inspect.update([...])`` call. + +Prerequisites +------------- +- A reachable VideoIPath server, VideoIPath >= 2025.4. +- A device named ``leaf-1`` in the topology whose ports/vertices are synced. + +Related examples +---------------- +- 03_topology_and_inspect/02_configure_vertices_topology.py +- 05_administration/02_profiles.py +""" + +from __future__ import annotations + +from videoipath_automation_tool import VideoIPathApp + +SERVER_ADDRESS = "" +USERNAME = "" +PASSWORD = "" + + +def main() -> None: + # --- 1. Connect ----------------------------------------------------------- + app = VideoIPathApp(server_address=SERVER_ADDRESS, username=USERNAME, password=PASSWORD, use_https=False) + + device = app.inspect.find_device_by_label("leaf-1") + assert device is not None + + # Media profile tags to assign to endpoints (falls back to a fixed list if none are defined). + profile_tags = app.profile.list_profile_names() or ["V_1080i50", "V_1080p50"] + + # --- 2. Configure every codec vertex via property setters ----------------- + edited = [] + for vertex in device.codec_vertices: + vertex.use_as_endpoint = True + vertex.sips_mode = "SIPSAuto" + vertex.active = True + vertex.tags = profile_tags + edited.append(vertex) + + # --- 3. Flush all edits in a single unit of work -------------------------- + if edited: + app.inspect.update(edited) + print(f"Configured {len(edited)} codec vertices.") + # > Configured 8 codec vertices. + + # --- 4. Target a single vertex by its factory label ----------------------- + uplink = device.find_vertex_by_factory_label("port-out-1") + if uplink is not None: + uplink.label = "Uplink to spine-1" + app.inspect.update(uplink) + print("Labeled uplink vertex", uplink.id) + # > Labeled uplink vertex device34.1.3000000 + + # --- 5. Alternative: keyword-style update --------------------------------- + # A single vertex can also be edited without holding the object: + # + # app.inspect.update_vertex(uplink.id, use_as_endpoint=True, sips_mode="SIPSAuto", tags=profile_tags) + + +if __name__ == "__main__": + main() diff --git a/docs/examples/03_topology_and_inspect/02_configure_vertices_topology.py b/docs/examples/03_topology_and_inspect/02_configure_vertices_topology.py new file mode 100644 index 0000000..d4985e0 --- /dev/null +++ b/docs/examples/03_topology_and_inspect/02_configure_vertices_topology.py @@ -0,0 +1,65 @@ +"""Configure device vertices (Topology app): endpoints, SIPS, media tags. + +Description +----------- +A device's vertices describe its media inputs/outputs (codec vertices) and network interfaces (IP +vertices). This example marks the codec vertices as usable endpoints, sets their SIPS mode, and tags +them with media profiles pulled from the profile app. The paired ``02_configure_vertices_inspect.py`` +does the same with the Inspect app. + +With the Topology app you mutate the vertex objects held inside ``device.configuration`` and then push +the whole device once with ``update_device`` — a single write covers all the vertex changes. + +Prerequisites +------------- +- A reachable VideoIPath server; a device named ``leaf-1`` in the topology. +- NOTE: the Topology app is deprecated on VideoIPath 2025.x and unsupported on 2026.x (its + constructor raises ``TopologyUnsupportedError``). On modern servers prefer the paired + ``02_configure_vertices_inspect.py``. + +Related examples +---------------- +- 03_topology_and_inspect/02_configure_vertices_inspect.py +- 05_administration/02_profiles.py +""" + +from __future__ import annotations + +from videoipath_automation_tool import VideoIPathApp + +SERVER_ADDRESS = "" +USERNAME = "" +PASSWORD = "" + + +def main() -> None: + # --- 1. Connect ----------------------------------------------------------- + app = VideoIPathApp(server_address=SERVER_ADDRESS, username=USERNAME, password=PASSWORD, use_https=False) + + device_id = app.topology.find_device_id_by_label("leaf-1", label_search_mode="user_defined_label_only") + assert isinstance(device_id, str) + device = app.topology.get_device(device_id=device_id) + + profile_tags = app.profile.list_profile_names() or ["V_1080i50", "V_1080p50"] + + # --- 2. Configure every codec vertex in the configuration object ---------- + for vertex in device.configuration.codec_vertices: + vertex.use_as_endpoint = True + vertex.sips_mode = "SIPSAuto" + vertex.sdp_support = True + vertex.tags = profile_tags + + # --- 3. Target a single vertex by its factory label ----------------------- + uplink = device.configuration.get_vertex_by_label("port-out-1", label_type="factory") + if uplink is not None: + uplink.label = "Uplink to spine-1" + + # --- 4. Push the whole device once ---------------------------------------- + # update_device diffs against the server and writes only the changed graph elements. + app.topology.update_device(device=device) + print(f"Configured vertices on {device.configuration.label}.") + # > Configured vertices on leaf-1. + + +if __name__ == "__main__": + main() diff --git a/docs/examples/03_topology_and_inspect/03_connect_devices_with_edges_inspect.py b/docs/examples/03_topology_and_inspect/03_connect_devices_with_edges_inspect.py new file mode 100644 index 0000000..be4b861 --- /dev/null +++ b/docs/examples/03_topology_and_inspect/03_connect_devices_with_edges_inspect.py @@ -0,0 +1,85 @@ +"""Connect two devices with edges (Inspect app). + +Description +----------- +A physical link between two devices is modeled as a pair of directed edges (one per direction) between +an out-vertex on one device and an in-vertex on the other. This example connects ``leaf-1`` to +``spine-1`` bidirectionally, then tunes the resulting edge's routing weight. The paired +``03_connect_devices_with_edges_topology.py`` does the same with the Topology app. + +Edge creation has no property-setter form, so ``connect`` is used directly; editing an existing edge +uses the recommended setter + ``app.inspect.update(edge)`` style. + +Prerequisites +------------- +- A reachable VideoIPath server, VideoIPath >= 2025.4. +- Devices ``leaf-1`` and ``spine-1`` in the topology with synced ports. + +Related examples +---------------- +- 03_topology_and_inspect/03_connect_devices_with_edges_topology.py +- 04_inspect/02_transactions_and_conflicts.py +""" + +from __future__ import annotations + +from videoipath_automation_tool import VideoIPathApp +from videoipath_automation_tool.apps.inspect import InspectDevice +from videoipath_automation_tool.apps.inspect.domain import InspectVertex + +SERVER_ADDRESS = "" +USERNAME = "" +PASSWORD = "" + + +def first_free_port_vertices(device: InspectDevice) -> tuple[InspectVertex, InspectVertex]: + """Return the (out, in) vertices of the device's first usable port.""" + for port in device.ports: + if port.vertex_out is not None and port.vertex_in is not None: + return port.vertex_out, port.vertex_in + raise LookupError(f"No connectable port found on {device.label}.") + + +def main() -> None: + # --- 1. Connect ----------------------------------------------------------- + app = VideoIPathApp(server_address=SERVER_ADDRESS, username=USERNAME, password=PASSWORD, use_https=False) + + leaf = app.inspect.find_device_by_label("leaf-1") + spine = app.inspect.find_device_by_label("spine-1") + assert leaf is not None and spine is not None + + leaf_out, leaf_in = first_free_port_vertices(leaf) + spine_out, spine_in = first_free_port_vertices(spine) + + # --- 2. Create the bidirectional link ------------------------------------- + # bidirectional=True also stages the reverse edge. Extra edge fields (bandwidth, weight, + # redundancy) are passed straight through. Bandwidth here reserves 10% headroom (90% of 10G). + app.inspect.connect( + leaf_out.id, + spine_in.id, + bidirectional=True, + bandwidth=int(10_000 * 0.9), + redundancy_mode="OnlyMain", + ) + print(f"Connected {leaf.label} <-> {spine.label}") + # > Connected leaf-1 <-> spine-1 + + # --- 3. Verify connectivity ----------------------------------------------- + neighbours = {d.label for d in leaf.linked_devices} + print("leaf-1 neighbours:", neighbours) + # > leaf-1 neighbours: {'spine-1'} + + # --- 4. Tune an existing edge via setter (recommended) -------------------- + edge = next((e for e in leaf.edges if e.to_device and e.to_device.label == "spine-1"), None) + if edge is not None: + edge.weight = 7 + app.inspect.update(edge) + print("Set edge weight to", edge.weight) + # > Set edge weight to 7 + + # --- 5. Remove the link ---------------------------------------------------- + # app.inspect.disconnect(leaf_out.id, spine_in.id, bidirectional=True) + + +if __name__ == "__main__": + main() diff --git a/docs/examples/03_topology_and_inspect/03_connect_devices_with_edges_topology.py b/docs/examples/03_topology_and_inspect/03_connect_devices_with_edges_topology.py new file mode 100644 index 0000000..c295ebf --- /dev/null +++ b/docs/examples/03_topology_and_inspect/03_connect_devices_with_edges_topology.py @@ -0,0 +1,68 @@ +"""Connect two devices with edges (Topology app). + +Description +----------- +A physical link between two devices is modeled as directed edges between their vertices. This example +connects ``leaf-1`` to ``spine-1`` using ``create_edges`` (which resolves the correct vertex pairing +from factory labels), attaches the edges to the device, and pushes it. The paired +``03_connect_devices_with_edges_inspect.py`` does the same with the Inspect app. + +Prerequisites +------------- +- A reachable VideoIPath server; devices ``leaf-1`` and ``spine-1`` in the topology. +- NOTE: the Topology app is deprecated on VideoIPath 2025.x and unsupported on 2026.x (its + constructor raises ``TopologyUnsupportedError``). On modern servers prefer the paired + ``03_connect_devices_with_edges_inspect.py``. + +Related examples +---------------- +- 03_topology_and_inspect/03_connect_devices_with_edges_inspect.py +""" + +from __future__ import annotations + +from videoipath_automation_tool import VideoIPathApp + +SERVER_ADDRESS = "" +USERNAME = "" +PASSWORD = "" + + +def main() -> None: + # --- 1. Connect ----------------------------------------------------------- + app = VideoIPathApp(server_address=SERVER_ADDRESS, username=USERNAME, password=PASSWORD, use_https=False) + + leaf_id = app.topology.find_device_id_by_label("leaf-1", label_search_mode="user_defined_label_only") + spine_id = app.topology.find_device_id_by_label("spine-1", label_search_mode="user_defined_label_only") + assert isinstance(leaf_id, str) and isinstance(spine_id, str) + + # --- 2. Build the directed edges between two ports ------------------------ + # create_edges pairs the out/in vertices behind the given factory labels. `bandwidth_factor` + # reserves headroom (0.9 -> use 90% of the nominal bandwidth); redundancy_mode tags the link. + edges = app.topology.create_edges( + device_1_id=leaf_id, + device_1_vertex_factory_label="port-out-1", + device_2_id=spine_id, + device_2_vertex_factory_label="port-in-1", + bandwidth=10000, + bandwidth_factor=0.9, + redundancy_mode="OnlyMain", + ) + + # --- 3. Attach the edges to the device and push --------------------------- + leaf = app.topology.get_device(device_id=leaf_id) + leaf.configuration.external_edges.extend(edges) + app.topology.update_device(device=leaf) + print(f"Connected leaf-1 <-> spine-1 with {len(edges)} directed edge(s).") + # > Connected leaf-1 <-> spine-1 with 2 directed edge(s). + + # --- 4. Tune an existing edge --------------------------------------------- + edge = edges[0] + edge.weight = 7 + app.topology.update_element(edge) + print("Set edge weight to", edge.weight) + # > Set edge weight to 7 + + +if __name__ == "__main__": + main() diff --git a/docs/examples/03_topology_and_inspect/04_grid_placement_inspect.py b/docs/examples/03_topology_and_inspect/04_grid_placement_inspect.py new file mode 100644 index 0000000..44c12d9 --- /dev/null +++ b/docs/examples/03_topology_and_inspect/04_grid_placement_inspect.py @@ -0,0 +1,64 @@ +"""Lay out devices in a grid (Inspect app). + +Description +----------- +Automated topology builds usually arrange devices on the map programmatically. This example computes a +tidy grid for a set of leaf/spine devices and moves them there — but only writes the positions that +actually changed, so re-running it is a no-op. The paired ``04_grid_placement_topology.py`` does the +same with the Topology app. + +Prerequisites +------------- +- A reachable VideoIPath server, VideoIPath >= 2025.4. +- The devices below present in the topology. + +Related examples +---------------- +- 03_topology_and_inspect/04_grid_placement_topology.py +""" + +from __future__ import annotations + +from videoipath_automation_tool import VideoIPathApp + +SERVER_ADDRESS = "" +USERNAME = "" +PASSWORD = "" + +DEVICE_LABELS = ["leaf-1", "leaf-2", "leaf-3", "leaf-4", "spine-1", "spine-2"] + +ORIGIN_X, ORIGIN_Y = 1000, 500 +COLUMNS = 3 +SPACING = 300 + + +def main() -> None: + # --- 1. Connect ----------------------------------------------------------- + app = VideoIPathApp(server_address=SERVER_ADDRESS, username=USERNAME, password=PASSWORD, use_https=False) + + # --- 2. Compute the target grid coordinates ------------------------------- + targets: dict[str, tuple[int, int]] = {} + for index, label in enumerate(DEVICE_LABELS): + row, col = divmod(index, COLUMNS) + targets[label] = (ORIGIN_X + col * SPACING, ORIGIN_Y + row * SPACING) + + # --- 3. Move only the devices whose position changed ---------------------- + moved = 0 + with app.inspect.transaction() as tx: + for label, (x, y) in targets.items(): + device = app.inspect.find_device_by_label(label) + if device is None: + continue + current = device.coordinates or {} + if (current.get("x"), current.get("y")) == (x, y): + continue # already in place — skip the write + tx.place_device(device.id, x, y) + moved += 1 + tx.commit() + + print(f"Repositioned {moved} device(s).") + # > Repositioned 6 device(s). + + +if __name__ == "__main__": + main() diff --git a/docs/examples/03_topology_and_inspect/04_grid_placement_topology.py b/docs/examples/03_topology_and_inspect/04_grid_placement_topology.py new file mode 100644 index 0000000..ec764bf --- /dev/null +++ b/docs/examples/03_topology_and_inspect/04_grid_placement_topology.py @@ -0,0 +1,67 @@ +"""Lay out devices in a grid (Topology app). + +Description +----------- +Automated topology builds usually arrange devices on the map programmatically. This example reads the +current positions with ``placement.get_all_device_positions``, computes a tidy grid, and moves only the +devices whose position changed. The paired ``04_grid_placement_inspect.py`` does the same with the +Inspect app. + +Prerequisites +------------- +- A reachable VideoIPath server with the devices below present in the topology. +- NOTE: the Topology app is deprecated on VideoIPath 2025.x and unsupported on 2026.x (its + constructor raises ``TopologyUnsupportedError``). On modern servers prefer the paired + ``04_grid_placement_inspect.py``. + +Related examples +---------------- +- 03_topology_and_inspect/04_grid_placement_inspect.py +""" + +from __future__ import annotations + +from videoipath_automation_tool import VideoIPathApp + +SERVER_ADDRESS = "" +USERNAME = "" +PASSWORD = "" + +DEVICE_LABELS = ["leaf-1", "leaf-2", "leaf-3", "leaf-4", "spine-1", "spine-2"] + +ORIGIN_X, ORIGIN_Y = 1000, 500 +COLUMNS = 3 +SPACING = 300 + + +def main() -> None: + # --- 1. Connect ----------------------------------------------------------- + app = VideoIPathApp(server_address=SERVER_ADDRESS, username=USERNAME, password=PASSWORD, use_https=False) + + # --- 2. Read the current positions of all devices ------------------------- + current_positions = app.topology.placement.get_all_device_positions() + + # --- 3. Move only the devices whose position changed ---------------------- + moved = 0 + for index, label in enumerate(DEVICE_LABELS): + device_id = app.topology.find_device_id_by_label(label, label_search_mode="user_defined_label_only") + if not isinstance(device_id, str): + continue + + row, col = divmod(index, COLUMNS) + x, y = ORIGIN_X + col * SPACING, ORIGIN_Y + row * SPACING + + current = current_positions.get(device_id, {}) + if (current.get("x"), current.get("y")) == (x, y): + continue # already in place — skip the write + + # fetch_device=False keeps bulk repositioning fast (no re-fetch after each move). + app.topology.placement.set_device_position(device_id, x=x, y=y, fetch_device=False) + moved += 1 + + print(f"Repositioned {moved} device(s).") + # > Repositioned 6 device(s). + + +if __name__ == "__main__": + main() diff --git a/docs/examples/03_topology_and_inspect/05_virtual_device_inspect.py b/docs/examples/03_topology_and_inspect/05_virtual_device_inspect.py new file mode 100644 index 0000000..e888ee7 --- /dev/null +++ b/docs/examples/03_topology_and_inspect/05_virtual_device_inspect.py @@ -0,0 +1,60 @@ +"""Create a virtual (driverless) device (Inspect app). + +Description +----------- +Virtual devices model endpoints that have no real driver — patch panels, tie-line groups, or external +switches. This example builds one from a port-template spec and places it on the map. The paired +``05_virtual_device_topology.py`` does the same with the Topology app. + +A virtual device is created unplaced; afterward it behaves like any other device (``place_device``, +metadata edits via setters, ``remove_device_from_topology``). + +Prerequisites +------------- +- A reachable VideoIPath server, VideoIPath >= 2025.4. + +Related examples +---------------- +- 03_topology_and_inspect/05_virtual_device_topology.py +""" + +from __future__ import annotations + +from videoipath_automation_tool import VideoIPathApp +from videoipath_automation_tool.apps.inspect import VirtualDeviceSpec + +SERVER_ADDRESS = "" +USERNAME = "" +PASSWORD = "" + + +def main() -> None: + # --- 1. Connect ----------------------------------------------------------- + app = VideoIPathApp(server_address=SERVER_ADDRESS, username=USERNAME, password=PASSWORD, use_https=False) + + # --- 2. Inspect the available port templates ------------------------------ + templates = app.inspect.list_port_templates() + for template in templates: + print(f"{template.id}: {template.label} ({template.direction})") + # > ip_in: IP input (In) + # > ip_out: IP output (Out) + + # --- 3. Build a virtual-device spec --------------------------------------- + # from_ports takes template ids (optionally as (template_id, count) tuples). + spec = VirtualDeviceSpec.from_ports(("ip_in", 2), ("ip_out", 2)) + + # --- 4. Create and place the device --------------------------------------- + device = app.inspect.create_virtual_device(spec) + print("Created virtual device", device.id) + # > Created virtual device virtual.1 + + device.label = "tieline-a" + device.tags = ["virtual", "tieline"] + app.inspect.update(device) + app.inspect.place_device(device.id, x=1500, y=900) + print("Placed", device.label) + # > Placed tieline-a + + +if __name__ == "__main__": + main() diff --git a/docs/examples/03_topology_and_inspect/05_virtual_device_topology.py b/docs/examples/03_topology_and_inspect/05_virtual_device_topology.py new file mode 100644 index 0000000..eddc72e --- /dev/null +++ b/docs/examples/03_topology_and_inspect/05_virtual_device_topology.py @@ -0,0 +1,53 @@ +"""Create a virtual (driverless) device (Topology app). + +Description +----------- +Virtual devices model endpoints that have no real driver — patch panels, tie-line groups, or external +switches. This example builds one by adding a virtual switching-core module and codec vertices, then +adds it to the topology (which assigns the next free ``virtual.N`` id). The paired +``05_virtual_device_inspect.py`` does the same with the Inspect app. + +Prerequisites +------------- +- A reachable VideoIPath server. +- NOTE: the Topology app is deprecated on VideoIPath 2025.x and unsupported on 2026.x (its + constructor raises ``TopologyUnsupportedError``). Virtual-device editing here is experimental. On + modern servers prefer the paired ``05_virtual_device_inspect.py``. + +Related examples +---------------- +- 03_topology_and_inspect/05_virtual_device_inspect.py +""" + +from __future__ import annotations + +from videoipath_automation_tool import VideoIPathApp + +SERVER_ADDRESS = "" +USERNAME = "" +PASSWORD = "" + + +def main() -> None: + # --- 1. Connect ----------------------------------------------------------- + app = VideoIPathApp(server_address=SERVER_ADDRESS, username=USERNAME, password=PASSWORD, use_https=False) + + # --- 2. Build the virtual device ------------------------------------------ + device = app.topology.create_virtual_device() + device.configuration.label = "tieline-a" + device.configuration.tags = ["virtual", "tieline"] + + # Add a switching core, then attach codec vertices to it. + device.add_virtual_module() + device.add_virtual_codec_vertex(vertex_direction="In", codec_format="Video", module_number=0) + device.add_virtual_codec_vertex(vertex_direction="Out", codec_format="Video", module_number=0) + + # --- 3. Add it to the topology -------------------------------------------- + # add_device_initially assigns the next free virtual.N id automatically. + app.topology.add_device_initially(device) + print("Created virtual device", device.configuration.base_device.id) + # > Created virtual device virtual.1 + + +if __name__ == "__main__": + main() diff --git a/docs/examples/04_inspect/01_explore_topology_read_only.py b/docs/examples/04_inspect/01_explore_topology_read_only.py new file mode 100644 index 0000000..adba296 --- /dev/null +++ b/docs/examples/04_inspect/01_explore_topology_read_only.py @@ -0,0 +1,73 @@ +"""Explore the network topology (read-only). + +Description +----------- +A safe, read-only tour of the Inspect app: list devices, edges, and services, walk from a device to +its ports, vertices, and neighbours, and look devices up by label. It also explains skeleton vs. full +loading and the ``preload`` call that avoids N+1 fetches when you need detail for many devices. + +Nothing here writes to the server, so it is a good first script to run against any environment. + +Prerequisites +------------- +- A reachable VideoIPath server, VideoIPath >= 2025.4. + +Related examples +---------------- +- 04_inspect/03_services_and_paths.py +- 06_workflows/02_network_audit_report.py +""" + +from __future__ import annotations + +from videoipath_automation_tool import VideoIPathApp + +SERVER_ADDRESS = "" +USERNAME = "" +PASSWORD = "" + + +def main() -> None: + # --- 1. Connect ----------------------------------------------------------- + app = VideoIPathApp(server_address=SERVER_ADDRESS, username=USERNAME, password=PASSWORD, use_https=False) + + # --- 2. Skeleton reads (no per-device detail I/O) ------------------------- + # The first read builds a fast "skeleton" view: all devices and edges, no port detail. + print(f"{len(app.inspect.devices)} devices, {len(app.inspect.edges)} edges") + # > 12 devices, 20 edges + + for device in app.inspect.devices: + print(device.id, device.label, device.status) + # > device34 leaf-1 + + # --- 3. Look up a device and walk its detail ------------------------------ + device = app.inspect.find_device_by_label("leaf-1") + assert device is not None + + # The first access to .ports hydrates this one device (a single scoped fetch), then caches it. + for port in device.ports: + out_vertex = port.vertex_out.id if port.vertex_out else "-" + in_vertex = port.vertex_in.id if port.vertex_in else "-" + print(f"{port.label}: out={out_vertex} in={in_vertex}") + # > Router Out 1: out=device34.1.0 in=- + + for neighbour in device.linked_devices: # local graph walk, no I/O + print("linked to", neighbour.label) + # > linked to spine-1 + + # --- 4. Preload many devices in parallel ---------------------------------- + # Hydrate everything up front to avoid one fetch per device in a loop. + app.inspect.preload() + hydrated = sum(1 for d in app.inspect.devices if app.inspect.is_device_hydrated(d.id)) + print(f"{hydrated} device(s) hydrated.") + # > 12 device(s) hydrated. + + # --- 5. Skeleton vs. full loading ----------------------------------------- + # "full" loads the entire topology eagerly in one request (a point-in-time snapshot). + app.inspect.refresh(load="full") + print("Reloaded eagerly.") + # > Reloaded eagerly. + + +if __name__ == "__main__": + main() diff --git a/docs/examples/04_inspect/02_transactions_and_conflicts.py b/docs/examples/04_inspect/02_transactions_and_conflicts.py new file mode 100644 index 0000000..bfce8d0 --- /dev/null +++ b/docs/examples/04_inspect/02_transactions_and_conflicts.py @@ -0,0 +1,86 @@ +"""Batch changes with transactions and handle concurrent edits. + +Description +----------- +A transaction stages several changes and commits them atomically — either all apply or none do. This +example batches a device edit, a vertex edit, and a new connection into one commit, then shows how to +handle a concurrent modification: the commit detects it, raises ``InspectCommitConflictError``, and you +``rebase`` onto fresh server state and retry. + +The recommended way to stage domain-object edits into a transaction is ``tx.update(objects)``; +the keyword-style ``tx.update_device(...)`` is shown as an alternative. + +Prerequisites +------------- +- A reachable VideoIPath server, VideoIPath >= 2025.4. +- Devices ``leaf-1`` and ``spine-1`` in the topology with synced ports. + +Related examples +---------------- +- 03_topology_and_inspect/03_connect_devices_with_edges_inspect.py +""" + +from __future__ import annotations + +from videoipath_automation_tool import VideoIPathApp +from videoipath_automation_tool.apps.inspect import InspectCommitConflictError, InspectCommitError + +SERVER_ADDRESS = "" +USERNAME = "" +PASSWORD = "" + +MAX_RETRIES = 3 + + +def main() -> None: + # --- 1. Connect ----------------------------------------------------------- + app = VideoIPathApp(server_address=SERVER_ADDRESS, username=USERNAME, password=PASSWORD, use_https=False) + + leaf = app.inspect.find_device_by_label("leaf-1") + spine = app.inspect.find_device_by_label("spine-1") + assert leaf is not None and spine is not None + + leaf_out = next((p.vertex_out for p in leaf.ports if p.vertex_out), None) + spine_in = next((p.vertex_in for p in spine.ports if p.vertex_in), None) + assert leaf_out is not None and spine_in is not None + + # --- 2. Stage several changes and commit them atomically ------------------ + leaf.description = "Rack A leaf" + leaf_out.use_as_endpoint = True + try: + with app.inspect.transaction() as tx: + tx.update([leaf, leaf_out]) # stage setter edits into the transaction + tx.connect(leaf_out.id, spine_in.id, bidirectional=True) # edge creation (no setter form) + result = tx.commit() + print("Committed:", result.applied_ids) + # > Committed: ['device34', 'device34.1.0', ...] + except InspectCommitError as error: + # The server rejected the commit (validation / apply gate) — nothing was written. + print("Server rejected the commit:", error) + return + + # Alternative keyword style for the same device edit: + # tx.update_device(leaf.id, description="Rack A leaf") + + # --- 3. Handle a concurrent modification with rebase + retry -------------- + # Stage the edit once, then retry the commit; rebase re-fetches baselines and keeps our intent. + leaf.description = "Rack A leaf (updated)" + tx = app.inspect.transaction() + tx.update(leaf) + for attempt in range(1, MAX_RETRIES + 1): + try: + tx.commit() + print("Committed on attempt", attempt) + # > Committed on attempt 1 + break + except InspectCommitConflictError as conflict: + # Someone changed leaf-1 since we staged it. + print(f"Conflict on {[c.entity_id for c in conflict.conflicts]}; rebasing.") + tx.rebase() + else: + print("Gave up after", MAX_RETRIES, "attempts.") + tx.discard() + + +if __name__ == "__main__": + main() diff --git a/docs/examples/04_inspect/03_services_and_paths.py b/docs/examples/04_inspect/03_services_and_paths.py new file mode 100644 index 0000000..30a0109 --- /dev/null +++ b/docs/examples/04_inspect/03_services_and_paths.py @@ -0,0 +1,69 @@ +"""Inspect services and their paths. + +Description +----------- +Services are the booked media connections routed across the topology. This example lists them, looks +one up by booking id, prints its source, destination, and the devices its path traverses, and shows +the "service-impact guard": before changing or removing a device, check which services still depend on +it. + +This is read-only except for the illustrative guard at the end. + +Prerequisites +------------- +- A reachable VideoIPath server, VideoIPath >= 2025.4, with at least one booked service. + +Related examples +---------------- +- 04_inspect/01_explore_topology_read_only.py +- 03_topology_and_inspect/01_device_lifecycle_inspect.py +""" + +from __future__ import annotations + +from videoipath_automation_tool import VideoIPathApp + +SERVER_ADDRESS = "" +USERNAME = "" +PASSWORD = "" + + +def main() -> None: + # --- 1. Connect ----------------------------------------------------------- + app = VideoIPathApp(server_address=SERVER_ADDRESS, username=USERNAME, password=PASSWORD, use_https=False) + + # --- 2. List services ----------------------------------------------------- + services = app.inspect.services + print(f"{len(services)} service(s) booked.") + # > 4 service(s) booked. + + for service in services: + print(f"{service.booking_id}: {service.source} -> {service.destination} [{service.status}]") + # > booking-1: leaf-1 -> spine-1 [] + + if not services: + return + + # --- 3. Resolve one service and print its path ---------------------------- + service = app.inspect.get_service_by_booking_id(services[0].booking_id) + assert service is not None + print("Source device:", service.source_device.label if service.source_device else "-") + # > Source device: leaf-1 + path = " -> ".join(device.label or device.id for device in service.path_devices) + print("Path:", path) + # > Path: leaf-1 -> spine-1 -> leaf-2 + + # --- 4. Service-impact guard before touching a device --------------------- + device = app.inspect.find_device_by_label("leaf-1") + if device is not None: + affected = app.inspect.get_services_for_device(device.id) + if affected: + print(f"leaf-1 carries {len(affected)} service(s); change it with care.") + # > leaf-1 carries 2 service(s); change it with care. + else: + print("leaf-1 carries no services; safe to change.") + # > leaf-1 carries no services; safe to change. + + +if __name__ == "__main__": + main() diff --git a/docs/examples/05_administration/01_security_domains_and_memberships.py b/docs/examples/05_administration/01_security_domains_and_memberships.py new file mode 100644 index 0000000..114f2c6 --- /dev/null +++ b/docs/examples/05_administration/01_security_domains_and_memberships.py @@ -0,0 +1,85 @@ +"""Sync security domains and device memberships. + +Description +----------- +Security domains partition resources (devices, profiles) for access control. This example reconciles a +desired set of domains against the server — creating what is missing, updating descriptions, and +removing what is stale — while always protecting the built-in ``Default`` domain. It then reconciles a +single device's domain memberships with a read → compare → write-only-on-change pattern. + +This mirrors how an external source of truth (e.g. site/tenant data) drives domain management. + +Prerequisites +------------- +- A reachable VideoIPath server and credentials with security-administration access. +- A device named ``device-a`` in the inventory. + +Related examples +---------------- +- 06_workflows/01_full_onboarding_pipeline.py +""" + +from __future__ import annotations + +from videoipath_automation_tool import VideoIPathApp + +SERVER_ADDRESS = "" +USERNAME = "" +PASSWORD = "" + +PROTECTED_DOMAIN = "Default" +DESIRED_DOMAINS = { + "site-a": "Devices at site A", + "site-b": "Devices at site B", + "site-c": "Devices at site C", +} + + +def main() -> None: + # --- 1. Connect ----------------------------------------------------------- + app = VideoIPathApp(server_address=SERVER_ADDRESS, username=USERNAME, password=PASSWORD, use_https=False) + + # --- 2. Reconcile the set of domains -------------------------------------- + existing = set(app.security.domains.list_domain_names()) + + # Create missing domains. + for name, description in DESIRED_DOMAINS.items(): + if name not in existing: + app.security.domains.create_domain(name=name, description=description) + print("Created domain", name) + # > Created domain site-a + + # Update descriptions that drifted. + for name, description in DESIRED_DOMAINS.items(): + if name in existing: + domain = app.security.domains.get_domain_by_name(domain_name=name) + if domain.description != description: + domain.description = description + app.security.domains.update_domain(domain) + print("Updated domain", name) + + # Remove stale domains (never the protected one). + for name in existing - set(DESIRED_DOMAINS) - {PROTECTED_DOMAIN}: + app.security.domains.remove_domain(app.security.domains.get_domain_by_name(domain_name=name)) + print("Removed domain", name) + + # --- 3. Reconcile one device's domain memberships ------------------------- + device_id = app.inventory.find_device_id_by_label("device-a", label_search_mode="user_defined_label_only") + assert isinstance(device_id, str) + + memberships = app.security.resources.get_device_memberships(device_id=device_id) + current = set(app.security.resources.convert_domain_ids_to_names(memberships.domains)) + desired = {"site-a"} + + if current != desired: + memberships.domains = app.security.resources.convert_domain_names_to_ids(sorted(desired)) + app.security.resources.update_memberships(memberships=memberships) + print(f"Set {device_id} memberships to {sorted(desired)}") + # > Set device34 memberships to ['site-a'] + else: + print("Memberships already correct.") + # > Memberships already correct. + + +if __name__ == "__main__": + main() diff --git a/docs/examples/05_administration/02_profiles.py b/docs/examples/05_administration/02_profiles.py new file mode 100644 index 0000000..6d012dc --- /dev/null +++ b/docs/examples/05_administration/02_profiles.py @@ -0,0 +1,58 @@ +"""Manage profiles. + +Description +----------- +Profiles describe media formats and are referenced as vertex tags when configuring endpoints. This +example lists profile names, fetches one, creates a new profile, clones an existing one as a template, +and cleans up the clone. + +Prerequisites +------------- +- A reachable VideoIPath server and credentials with profile-management access. + +Related examples +---------------- +- 03_topology_and_inspect/02_configure_vertices_inspect.py +""" + +from __future__ import annotations + +from videoipath_automation_tool import VideoIPathApp + +SERVER_ADDRESS = "" +USERNAME = "" +PASSWORD = "" + + +def main() -> None: + # --- 1. Connect ----------------------------------------------------------- + app = VideoIPathApp(server_address=SERVER_ADDRESS, username=USERNAME, password=PASSWORD, use_https=False) + + # --- 2. List existing profiles -------------------------------------------- + names = app.profile.list_profile_names() or [] + print(f"{len(names)} profile(s): {names[:5]}") + # > 12 profile(s): ['V_1080i50', 'V_1080p50', 'A_2CH_LR', ...] + + # --- 3. Create a new profile ---------------------------------------------- + profile = app.profile.create_profile(name="profile-a") + app.profile.add_profile(profile) + print("Created profile-a") + # > Created profile-a + + # --- 4. Clone an existing profile as a template --------------------------- + if names: + source = app.profile.get_profile_by_name(names[0]) + if source is not None and not isinstance(source, list): + clone = app.profile.clone_profile(source) + app.profile.add_profile(clone) + print("Cloned", names[0], "->", clone.name) + # > Cloned V_1080i50 -> V_1080i50 (clone) + + # --- 5. Clean up the clone -------------------------------------------- + app.profile.remove_profile(profile=clone) + print("Removed the clone.") + # > Removed the clone. + + +if __name__ == "__main__": + main() diff --git a/docs/examples/05_administration/03_multicast_pools.py b/docs/examples/05_administration/03_multicast_pools.py new file mode 100644 index 0000000..a95c461 --- /dev/null +++ b/docs/examples/05_administration/03_multicast_pools.py @@ -0,0 +1,63 @@ +"""Configure multicast allocation pools. + +Description +----------- +Multicast allocation pools are the IP ranges VideoIPath draws from when assigning multicast addresses +to services. This example reads the existing pools and their utilization, creates a new pool, extends +it with an extra range, and removes it again. + +Prerequisites +------------- +- A reachable VideoIPath server and credentials with system-configuration access. + +Related examples +---------------- +- 04_inspect/01_explore_topology_read_only.py +""" + +from __future__ import annotations + +from videoipath_automation_tool import VideoIPathApp + +SERVER_ADDRESS = "" +USERNAME = "" +PASSWORD = "" + +POOL_NAME = "pool-a" + + +def main() -> None: + # --- 1. Connect ----------------------------------------------------------- + app = VideoIPathApp(server_address=SERVER_ADDRESS, username=USERNAME, password=PASSWORD, use_https=False) + allocation_pools = app.preferences.system_configuration.allocation_pools + + # --- 2. Read existing pools and utilization ------------------------------- + ranges = allocation_pools.get_multicast_ranges() + print("Pools:", ranges.available_ranges) + # > Pools: ['default'] + for name in ranges.available_ranges: + pool = allocation_pools.get_multicast_range_by_name(name) + print(f" {name}: {pool.utilization.percentage}% used") + # > default: 0% used + + # --- 3. Create a new pool ------------------------------------------------- + staged = allocation_pools.create_multicast_range(name=POOL_NAME, start_ip="239.0.10.0", end_ip="239.0.10.255") + allocation_pools.add_multicast_range(staged) + print("Created", POOL_NAME) + # > Created pool-a + + # --- 4. Extend the pool with another range -------------------------------- + pool = allocation_pools.get_multicast_range_by_name(POOL_NAME) + pool.add_ip_range(start_ip="239.0.11.0", end_ip="239.0.11.255") + allocation_pools.update_multicast_range(pool) + print(f"{POOL_NAME} now has {len(pool.ranges)} range(s).") + # > pool-a now has 2 range(s). + + # --- 5. Remove the pool --------------------------------------------------- + allocation_pools.remove_multicast_range(POOL_NAME) + print("Removed", POOL_NAME) + # > Removed pool-a + + +if __name__ == "__main__": + main() diff --git a/docs/examples/06_workflows/01_full_onboarding_pipeline.py b/docs/examples/06_workflows/01_full_onboarding_pipeline.py new file mode 100644 index 0000000..36652f3 --- /dev/null +++ b/docs/examples/06_workflows/01_full_onboarding_pipeline.py @@ -0,0 +1,168 @@ +"""Full onboarding pipeline: external source of truth to a running topology. + +Description +----------- +This is the flagship example — it stitches the individual operations into one end-to-end pipeline, +modeled on a real "sync from an external source of truth" workflow (e.g. an IPAM/DCIM system). Given a +small inline description of the desired network, it: + +1. creates or updates each device in the inventory (diff before write), +2. waits until the devices are reachable, +3. adds them to the topology at computed grid coordinates, +4. applies base configuration and endpoint settings in one transaction, +5. connects the devices per the cabling data (reserving bandwidth headroom), +6. assigns each device to its site's security domain. + +A ``DRY_RUN`` flag runs the whole thing without writing, and because every step is diff-based, running +it twice is a no-op. + +Prerequisites +------------- +- A reachable VideoIPath server, VideoIPath >= 2025.4, with inventory + security write access. + +Related examples +---------------- +- 02_inventory/02_get_update_and_diff_device.py +- 03_topology_and_inspect/03_connect_devices_with_edges_inspect.py +- 05_administration/01_security_domains_and_memberships.py +""" + +from __future__ import annotations + +import time + +from videoipath_automation_tool import VideoIPathApp + +SERVER_ADDRESS = "" +USERNAME = "" +PASSWORD = "" + +DRIVER = "com.nevion.NMOS_multidevice-0.1.0" +DRY_RUN = True # set to False to actually write to the server +BANDWIDTH_HEADROOM = 0.9 # use 90% of the nominal link bandwidth + +# The desired network, as it might come from an external source of truth. +DESIRED_DEVICES = [ + {"label": "leaf-1", "address": "192.0.2.21", "site": "site-a"}, + {"label": "leaf-2", "address": "192.0.2.22", "site": "site-a"}, + {"label": "spine-1", "address": "192.0.2.31", "site": "site-a"}, +] +DESIRED_LINKS = [ + {"a": "leaf-1", "b": "spine-1", "bandwidth": 10000}, + {"a": "leaf-2", "b": "spine-1", "bandwidth": 10000}, +] + + +def main() -> None: + # --- 1. Connect ----------------------------------------------------------- + app = VideoIPathApp(server_address=SERVER_ADDRESS, username=USERNAME, password=PASSWORD, use_https=False) + print(f"{'DRY RUN — ' if DRY_RUN else ''}syncing {len(DESIRED_DEVICES)} device(s)") + + # --- 2. Inventory: create or update each device (diff before write) ------- + device_ids: dict[str, str] = {} + for spec in DESIRED_DEVICES: + device_ids[spec["label"]] = sync_inventory_device(app, spec) + + if DRY_RUN: + print("Dry run complete — no changes written.") + return + + # --- 3. Wait until the devices are reachable ------------------------------ + for label, device_id in device_ids.items(): + if wait_until_reachable(app, device_id): + print(f"{label} is reachable.") + + # --- 4. Topology: add devices at a grid position -------------------------- + # add_devices_to_topology syncs driver-reported ports/vertices by default. + app.inspect.add_devices_to_topology( + [(device_id, 1000 + i * 300, 500) for i, device_id in enumerate(device_ids.values())] + ) + + # --- 5. Base configuration + endpoints in one transaction ----------------- + with app.inspect.transaction() as tx: + for spec in DESIRED_DEVICES: + device = app.inspect.get_device(device_ids[spec["label"]]) + if device is None: + continue + device.label = spec["label"] + device.tags = [spec["site"]] + for vertex in device.codec_vertices: + vertex.use_as_endpoint = True + vertex.sips_mode = "SIPSAuto" + tx.update(device) # cascades the device + its dirty vertices + tx.commit() + + # --- 6. Connect devices per the cabling data ------------------------------ + for link in DESIRED_LINKS: + connect_devices(app, device_ids[link["a"]], device_ids[link["b"]], link["bandwidth"]) + + # --- 7. Assign each device to its site's security domain ------------------ + domain_names = set(app.security.domains.list_domain_names()) + for spec in DESIRED_DEVICES: + if spec["site"] not in domain_names: + app.security.domains.create_domain(name=spec["site"], description=f"Devices at {spec['site']}") + domain_names.add(spec["site"]) + assign_domain(app, device_ids[spec["label"]], spec["site"]) + + print("Onboarding complete.") + + +def sync_inventory_device(app: VideoIPathApp, spec: dict[str, str]) -> str: + """Create the device, or update it when its configuration drifted. Returns the device id.""" + existing_id = app.inventory.find_device_id_by_label(spec["label"], label_search_mode="user_defined_label_only") + + if not isinstance(existing_id, str): + staged = app.inventory.create_device(driver=DRIVER) + staged.configuration.label = spec["label"] + staged.configuration.address = spec["address"] + print(f" + create {spec['label']} ({spec['address']})") + if DRY_RUN: + return "" + return app.inventory.add_device(staged).configuration.device_id + + reference = app.inventory.get_device(device_id=existing_id, custom_settings_type=DRIVER) + staged = app.inventory.get_device(device_id=existing_id, custom_settings_type=DRIVER) + staged.configuration.address = spec["address"] + + diff = app.inventory.diff_device_configuration(reference_device=reference, staged_device=staged) + if diff.configuration_diff.changed: + print(f" ~ update {spec['label']}") + if not DRY_RUN: + app.inventory.update_device(device=staged) + return existing_id + + +def wait_until_reachable(app: VideoIPathApp, device_id: str, *, attempts: int = 10, delay: int = 3) -> bool: + """Poll the device status until it reports reachable (bounded retry).""" + device = app.inventory.get_device(device_id=device_id) + for _ in range(attempts): + app.inventory.refresh_device_status(device=device) + if device.status and device.status.reachable: + return True + time.sleep(delay) + return False + + +def connect_devices(app: VideoIPathApp, id_a: str, id_b: str, bandwidth: int) -> None: + """Connect the first free port of each device bidirectionally.""" + device_a, device_b = app.inspect.get_device(id_a), app.inspect.get_device(id_b) + if device_a is None or device_b is None: + return + out_a = next((p.vertex_out for p in device_a.ports if p.vertex_out), None) + in_b = next((p.vertex_in for p in device_b.ports if p.vertex_in), None) + if out_a is None or in_b is None: + return + app.inspect.connect(out_a.id, in_b.id, bidirectional=True, bandwidth=int(bandwidth * BANDWIDTH_HEADROOM)) + + +def assign_domain(app: VideoIPathApp, device_id: str, domain_name: str) -> None: + """Ensure the device belongs to exactly the given domain (write only on change).""" + memberships = app.security.resources.get_device_memberships(device_id=device_id) + current = set(app.security.resources.convert_domain_ids_to_names(memberships.domains)) + if current != {domain_name}: + memberships.domains = app.security.resources.convert_domain_names_to_ids([domain_name]) + app.security.resources.update_memberships(memberships=memberships) + + +if __name__ == "__main__": + main() diff --git a/docs/examples/06_workflows/02_network_audit_report.py b/docs/examples/06_workflows/02_network_audit_report.py new file mode 100644 index 0000000..5768fa1 --- /dev/null +++ b/docs/examples/06_workflows/02_network_audit_report.py @@ -0,0 +1,81 @@ +"""Read-only network audit report. + +Description +----------- +A strictly read-only, cross-app audit you can run against production safely. It reports devices in the +inventory that are missing from the topology, unreachable devices, endpoint vertices without media +tags, edges left at the default weight, services per device, and multicast pool utilization. + +Because it never writes, this is a good first script to point at any environment. + +Prerequisites +------------- +- A reachable VideoIPath server, VideoIPath >= 2025.4. + +Related examples +---------------- +- 04_inspect/01_explore_topology_read_only.py +- 06_workflows/03_bulk_retag_and_relabel.py +""" + +from __future__ import annotations + +from videoipath_automation_tool import VideoIPathApp + +SERVER_ADDRESS = "" +USERNAME = "" +PASSWORD = "" + +DRIVER = "com.nevion.NMOS_multidevice-0.1.0" + + +def main() -> None: + # --- 1. Connect ----------------------------------------------------------- + app = VideoIPathApp(server_address=SERVER_ADDRESS, username=USERNAME, password=PASSWORD, use_https=False) + print(f"Audit of {SERVER_ADDRESS} (VideoIPath {app.get_server_version()})") + + # --- 2. Inventory vs. topology coverage ----------------------------------- + topology_ids = {device.id for device in app.inspect.devices} + inventory_ids = app.inventory.list_device_ids_by_driver(DRIVER) + missing = [device_id for device_id in inventory_ids if device_id not in topology_ids] + print(f"\nInventory devices not in the topology: {len(missing)}") + for device_id in missing: + print(" -", device_id) + + # --- 3. Unreachable devices ----------------------------------------------- + print("\nUnreachable devices:") + for device_id in inventory_ids: + device = app.inventory.get_device(device_id=device_id) + app.inventory.refresh_device_status(device=device) + if device.status and not device.status.reachable: + print(" -", device.configuration.label) + + # --- 4. Endpoints without media tags -------------------------------------- + app.inspect.preload() + print("\nEndpoint vertices without tags:") + for device in app.inspect.devices: + untagged = [v for v in device.codec_vertices if v.is_endpoint and not v.tags] + if untagged: + print(f" - {device.label}: {len(untagged)} untagged endpoint(s)") + + # --- 5. Edges left at the default weight ---------------------------------- + default_weight_edges = [edge for edge in app.inspect.edges if not edge.weight] + print(f"\nEdges at default weight: {len(default_weight_edges)}") + + # --- 6. Services per device ----------------------------------------------- + print("\nServices per device:") + for device in app.inspect.devices: + services = app.inspect.get_services_for_device(device.id) + if services: + print(f" - {device.label}: {len(services)} service(s)") + + # --- 7. Multicast pool utilization ---------------------------------------- + allocation_pools = app.preferences.system_configuration.allocation_pools + print("\nMulticast pool utilization:") + for name in allocation_pools.get_multicast_ranges().available_ranges: + pool = allocation_pools.get_multicast_range_by_name(name) + print(f" - {name}: {pool.utilization.percentage}%") + + +if __name__ == "__main__": + main() diff --git a/docs/examples/06_workflows/03_bulk_retag_and_relabel.py b/docs/examples/06_workflows/03_bulk_retag_and_relabel.py new file mode 100644 index 0000000..9897525 --- /dev/null +++ b/docs/examples/06_workflows/03_bulk_retag_and_relabel.py @@ -0,0 +1,80 @@ +"""Bulk re-labeling and re-tagging. + +Description +----------- +Applies a naming/tagging policy across a fleet of devices: select devices by a label prefix, compute +the change set, print a dry-run report, then apply every change in a single Inspect transaction. The +matching inventory labels are updated too, so both views stay consistent. + +This demonstrates the change-set + dry-run + batch-commit pattern on a realistic bulk edit. + +Prerequisites +------------- +- A reachable VideoIPath server, VideoIPath >= 2025.4. + +Related examples +---------------- +- 06_workflows/02_network_audit_report.py +- 03_topology_and_inspect/02_configure_vertices_inspect.py +""" + +from __future__ import annotations + +from videoipath_automation_tool import VideoIPathApp + +SERVER_ADDRESS = "" +USERNAME = "" +PASSWORD = "" + +DRY_RUN = True # set to False to apply the changes +OLD_PREFIX = "cam-" +NEW_PREFIX = "camera-" +ADD_TAG = "camera" + + +def main() -> None: + # --- 1. Connect ----------------------------------------------------------- + app = VideoIPathApp(server_address=SERVER_ADDRESS, username=USERNAME, password=PASSWORD, use_https=False) + + # --- 2. Compute the change set -------------------------------------------- + changes: list[tuple[str, str, str]] = [] # (device_id, old_label, new_label) + for device in app.inspect.devices: + label = device.label or "" + if not label.startswith(OLD_PREFIX): + continue + new_label = NEW_PREFIX + label[len(OLD_PREFIX) :] + if new_label != label or ADD_TAG not in device.tags: + changes.append((device.id, label, new_label)) + + # --- 3. Dry-run report ---------------------------------------------------- + print(f"{len(changes)} device(s) to update:") + for _, old_label, new_label in changes: + print(f" {old_label} -> {new_label} (+tag '{ADD_TAG}')") + # > cam-1 -> camera-1 (+tag 'camera') + + if DRY_RUN: + print("Dry run — no changes written. Set DRY_RUN = False to apply.") + return + + # --- 4. Apply topology changes in one transaction ------------------------- + with app.inspect.transaction() as tx: + for device_id, _, new_label in changes: + device = app.inspect.get_device(device_id) + if device is None: + continue + device.label = new_label + device.tags = sorted(set(device.tags) | {ADD_TAG}) + tx.update(device) + tx.commit() + + # --- 5. Keep the inventory labels in sync --------------------------------- + for device_id, _, new_label in changes: + inventory_device = app.inventory.get_device(device_id=device_id) + inventory_device.configuration.label = new_label + app.inventory.update_device(device=inventory_device) + + print(f"Updated {len(changes)} device(s).") + + +if __name__ == "__main__": + main() diff --git a/docs/examples/README.md b/docs/examples/README.md new file mode 100644 index 0000000..b9abe67 --- /dev/null +++ b/docs/examples/README.md @@ -0,0 +1,96 @@ +# Examples + +Runnable, task-oriented example scripts for the VideoIPath Automation Tool. Each file is a +self-contained Python script with a module docstring (title, description, prerequisites, related +examples) and numbered sections, showing how to solve one realistic automation scenario. + +Where the [Getting Started Guide](../getting-started-guide/README.md) explains concepts, these +examples show complete workflows you can copy and adapt. + +## Running an example + +```bash +pip install videoipath-automation-tool +python docs/examples/01_setup/01_connect_and_check.py +``` + +Each script has a small block of placeholder constants near the top (`SERVER_ADDRESS`, `USERNAME`, +`PASSWORD`, device labels, …) — edit these for your environment. Alternatively, set the `VIPAT_*` +environment variables (or a `.env` file) and connect with a bare `VideoIPathApp()`; see +[01_connect_and_check.py](01_setup/01_connect_and_check.py). + +All identifiers in these examples are anonymized placeholders (`device-a`, `leaf-1`, `192.0.2.x`, +`site-a`, …). **The examples write to the server — run them against a test system first.** The +read-only scripts ([04_inspect/01](04_inspect/01_explore_topology_read_only.py), +[06_workflows/02](06_workflows/02_network_audit_report.py)) are safe starting points. + +## Compatibility and conventions + +- **Inspect app** (`app.inspect`) is the forward-looking read/write interface and requires + **VideoIPath 2025.4 or newer**. +- **Topology app** (`app.topology`) is **deprecated on VideoIPath 2025.x** and **unsupported on + 2026.x**, where its constructor raises `TopologyUnsupportedError`. Prefer the Inspect variant of a + scenario on modern servers. +- **Recommended write style (Inspect):** edit a domain object's properties and flush with + `app.inspect.update(obj)`. The keyword-argument methods (`app.inspect.update_device(...)`, + `update_vertex(...)`, …) do the same in one call and are shown as an alternative where relevant. + +## Contents + +### 01 — Setup + +- [01_connect_and_check.py](01_setup/01_connect_and_check.py) — connect (constructor args or `VIPAT_*` + env vars), verify the connection, read the server version. + +### 02 — Inventory + +- [01_create_and_add_device.py](02_inventory/01_create_and_add_device.py) — create a device from a + driver, set typed `custom_settings`, add it. +- [02_get_update_and_diff_device.py](02_inventory/02_get_update_and_diff_device.py) — fetch, diff, and + update a device; idempotent "write only on change". +- [03_discovery_onboarding.py](02_inventory/03_discovery_onboarding.py) — onboard auto-discovered + devices; enable/disable. +- [04_backup_restore_clone.py](02_inventory/04_backup_restore_clone.py) — dump/parse a configuration to + back up, restore, or clone a device (same or second server). +- [05_driver_settings_and_queries.py](02_inventory/05_driver_settings_and_queries.py) — typed driver + settings, inventory queries, global SNMP configuration. + +### 03 — Topology and Inspect (paired examples) + +Each scenario is implemented twice — once with the modern **Inspect** app, once with the classic +**Topology** app — using identical data, so you can compare the two approaches side by side. + +| Scenario | Inspect | Topology | +|---|---|---| +| Device lifecycle: add, configure, remove | [01_device_lifecycle_inspect.py](03_topology_and_inspect/01_device_lifecycle_inspect.py) | [01_device_lifecycle_topology.py](03_topology_and_inspect/01_device_lifecycle_topology.py) | +| Configure vertices (endpoints, SIPS, tags) | [02_configure_vertices_inspect.py](03_topology_and_inspect/02_configure_vertices_inspect.py) | [02_configure_vertices_topology.py](03_topology_and_inspect/02_configure_vertices_topology.py) | +| Connect two devices with edges | [03_connect_devices_with_edges_inspect.py](03_topology_and_inspect/03_connect_devices_with_edges_inspect.py) | [03_connect_devices_with_edges_topology.py](03_topology_and_inspect/03_connect_devices_with_edges_topology.py) | +| Lay out devices in a grid | [04_grid_placement_inspect.py](03_topology_and_inspect/04_grid_placement_inspect.py) | [04_grid_placement_topology.py](03_topology_and_inspect/04_grid_placement_topology.py) | +| Create a virtual (driverless) device | [05_virtual_device_inspect.py](03_topology_and_inspect/05_virtual_device_inspect.py) | [05_virtual_device_topology.py](03_topology_and_inspect/05_virtual_device_topology.py) | + +### 04 — Inspect (app-specific strengths) + +- [01_explore_topology_read_only.py](04_inspect/01_explore_topology_read_only.py) — read-only tour: + devices, edges, ports, neighbours; skeleton vs. full loading, `preload`. +- [02_transactions_and_conflicts.py](04_inspect/02_transactions_and_conflicts.py) — atomic batched + changes; detect concurrent edits and `rebase` + retry. +- [03_services_and_paths.py](04_inspect/03_services_and_paths.py) — inspect services, their paths, and + the service-impact guard. + +### 05 — Administration + +- [01_security_domains_and_memberships.py](05_administration/01_security_domains_and_memberships.py) — + reconcile security domains and a device's domain memberships. +- [02_profiles.py](05_administration/02_profiles.py) — list, create, clone, and remove profiles. +- [03_multicast_pools.py](05_administration/03_multicast_pools.py) — read, create, extend, and remove + multicast allocation pools. + +### 06 — Workflows (composite, real-world) + +- [01_full_onboarding_pipeline.py](06_workflows/01_full_onboarding_pipeline.py) — end-to-end sync from + an external source of truth: inventory → reachability → topology → edges → domains, with a dry-run + flag and diff-before-write. +- [02_network_audit_report.py](06_workflows/02_network_audit_report.py) — read-only cross-app audit + report (safe to run against production). +- [03_bulk_retag_and_relabel.py](06_workflows/03_bulk_retag_and_relabel.py) — apply a naming/tagging + policy across a fleet with a dry-run report and a single batched commit. diff --git a/docs/getting-started-guide/03_Topology.md b/docs/getting-started-guide/03_A_Topology.md similarity index 61% rename from docs/getting-started-guide/03_Topology.md rename to docs/getting-started-guide/03_A_Topology.md index 244ee1a..2f631c5 100644 --- a/docs/getting-started-guide/03_Topology.md +++ b/docs/getting-started-guide/03_A_Topology.md @@ -1,29 +1,54 @@ -# Topology App +# 03-A — Topology App -## 1. Introduction +> **Paired stage:** this page and [03-B Inspect](03_B_Inspect.md) cover the same +> topology workflows with different implementations. Prefer **03-B** on VideoIPath +> version >= 2025.x; use this page when you still target the classic Topology API. + +### Compatibility & deprecation -The Topology App focuses on configuring devices, defining their capabilities, and establishing links between them. For all these purposes, instances of "Topology Device" are used. +**⚠️ CAUTION ⚠️**: The Topology App is **not supported** for VideoIPath version **2026.x or above**! Use the Inspect App for these versions (see [03-B Inspect](03_B_Inspect.md)). -A **Topology Device** represents a network entity within the topology, containing configuration details. It is composed of multiple elements, each serving a specific role: +| Server version | Status | +| ----------------------- | ------------------------------------------------------------------------- | +| VideoIPath **≤ 2024.x** | Supported | +| VideoIPath **2025.x** | **Deprecated** — constructor emits `DeprecationWarning` and a log warning | +| VideoIPath **≥ 2026.x** | **Unsupported** — constructor raises `TopologyUnsupportedError` | -- **Base Device (BaseDevice)**: Stores fundamental properties such as labels, descriptions, and appearance settings. -- **Vertices**: - - **Generic Vertices (GenericVertex)**: Primarily represent switching cores. - - **IP Vertices (IpVertex)**: Represent network interfaces. - - **Codec Vertices (CodecVertex)**: Represent media-specific inputs and outputs. -- **Edges**: - - **Internal Edges**: Connect vertices within the same device. - - **External Edges**: Link the device to other devices in the topology. +## 1. Introduction -All device properties are stored within the configuration attribute of a **Topology Device**. -While the **Base Device** exists as a single instance and can be accessed directly, all **Vertices** and **Edges** are stored in lists within configuration, categorized by their type. -The following examples illustrate how to retrieve, modify and update specific properties of a **Topology Device**. +The Topology App (`app.topology`) focuses on configuring devices, defining their +capabilities, and establishing links between them. For all these purposes, +instances of "Topology Device" are used. + +A **Topology Device** represents a network entity within the topology, containing +configuration details. It is composed of multiple elements, each serving a +specific role: + +- **Base Device (BaseDevice)**: Stores fundamental properties such as labels, +descriptions, and appearance settings. +- **Vertices**: + - **Generic Vertices (GenericVertex)**: Primarily represent switching cores. + - **IP Vertices (IpVertex)**: Represent network interfaces. + - **Codec Vertices (CodecVertex)**: Represent media-specific inputs and outputs. +- **Edges**: + - **Internal Edges**: Connect vertices within the same device. + - **External Edges**: Link the device to other devices in the topology. + +All device properties are stored within the configuration attribute of a +**Topology Device**. While the **Base Device** exists as a single instance and +can be accessed directly, all **Vertices** and **Edges** are stored in lists +within configuration, categorized by their type. The following examples +illustrate how to retrieve, modify and update specific properties of a +**Topology Device**. ## 2. Basic Usage + + ### 2.1. Retrieving the Configuration of a Device in the Topology -The configuration of a device that has already been added to the topology or is ready for synchronization can be retrieved using its unique device ID. +The configuration of a device that has already been added to the topology or is +ready for synchronization can be retrieved using its unique device ID. ```python device = app.topology.get_device(device_id="device10") @@ -31,14 +56,20 @@ print(device.configuration.factory_label) # > BORDERLEAF-26B [10.0.1.26][Arista Networks EOS] ``` -Alternatively, a device can be identified by determining its device ID based on its label. +Alternatively, a device can be identified by determining its device ID based on +its label. -Similar to the Inventory app, multiple label_search_mode options are available, with canonical_label set as the default. -In this mode, devices that have not yet been added but are ready for synchronization are matched using the factory label. -For devices already configured in the topology, the displayed label is used—either the user-defined label, if set, or otherwise the factory label. +Similar to the Inventory app, multiple `label_search_mode` options are available, +with `canonical_label` set as the default. In this mode, devices that have not +yet been added but are ready for synchronization are matched using the factory +label. For devices already configured in the topology, the displayed label is +used—either the user-defined label, if set, or otherwise the factory label. ```python -device_id = app.topology.find_device_id_by_label("BORDERLEAF-26B [10.0.1.26][Arista Networks EOS]", label_search_mode="canonical_label") +device_id = app.topology.find_device_id_by_label( + "BORDERLEAF-26B [10.0.1.26][Arista Networks EOS]", + label_search_mode="canonical_label", +) if device_id is None: raise ValueError("Device not found") @@ -50,9 +81,12 @@ print(device_id) # > device10 ``` + + ### 2.2. Updating a Device in the Topology -The configuration of a device in the topology can be updated. If the device does not exist, it is automatically added to the topology. +The configuration of a device in the topology can be updated. If the device does +not exist, it is automatically added to the topology. ```python device.configuration.label = "New Label" @@ -66,7 +100,10 @@ print(updated_device.configuration.label) # > New Label ``` -The method evaluates which `nGraphElements` have changed compared to the server state and updates only those elements. Additionally, by default, it checks whether any services are affected. This behavior can be bypassed by setting `ignore_affected_services` to `True`. +The method evaluates which `nGraphElements` have changed compared to the server +state and updates only those elements. Additionally, by default, it checks +whether any services are affected. This behavior can be bypassed by setting +`ignore_affected_services` to `True`. ### 2.3. Remove a Device from the Topology @@ -76,14 +113,17 @@ A device can be removed from the topology using its device ID. app.topology.remove_device_by_id(device_id="device10") ``` + + ## 3. Working with Vertices and Edges -It is possible to either iterate through all vertices/edges of a category, which is often useful for bulk operations, or access individual vertices/edges directly by their ID or label. +It is possible to either iterate through all vertices/edges of a category, which +is often useful for bulk operations, or access individual vertices/edges +directly by their ID or label. ### 3.1 Iterate through all Edges/Vertices of a Category ```python - device = app.topology.get_device(device_id="device80") codec_vertices = device.configuration.codec_vertices @@ -99,6 +139,8 @@ for codec_vertex in codec_vertices: # ... ``` + + ### 3.2 Access a Edge/Vertex by its ID ```python @@ -111,6 +153,8 @@ print(vertice_video_ip_in_1_1.factory_label) # > Video-IP In 1.1 ``` + + ### 3.3 Access a Vertex by its Label ```python @@ -123,8 +167,12 @@ print(vertice_video_ip_in_1_1.id) # > device80.1.3000000 ``` + + ### 3.4. Example: Configure existing Edges/Vertices + + #### 3.4.1. Configure Codec Vertices based on information from factory labels ```python @@ -164,12 +212,12 @@ for vertex in codec_vertices: vertex.spare_destination_address_pool = "SPARE_POOL" print( - f"Vertex with id '{vertex.id}‘ and label '{vertex.factory_label}' configured for {codec_format} ({direction}). Tags: {', '.join(vertex.tags)}" + f"Vertex with id '{vertex.id}' and label '{vertex.factory_label}' configured for {codec_format} ({direction}). Tags: {', '.join(vertex.tags)}" ) - # > Vertex with id 'device80.1.3000000‘ and label 'Video-IP In 1.1' configured for Video (RX). Tags: V_1080i25, V_1080p50, V_2160p50 - # Vertex with id 'device80.1.3000001‘ and label 'Video-IP In 1.2' configured for Video (RX). Tags: V_1080i25, V_1080p50, V_2160p50 + # > Vertex with id 'device80.1.3000000' and label 'Video-IP In 1.1' configured for Video (RX). Tags: V_1080i25, V_1080p50, V_2160p50 + # Vertex with id 'device80.1.3000001' and label 'Video-IP In 1.2' configured for Video (RX). Tags: V_1080i25, V_1080p50, V_2160p50 # ... - + app.topology.update_device(device=device) ``` @@ -177,6 +225,8 @@ More examples will be added soon! --- + + ### 3.5. Creating external Edges between devices ```python @@ -218,4 +268,9 @@ virtuoso_topology.configuration.external_edges.extend(edges_blue_slot_1) app.topology.update_device(device=virtuoso_topology) ``` -> **Note:** The documentation is currently being expanded. Upcoming sections will include details on device positioning, virtual device management, and device comparison, as well as synchronization status and various helper functions. +> **Note:** The documentation is currently being expanded. Upcoming sections will +> include details on device positioning, virtual device management, and device +> comparison, as well as synchronization status and various helper functions. +> Runnable paired scripts for these workflows live under +> `[docs/examples/03_topology_and_inspect/](../examples/03_topology_and_inspect/)`. + diff --git a/docs/getting-started-guide/03_B_Inspect.md b/docs/getting-started-guide/03_B_Inspect.md new file mode 100644 index 0000000..43bd148 --- /dev/null +++ b/docs/getting-started-guide/03_B_Inspect.md @@ -0,0 +1,220 @@ +# 03-B — Inspect App + +> **Paired stage:** this page and [03-A Topology](03_A_Topology.md) cover the same +> topology workflows with different implementations. This page is the recommended +> path on VideoIPath **2025.4+**. + +**⚠️ BETA ⚠️**: The Inspect App is still in beta. The API and behaviour may change +in future releases. Accessing `app.inspect` emits a `UserWarning` and a log warning. + +### Compatibility & deprecation + +| Server version | Status | +|---|---| +| VideoIPath **≥ 2025.4** | Supported and recommended (verified) | +| VideoIPath **< 2025.4** | Unverified — the app logs a warning; behaviour is not guaranteed | +| Relation to Topology | Inspect **replaces** `app.topology` going forward | + +## 1. Introduction + +The **Inspect App** (`app.inspect`) is the read/write interface to VideoIPath's +newer *Inspect* surface: it builds a live view of the topology (devices, ports, +edges, and services) and applies topology changes with a **commit-style** write +model. + +Two ideas shape the API: + +- **Skeleton-first snapshots** — a snapshot loads only the minimal topology (all + devices and edges, without per-port detail) up front, then *lazily hydrates* + detail the first time you touch it. This keeps the initial read fast even in + large environments. A snapshot is never a single point in time; each device and + section carries its own fetch timestamp. +- **Commit-style writes** — changes are staged and applied atomically. Before + sending, the change set re-checks that nobody else modified the affected + entities (compare-and-commit); after a successful commit it refreshes only the + touched entities. + +The app keeps a single topology view internally — you never handle a "snapshot" +object. It loads on your first read and stays current across writes; call +`app.inspect.refresh()` to reload it. + +## 2. Reading the topology + +### 2.1. Devices, ports, and edges + +Everything is read straight off `app.inspect`. Skeleton fields are available +without any per-device I/O: + +```python +device = app.inspect.get_device("device10") +device = app.inspect.find_device_by_label("BORDERLEAF-26B") + +print(device.label, device.coordinates, device.tags) +print(device.status.severity if device.status else None, device.sync_severity) +# InspectSeverity is an IntEnum: str(...) → "OK" / "Notice" / …; int(...) / == N still work. +print(device.status_message) # worst active alarm text, if any +for alarm in device.alarms: # lazy section load of status/alarms/current + print(alarm.severity, alarm.message) + +for device in app.inspect.devices: # all devices + print(device.id, device.label) +``` + +The first access to a device's **ports** hydrates that one device (a single +scoped read), then serves from local state: + +```python +for port in device.ports: # triggers one hydration fetch for this device + print(port.label, port.vertex_id, port.status, port.tags) + edge = port.edge # local edge-skeleton lookup, no I/O + if edge: + print("connected to", edge.to_device.label) + +for edge in device.edges: # local, no hydration + print(edge.from_port, "->", edge.to_port, edge.status) + if edge.status: + print(edge.status.alarm, edge.status.ptp) # InspectSeverity labels + +for other in device.linked_devices: # local graph walk + print(other.label) + +for edge in app.inspect.edges: # all external edges + print(edge.id, edge.status) +``` + +Hydrate many devices at once (parallel) to avoid N+1 reads: + +```python +app.inspect.preload() # all devices +app.inspect.preload(["device10", "device11"]) # a subset +``` + +### 2.2. Services + +Services load once as a section, on first access: + +```python +for service in app.inspect.services: # loads the paths section on first touch + print(service.booking_id) +``` + +### 2.3. Refreshing + +The view updates itself after your own writes and network actions (targeted +scoped re-fetch of touched devices/edges) — you do **not** need to call +`refresh()` after `connect`, `update`, `add_devices_to_topology`, and similar. +Use `refresh()` only to pick up **external** changes (another user/session, or +server-side work outside this app): + +```python +app.inspect.refresh() # reload (skeleton; lazy detail) +app.inspect.refresh(load="full") # reload eagerly in one request +``` + +## 3. Writing to the topology + +### 3.1. Direct writes (auto-commit) + +Each direct method opens a one-change transaction and commits it immediately. If +the internal view is already loaded, the change is reflected into it via targeted +refresh: + +```python +app.inspect.place_device("device12", x=1600, y=9050) +app.inspect.update_device("device12", label="BU-LEAF-A", icon_type="ipSwitchRouter") +app.inspect.update_vertex("device12.1.Ethernet1.out", use_as_endpoint=True) +app.inspect.update_edge(edge_id, weight=10) + +# Assign catalog tags to a port (an Inspect-only capability). Tags are referenced by their +# "Category~~name" id; read them back with port.tags. +app.inspect.update_vertex("device12.1.Ethernet1.out", tags=["Video~~1080p50"]) + +# Module tags use the same setter / update() pattern (backed by assignTag / unassignTag). +module = device.get_module("device12.dev.0") +module.tags = ["Format~~V_720p60"] +app.inspect.update(module) +# or: app.inspect.update_module("device12.dev.0", tags=["Format~~V_720p60"]) + +app.inspect.connect( + "device12.1.Ethernet1.out", + "device7.0.swp1.in", + bidirectional=True, # also stages the reverse edge + capacity=65535, +) +app.inspect.disconnect("device12.1.Ethernet1.out", "device7.0.swp1.in") +app.inspect.remove_device_from_topology("device12") +``` + +### 3.2. Batched, atomic changes (transaction) + +Use a transaction to stage several changes and commit them together: + +```python +with app.inspect.transaction() as tx: + device.description = "Rack A leaf" + tx.update(device) # stage setter edits into the transaction + tx.place_device("device12", x=100, y=200) + tx.connect(a_out, b_in, bidirectional=True) + tx.remove(edge_id) + result = tx.commit() # conflict check → POST → targeted refresh of the internal view + +print(result.ok, result.applied_ids) +``` + +Exiting the `with` block **without** committing discards the staged changes (and +logs a warning). + +### 3.3. Handling concurrent changes + +If another user changed a staged entity since you staged it, `commit()` raises +`InspectCommitConflictError` and sends nothing: + +```python +from videoipath_automation_tool.apps.inspect import InspectCommitConflictError + +try: + tx.commit() +except InspectCommitConflictError as exc: + for conflict in exc.conflicts: + print(conflict.entity_id, conflict.field_diffs) + tx.rebase() # re-fetch baselines, keep your intents + tx.commit() + +# or explicitly force last-writer-wins: +tx.commit(check_conflicts=False) +``` + +A server-rejected commit (validation or apply gate) raises `InspectCommitError`, +which carries the typed `validation` details. + +## 4. Onboarding devices into the topology + +`add_devices_to_topology` places devices and, by default, syncs their +driver-reported ports/vertices in one call: + +```python +from videoipath_automation_tool.apps.inspect import ConflictStrategy + +app.inspect.add_devices_to_topology([("device12", 100, 200), "device13"]) +# Pass sync=False to place only; or override sync options: +# app.inspect.add_devices_to_topology( +# ["device12"], sync=True, add_only=True, conflict_strategy=ConflictStrategy.STRICT +# ) + +# Preview what a later re-sync would change, then re-sync existing devices: +info = app.inspect.get_sync_info(["device12"]) +app.inspect.sync_devices(["device12"], add_only=True, conflict_strategy=ConflictStrategy.STRICT) +``` + +## 5. Notes + +- Inspect uses **only** the collector API surface at runtime; it never calls the + legacy `nGraphElements` / `edgesByDevice` endpoints. +- The topology view is loaded lazily and kept internal to `app.inspect`; a + pure-write workflow never triggers a read. Reads and hydration are internally + consistent under concurrent access, but a single `VideoIPathApp` is otherwise + intended for single-owner use. +- Runnable paired scripts (Inspect vs Topology) live under + [`docs/examples/03_topology_and_inspect/`](../examples/03_topology_and_inspect/). +- For the design rationale, see the architecture docs under + [`docs/architecture/inspect-app/`](../architecture/inspect-app/README.md). diff --git a/docs/getting-started-guide/README.md b/docs/getting-started-guide/README.md index 46d7fc6..8b26c12 100644 --- a/docs/getting-started-guide/README.md +++ b/docs/getting-started-guide/README.md @@ -1,10 +1,31 @@ # Getting Started Guide -This guide will help you get started with the VideoIPath Automation Tool. It will show you how to establish a connection to the VideoIPath server and how to manage devices in the inventory and topology. Also, it demonstrates how to configure multicast pools. +This guide will help you get started with the VideoIPath Automation Tool. It will +show you how to establish a connection to the VideoIPath server and how to manage +devices in the inventory and topology. Stage 3 covers topology work twice — once +with the classic Topology app and once with the modern Inspect app — so you can +follow the same workflows with either implementation. It also demonstrates how to +configure multicast pools. + +## Compatibility (Topology vs Inspect) + +| App | API | Compatibility | +|---|---|---| +| **Inspect** (`app.inspect`) | Forward-looking read/write topology interface | Requires **VideoIPath 2025.4 or newer** (recommended) | +| **Topology** (`app.topology`) | Classic topology interface | **Deprecated on 2025.x**; **unsupported on 2026.x** (`TopologyUnsupportedError`) | + +Prefer the Inspect variant ([03-B](03_B_Inspect.md)) on modern servers. Inventory +onboarding is unchanged and remains a prerequisite for both. ## Table of Contents 1. [Establishing a Connection to the VideoIPath Server](01_Setup_and_connect_to_Server.md) 2. [Managing Devices in the Inventory](02_Inventory.md) -3. [Managing Devices in the Topology](03_Topology.md) +3. Managing Devices in the Topology — choose an implementation: + - A. [Topology App](03_A_Topology.md) (classic / legacy) + - B. [Inspect App](03_B_Inspect.md) (recommended on 2025.4+) 4. [Configuring Multicast Pools](04_Multicast_Pools.md) + +For runnable, task-oriented scripts covering realistic automation scenarios +(including paired Topology/Inspect examples), see the +[Examples](../examples/README.md). diff --git a/poetry.lock b/poetry.lock index bae3156..8b2ee44 100644 --- a/poetry.lock +++ b/poetry.lock @@ -14,14 +14,14 @@ files = [ [[package]] name = "certifi" -version = "2026.5.20" +version = "2026.6.17" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.7" groups = ["main"] files = [ - {file = "certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897"}, - {file = "certifi-2026.5.20.tar.gz", hash = "sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d"}, + {file = "certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db"}, + {file = "certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432"}, ] [[package]] @@ -38,141 +38,105 @@ files = [ [[package]] name = "charset-normalizer" -version = "3.4.7" +version = "3.4.9" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." optional = false python-versions = ">=3.7" groups = ["main"] files = [ - {file = "charset_normalizer-3.4.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cdd68a1fb318e290a2077696b7eb7a21a49163c455979c639bf5a5dcdc46617d"}, - {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e17b8d5d6a8c47c85e68ca8379def1303fd360c3e22093a807cd34a71cd082b8"}, - {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:511ef87c8aec0783e08ac18565a16d435372bc1ac25a91e6ac7f5ef2b0bff790"}, - {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:007d05ec7321d12a40227aae9e2bc6dca73f3cb21058999a1df9e193555a9dcc"}, - {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf29836da5119f3c8a8a70667b0ef5fdca3bb12f80fd06487cfa575b3909b393"}, - {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:12d8baf840cc7889b37c7c770f478adea7adce3dcb3944d02ec87508e2dcf153"}, - {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d560742f3c0d62afaccf9f41fe485ed69bd7661a241f86a3ef0f0fb8b1a397af"}, - {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b14b2d9dac08e28bb8046a1a0434b1750eb221c8f5b87a68f4fa11a6f97b5e34"}, - {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:bc17a677b21b3502a21f66a8cc64f5bfad4df8a0b8434d661666f8ce90ac3af1"}, - {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:750e02e074872a3fad7f233b47734166440af3cdea0add3e95163110816d6752"}, - {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:4e5163c14bffd570ef2affbfdd77bba66383890797df43dc8b4cc7d6f500bf53"}, - {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6ed74185b2db44f41ef35fd1617c5888e59792da9bbc9190d6c7300617182616"}, - {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:94e1885b270625a9a828c9793b4d52a64445299baa1fea5a173bf1d3dd9a1a5a"}, - {file = "charset_normalizer-3.4.7-cp310-cp310-win32.whl", hash = "sha256:6785f414ae0f3c733c437e0f3929197934f526d19dfaa75e18fdb4f94c6fb374"}, - {file = "charset_normalizer-3.4.7-cp310-cp310-win_amd64.whl", hash = "sha256:6696b7688f54f5af4462118f0bfa7c1621eeb87154f77fa04b9295ce7a8f2943"}, - {file = "charset_normalizer-3.4.7-cp310-cp310-win_arm64.whl", hash = "sha256:66671f93accb62ed07da56613636f3641f1a12c13046ce91ffc923721f23c008"}, - {file = "charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7"}, - {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7"}, - {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e"}, - {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c"}, - {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df"}, - {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265"}, - {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4"}, - {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e"}, - {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38"}, - {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c"}, - {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b"}, - {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c"}, - {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d"}, - {file = "charset_normalizer-3.4.7-cp311-cp311-win32.whl", hash = "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad"}, - {file = "charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00"}, - {file = "charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1"}, - {file = "charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46"}, - {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2"}, - {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b"}, - {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a"}, - {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116"}, - {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb"}, - {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1"}, - {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15"}, - {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5"}, - {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d"}, - {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7"}, - {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464"}, - {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49"}, - {file = "charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c"}, - {file = "charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6"}, - {file = "charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d"}, - {file = "charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063"}, - {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c"}, - {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66"}, - {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18"}, - {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd"}, - {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215"}, - {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859"}, - {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8"}, - {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5"}, - {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832"}, - {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6"}, - {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48"}, - {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a"}, - {file = "charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e"}, - {file = "charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110"}, - {file = "charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b"}, - {file = "charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0"}, - {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a"}, - {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b"}, - {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41"}, - {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e"}, - {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae"}, - {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18"}, - {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b"}, - {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356"}, - {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab"}, - {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46"}, - {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44"}, - {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72"}, - {file = "charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10"}, - {file = "charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f"}, - {file = "charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246"}, - {file = "charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24"}, - {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79"}, - {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960"}, - {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4"}, - {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e"}, - {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1"}, - {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44"}, - {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e"}, - {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3"}, - {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0"}, - {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e"}, - {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb"}, - {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe"}, - {file = "charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0"}, - {file = "charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c"}, - {file = "charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d"}, - {file = "charset_normalizer-3.4.7-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:e5f4d355f0a2b1a31bc3edec6795b46324349c9cb25eed068049e4f472fb4259"}, - {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:16d971e29578a5e97d7117866d15889a4a07befe0e87e703ed63cd90cb348c01"}, - {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dca4bbc466a95ba9c0234ef56d7dd9509f63da22274589ebd4ed7f1f4d4c54e3"}, - {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e80c8378d8f3d83cd3164da1ad2df9e37a666cdde7b1cb2298ed0b558064be30"}, - {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:36836d6ff945a00b88ba1e4572d721e60b5b8c98c155d465f56ad19d68f23734"}, - {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux_2_31_armv7l.whl", hash = "sha256:bd9b23791fe793e4968dba0c447e12f78e425c59fc0e3b97f6450f4781f3ee60"}, - {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aef65cd602a6d0e0ff6f9930fcb1c8fec60dd2cfcb6facaf4bdb0e5873042db0"}, - {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:82b271f5137d07749f7bf32f70b17ab6eaabedd297e75dce75081a24f76eb545"}, - {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_armv7l.whl", hash = "sha256:1efde3cae86c8c273f1eb3b287be7d8499420cf2fe7585c41d370d3e790054a5"}, - {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:c593052c465475e64bbfe5dbd81680f64a67fdc752c56d7a0ae205dc8aeefe0f"}, - {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_riscv64.whl", hash = "sha256:af21eb4409a119e365397b2adbaca4c9ccab56543a65d5dbd9f920d6ac29f686"}, - {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:84c018e49c3bf790f9c2771c45e9313a08c2c2a6342b162cd650258b57817706"}, - {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:dd915403e231e6b1809fe9b6d9fc55cf8fb5e02765ac625d9cd623342a7905d7"}, - {file = "charset_normalizer-3.4.7-cp38-cp38-win32.whl", hash = "sha256:320ade88cfb846b8cd6b4ddf5ee9e80ee0c1f52401f2456b84ae1ae6a1a5f207"}, - {file = "charset_normalizer-3.4.7-cp38-cp38-win_amd64.whl", hash = "sha256:1dc8b0ea451d6e69735094606991f32867807881400f808a106ee1d963c46a83"}, - {file = "charset_normalizer-3.4.7-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:177a0ba5f0211d488e295aaf82707237e331c24788d8d76c96c5a41594723217"}, - {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e0d51f618228538a3e8f46bd246f87a6cd030565e015803691603f55e12afb5"}, - {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:14265bfe1f09498b9d8ec91e9ec9fa52775edf90fcbde092b25f4a33d444fea9"}, - {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:87fad7d9ba98c86bcb41b2dc8dbb326619be2562af1f8ff50776a39e55721c5a"}, - {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f22dec1690b584cea26fade98b2435c132c1b5f68e39f5a0b7627cd7ae31f1dc"}, - {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux_2_31_armv7l.whl", hash = "sha256:d61f00a0869d77422d9b2aba989e2d24afa6ffd552af442e0e58de4f35ea6d00"}, - {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6370e8686f662e6a3941ee48ed4742317cafbe5707e36406e9df792cdb535776"}, - {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:a6c5863edfbe888d9eff9c8b8087354e27618d9da76425c119293f11712a6319"}, - {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:ed065083d0898c9d5b4bbec7b026fd755ff7454e6e8b73a67f8c744b13986e24"}, - {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:2cd4a60d0e2fb04537162c62bbbb4182f53541fe0ede35cdf270a1c1e723cc42"}, - {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:813c0e0132266c08eb87469a642cb30aaff57c5f426255419572aaeceeaa7bf4"}, - {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:07d9e39b01743c3717745f4c530a6349eadbfa043c7577eef86c502c15df2c67"}, - {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:c0f081d69a6e58272819b70288d3221a6ee64b98df852631c80f293514d3b274"}, - {file = "charset_normalizer-3.4.7-cp39-cp39-win32.whl", hash = "sha256:8751d2787c9131302398b11e6c8068053dcb55d5a8964e114b6e196cf16cb366"}, - {file = "charset_normalizer-3.4.7-cp39-cp39-win_amd64.whl", hash = "sha256:12a6fff75f6bc66711b73a2f0addfc4c8c15a20e805146a02d147a318962c444"}, - {file = "charset_normalizer-3.4.7-cp39-cp39-win_arm64.whl", hash = "sha256:bb8cc7534f51d9a017b93e3e85b260924f909601c3df002bcdb58ddb4dc41a5c"}, - {file = "charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d"}, - {file = "charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5"}, + {file = "charset_normalizer-3.4.9-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cd6280cf040f233bd7d3407b743b4b4c74f70e8e1c4199cb112a62c941c0772a"}, + {file = "charset_normalizer-3.4.9-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa99adc8f081b475a12843953db36831eaf83ec33eb46a90629ca6a5de45a616"}, + {file = "charset_normalizer-3.4.9-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c1225416b463483160e4af85d5fc3a9690ccb53fd4b1865a6437825f5ede3209"}, + {file = "charset_normalizer-3.4.9-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:16d10d789dd9bcca1173c95af82c58433122564b7bc39385124be735a35cbe99"}, + {file = "charset_normalizer-3.4.9-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9bb41182d93ea91f60b4bc8fbf4c820c69ef8a12ab2d917f3f1834f1acad07e8"}, + {file = "charset_normalizer-3.4.9-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:bcf74c1df76758a395bf0af608c04c82257523f55c9868b334f06270d0f2112b"}, + {file = "charset_normalizer-3.4.9-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b5314963fce9b0b12743891de876e724997864ee22aa496f903f426c7e2fa5b2"}, + {file = "charset_normalizer-3.4.9-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e9701d0049d92c16703a42771b98d560b95248949f23f8cf7b4eddd201814fb9"}, + {file = "charset_normalizer-3.4.9-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:65a7ff3f705e57d392f7261b6d0550fe137c3019477431f1c355e0db0a7d3e15"}, + {file = "charset_normalizer-3.4.9-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:79580094b00d1789d1f93ea55bc43cb2f611910c72235b7657f3482ddcc1b22d"}, + {file = "charset_normalizer-3.4.9-cp310-cp310-win32.whl", hash = "sha256:432786d3561e69aeeae6c7e8648964ce0ad05736120135601f87ac26b9c83381"}, + {file = "charset_normalizer-3.4.9-cp310-cp310-win_amd64.whl", hash = "sha256:8c041122946b7ba21bb32c45b1aa57b1be35527690aeb3c5c234521085632eee"}, + {file = "charset_normalizer-3.4.9-cp310-cp310-win_arm64.whl", hash = "sha256:375b83ed0aecfce76c16d198fbc21f3b11b337d68662bea0a995046682a11419"}, + {file = "charset_normalizer-3.4.9-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0e94703ec9684807f20cfb5eed95c70f67f2a8f21ad620146d7b5a13677b93e5"}, + {file = "charset_normalizer-3.4.9-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a441ea71902098ffe78c5abe6c494f44160b4af614ed16c3d9a3b1d17fd8ee2"}, + {file = "charset_normalizer-3.4.9-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:304b13570067b2547562e308af560b3963857b1fa90bd6afd978130130fe2d6a"}, + {file = "charset_normalizer-3.4.9-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4773092f8019072343a7447203308b176e10199920eb02d6195e81bbb3274c29"}, + {file = "charset_normalizer-3.4.9-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:04ce310cb89c15df659582aee80a0603788732a5e017d5bd5c81158106ce249c"}, + {file = "charset_normalizer-3.4.9-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:c0323c9daef75ef2e5083624b4585018a0c9d5e3b40f607eed81a311270b934b"}, + {file = "charset_normalizer-3.4.9-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:871ff67ea1aad4dfd91736464934d56b32dac49f9fbe16cddba36198a7b3a0db"}, + {file = "charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:67830fc78e67501f47bb950471b2dcb9b35b140084429318e862895a8e89c993"}, + {file = "charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:3d92613ec25e43b05f042302531ec0f00b8445190e43325880cbd6ab7c2581da"}, + {file = "charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:280081916dc341820640489a66e4696049401ef1cf6dd672f672e70ad915aca3"}, + {file = "charset_normalizer-3.4.9-cp311-cp311-win32.whl", hash = "sha256:ac351b3b8014eead140e77e9717e2992c6bbe30b63bc3422422eb84865412e3d"}, + {file = "charset_normalizer-3.4.9-cp311-cp311-win_amd64.whl", hash = "sha256:6366a16e1a25018694d6a5d784d09b046edc9eac40ea2b54065c3052672516a1"}, + {file = "charset_normalizer-3.4.9-cp311-cp311-win_arm64.whl", hash = "sha256:1d22856ffbe153a602df38e4a5464f0b748a54002e0d69ac6d2ad0a197cc99ec"}, + {file = "charset_normalizer-3.4.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:45b0cc4e3556cd875e09102988d1ab8356c998b596c9fced84547c8138b487a0"}, + {file = "charset_normalizer-3.4.9-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b2aff1c7b3884512b9512c3eaadd9bab39fb45042ffaaa1dd08ff2b9f8109d9"}, + {file = "charset_normalizer-3.4.9-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9104ed0bd76a429d46f9ec0dbc9b08ad1d2dcdf2b00a5a0daa1c145329b35b44"}, + {file = "charset_normalizer-3.4.9-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7b86a2b16095d250c6f58b3d9b2eee6f4147754344f3dab0922f7c9bf7d226c9"}, + {file = "charset_normalizer-3.4.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e226f6218febc71f6c1fc2fafb91c226f75bdc1d8fb12d66823716e891608fd"}, + {file = "charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:90c44bc373b7687f6948b693cceaea1348ae0975d7474746559494468e3c1d84"}, + {file = "charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cdef90ae47919cae358d8ab15797a800ed41da7aba5d72419fb510729e2ed4b"}, + {file = "charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:60f44ade2cf573dad7a277e6f8ca9a51a21dda572b13bd7d8539bb3cd5dbedde"}, + {file = "charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:a1786910334ed46ab1dd73222f2cd1e05c2c3bb39f6dddb4f8b36fc382058a39"}, + {file = "charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:03d07803992c6c7bbc976327f34b18b6160327fc81cb82c9d504720ac0be3b62"}, + {file = "charset_normalizer-3.4.9-cp312-cp312-win32.whl", hash = "sha256:78841cccf1af7b40f6f716338d50c0902dbe88d9f800b3c973b7a9a0a693a642"}, + {file = "charset_normalizer-3.4.9-cp312-cp312-win_amd64.whl", hash = "sha256:4b3dac63058cc36820b0dd072f89898604e2d39686fe05321729d00d8ac185a0"}, + {file = "charset_normalizer-3.4.9-cp312-cp312-win_arm64.whl", hash = "sha256:78fa18e436a1a0e58dbd7e02fc4473f3f32cceb12df9dfca542d075961c307d2"}, + {file = "charset_normalizer-3.4.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:440eede837960000d74978f0eba527be106b5b9aee0daf779d395276ed0b0614"}, + {file = "charset_normalizer-3.4.9-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21e764fd1e70b6a3e205a0e46f3051701f98a8cb3fad66eeb80e48bb502f8698"}, + {file = "charset_normalizer-3.4.9-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e4fd89cc178bced6ad29cb3e6dd4aa63fa5017c3524dbd0b25998fb64a87cc8b"}, + {file = "charset_normalizer-3.4.9-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bd47ba7fc3ca94896759ea0109775132d3e7ab921fbf54038e1bab2e46c313c9"}, + {file = "charset_normalizer-3.4.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:84fd18bcc17526fc2b3c1af7d2b9217d32c9c04448c16ec693b9b4f1985c3d33"}, + {file = "charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:5b10cd92fc5c498b35a8635df6d5a100207f88b63a4dc1de7ef9a548e1e2cd63"}, + {file = "charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a4fbdde9dd4a9ce5fd52c2b3a347bb50cc89483ef783f1cb00d408c13f7a96c0"}, + {file = "charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:416c229f77e5ea25b3dfd4b582f8d73d7e43c22320302b9ab128a2d3a0b38efe"}, + {file = "charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:75286256590a6320cf106a0d28970d3560aad9ee09aa7b34fb40524792436d35"}, + {file = "charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:69b157c5d3292bcd443faca052f3096f637f1e074b98212a933c074ae23dc3b8"}, + {file = "charset_normalizer-3.4.9-cp313-cp313-win32.whl", hash = "sha256:51307f5c71007673a2bf8232ad973483d281e74cb99c8c5a990af1eefa6277d9"}, + {file = "charset_normalizer-3.4.9-cp313-cp313-win_amd64.whl", hash = "sha256:fe2c7201c642b7c308f1675355ad7ff7b66acfe3541625efe5a3ad38f29d6115"}, + {file = "charset_normalizer-3.4.9-cp313-cp313-win_arm64.whl", hash = "sha256:611057cc5d5c0afc743ba8be6bd828c17e0aaa8643f9d0a9b9bb7dea80eb8012"}, + {file = "charset_normalizer-3.4.9-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:0327fcd59a935777d83410750c50600ee9571af2846f71ce40f25b13da1ef380"}, + {file = "charset_normalizer-3.4.9-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a79d9f4d8001473a30c163556b3c3bfebec837495a412dde78b51672f6134f9"}, + {file = "charset_normalizer-3.4.9-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:33bdcc2a32c0a0e861f60841a512c8acc658c87c2ac59d89e3a46dacf7d866e4"}, + {file = "charset_normalizer-3.4.9-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f840ed6d8ecba8255df8c42b87fadeda98ddfc6eeec05e2dc66e26d46dd6f58a"}, + {file = "charset_normalizer-3.4.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c25fe15c70c59eb7c5ce8c06a1f3fa1da0ecc5ea1e7a5922c40fd2fa9b0d5046"}, + {file = "charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:f7fb7d750cfa0a070d2c24e831fd3481019a60dd317ea2b39acbcebc08b6ed81"}, + {file = "charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d1c96a7a18b9690a4d46df09e3e3382406ae3213727cd1019ebade1c4a81917"}, + {file = "charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a4cfde78a9f2880208d16a93b795726a3017d5977e08d1e162a7a31322479c41"}, + {file = "charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d4d6fcde76f94f5cb9e43e9e9a61f16dacefd228cbbf6f1a09bd9b219a92f1a1"}, + {file = "charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:898f0e9068ca27d37f8e83a5b962821df851532e6c4a7d615c1c033f9da6eedf"}, + {file = "charset_normalizer-3.4.9-cp314-cp314-win32.whl", hash = "sha256:c1c948747b03be832dceed96ca815cef7360de9aa19d37c730f8e3f6101aca48"}, + {file = "charset_normalizer-3.4.9-cp314-cp314-win_amd64.whl", hash = "sha256:16b65ea0f2465b6fb52aa22de5eca612aa964ddfec00a912e26f4656cbef890b"}, + {file = "charset_normalizer-3.4.9-cp314-cp314-win_arm64.whl", hash = "sha256:40a126142a56b2dfc0aacbad1de8310cbf60da7656db0e6b16eebd48e3e93519"}, + {file = "charset_normalizer-3.4.9-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:609b3ba8fcc0fb5ab7af00719d0fb6ad0cb518e48e7712d12fd68f1327951198"}, + {file = "charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51447e9aa2684679af07ca5021c3db526e0284347ebf4ffcec1154c3350cfe32"}, + {file = "charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cc1b0fff8ead343dae06305f954eb8468ba0ec1a97881f42489d198e4ce3c632"}, + {file = "charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa36ec09ef71d158186bc79e359ff5fdd6e7996fe8ab638f00d6b93139ba4fcf"}, + {file = "charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df115d4d83168fdf2cae48ef1ff6d1cb4c466364e30861b37121de0f3bf1b990"}, + {file = "charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f86c6358749bd4fda175388691e3ba8c46e24c5347d0afd20f9b7edfc9faf07d"}, + {file = "charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:32286a2c8d167e897177b673176c1e3e00d4057caf5d2b64eef9a3666b03018e"}, + {file = "charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:83aed2c10721ddd90f68140685391b50811a880af20654c59af6b6c66c40513c"}, + {file = "charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cd6c3d4b783c556fa00bf540854e42f135e2f256abd29669fcd0da0f2dec79c2"}, + {file = "charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ee2f2a527e3c1a6e6411eb4209642e138b544a2d72fe5d0d76daf77b24063534"}, + {file = "charset_normalizer-3.4.9-cp314-cp314t-win32.whl", hash = "sha256:0d861473f743244d349b50f850d10eb87aeb22bbdcc8e64f79273c94af5a8226"}, + {file = "charset_normalizer-3.4.9-cp314-cp314t-win_amd64.whl", hash = "sha256:9b8e0f3107e2200b76f6054de99016eac3ee6762713587b36baaa7e4bd2ae177"}, + {file = "charset_normalizer-3.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:19ac87f93086ce37b86e098888555c4b4bc48102279bae3350098c0ed664b501"}, + {file = "charset_normalizer-3.4.9-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:253a4a220747e8b5faf57ec320c4f5efb0cef05f647420bf267143ec15dba10a"}, + {file = "charset_normalizer-3.4.9-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:68ce9f4d6b26d5ccbf7fd4459bf75f74a0a146677ebba80597df60cbdb20e6f4"}, + {file = "charset_normalizer-3.4.9-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:58150c9f9b9a552505912d182ccdf26f6396fb6094816ceebcbb20eecabaed94"}, + {file = "charset_normalizer-3.4.9-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:df7276909358e5635ae203673ab7e509ddd224225a8d6b0790bf13eb2bde1cc5"}, + {file = "charset_normalizer-3.4.9-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3c09a49d6cde137258beb3d551994a2927fd35ad5cf96aed573f61bbd67c5f84"}, + {file = "charset_normalizer-3.4.9-cp39-cp39-manylinux_2_31_armv7l.whl", hash = "sha256:231ddcbb35e2ff8973e1365db41fe0572662893b99a05deb183b68ad4c0c8bd4"}, + {file = "charset_normalizer-3.4.9-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:920079c3f7456fa213e0829ed2073aaa727fd39d889ead5b4f35d0de5460d04f"}, + {file = "charset_normalizer-3.4.9-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:0fa1aec2d32bcc03c8fa0f6f1712caad1adc38509f31142112e5c9daf5b9c833"}, + {file = "charset_normalizer-3.4.9-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:ad41ba96094304aa090f5a30cb6e4fb3b3f1c264c523394b4c39bbacc4dc92ba"}, + {file = "charset_normalizer-3.4.9-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:43b9e366a31fdd1c87d0eb08f579b4a82b723ea54338f040d6b4e518a026ea29"}, + {file = "charset_normalizer-3.4.9-cp39-cp39-win32.whl", hash = "sha256:93d59d504b230e83c7a843251681959a0b6a9cd76f6e146ce1b8a80eb8739af9"}, + {file = "charset_normalizer-3.4.9-cp39-cp39-win_amd64.whl", hash = "sha256:ddf4af30b417d9fe16481e9b81c27ab2a7cde1ff7ba3e85653b02db7d145dc7b"}, + {file = "charset_normalizer-3.4.9-cp39-cp39-win_arm64.whl", hash = "sha256:476743fe6dfe14a2da12e3ac79125dc84a3b2cf8094369a47a1529b0cd8549fe"}, + {file = "charset_normalizer-3.4.9-py3-none-any.whl", hash = "sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5"}, + {file = "charset_normalizer-3.4.9.tar.gz", hash = "sha256:673611bbd43f0810bec0b0f028ddeaaa501190339cac411f347ac76917c3ae7b"}, ] [[package]] @@ -190,118 +154,103 @@ files = [ [[package]] name = "coverage" -version = "7.14.1" +version = "7.15.0" description = "Code coverage measurement for Python" optional = false python-versions = ">=3.10" groups = ["test"] files = [ - {file = "coverage-7.14.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:3e3680291c4a1d0dadfa84a2c459576a4af5133abb617905714339a0c73138cf"}, - {file = "coverage-7.14.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a5274669f37f2343635a347b91a60777621341ab3378e9c6ac9335eee704bddf"}, - {file = "coverage-7.14.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cfe5a5fec635799ef33428f1e5e61bafa45a92a96190ba731561ba558ccc214d"}, - {file = "coverage-7.14.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:62a9f70b52e0b5a95cfef4a5c5641b06983cadc5e538a3feeb5c00211f523ac2"}, - {file = "coverage-7.14.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c18ebc343e15be53049b3a2dce38fe82d58f37e20ab9094b3a39c0aa4f6bb47"}, - {file = "coverage-7.14.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b84ffdf877644e7096aa936991efeed873f7f3df57b9cd001312b7668ab08550"}, - {file = "coverage-7.14.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e854312c4103f2ad4c0dc023b69b77ebfd2c89db5f86c4c94dc2353f9a92167e"}, - {file = "coverage-7.14.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c643734307300234fafa36bf2a040a7235f8f177ea1fd6ec1423aea6fb7b929f"}, - {file = "coverage-7.14.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:84ac9499e48700399a5dd0ea7085b5091961fec52c68d66b4ec0d3cf7f4441b1"}, - {file = "coverage-7.14.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:7f02d09f70776579b926d889a4c9c235070a1f47c40458aeaca563fae5acfdb5"}, - {file = "coverage-7.14.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:ce66d8e46da2bb5ee313a745cbd2e391d319176c1f7a9451bfcd3a2fb920859b"}, - {file = "coverage-7.14.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c912c259304cfb5ee584481cfb7ce1ff932b4d61e6c9140b8f19cb7b5ed82332"}, - {file = "coverage-7.14.1-cp310-cp310-win32.whl", hash = "sha256:1238cb94638e610e972c60dac68e813f868dc7d6e982535270558443058d9d59"}, - {file = "coverage-7.14.1-cp310-cp310-win_amd64.whl", hash = "sha256:fc459e5d73be2d6332fcfe8dbf3d8994671fe33c700f4565988ecfa511547253"}, - {file = "coverage-7.14.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:478b5bcd63c2e1357c5c7e16c070690df7b07f676b1c114d7b93e533c664309f"}, - {file = "coverage-7.14.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a24a81f9715ee42ef59a316cc11611c98fe23920f7c81861315c9f3ff4a230f4"}, - {file = "coverage-7.14.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:196a13319ad88d6d8ef5ab489ec4f44ddde2143c0c7d5b27786f6c3ffd56a7e1"}, - {file = "coverage-7.14.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3d452fd08b5c72c5167c93e6867b5c08500bd40f2a21e1e854a500550b6cc36f"}, - {file = "coverage-7.14.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:23bf7fa51ac02e07fc7c96849b82946da47ae862dc8f86d183b2a4864fc38129"}, - {file = "coverage-7.14.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bcaa50684dcaadfa599ac48f81103c756d791cfd85c97203d2217c593d48b860"}, - {file = "coverage-7.14.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4ea1c034f95c9b056e856b794630b17f9fa3d57e4800ff1e503d3be0f9c9078c"}, - {file = "coverage-7.14.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c7e057326434e441306226fbeb5d1aaf14a2637efe97ba668306635835f32ad7"}, - {file = "coverage-7.14.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:59baf88468dbc8d63b1887afd92bda52e40bb1561696e5819670601403810cec"}, - {file = "coverage-7.14.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:d34d75f892b3ab73ba11cab5442cce7b3e168fd64162b16f0e1e0d09c508edef"}, - {file = "coverage-7.14.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:3a56abc20a472baf0304c455721bc601477440d28ecfde8a03dde79ede07e0df"}, - {file = "coverage-7.14.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6a3cb83d1552c0cd1b4906655b6a33fd4a8473229633a901c6b73bf86914dee9"}, - {file = "coverage-7.14.1-cp311-cp311-win32.whl", hash = "sha256:10274a1fbeb8ec5d72966e17bb198a3104257aca4ac09d98667c5f8aca8c8548"}, - {file = "coverage-7.14.1-cp311-cp311-win_amd64.whl", hash = "sha256:87ebdf787d4888e3f3f2d523eadc6e18c6d18c6d0eb173801a189641627fb37e"}, - {file = "coverage-7.14.1-cp311-cp311-win_arm64.whl", hash = "sha256:dd34767fa19848d35659ffc0a75314f58c7af3f1cd87ec521e8292a1238398a3"}, - {file = "coverage-7.14.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a06c76364a9360e33d6d23769aefdf7f66f38e2ffb60ceb1baaa4989d83b695c"}, - {file = "coverage-7.14.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fad54e871165f6ec2f536063ac74c3104508a12963e64072ba44bd822de52b0c"}, - {file = "coverage-7.14.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:84b535f00655ecafe1d929d1fb00ed5d6fa3051ea643ab2c161a3887b86f294b"}, - {file = "coverage-7.14.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6b6b0853b895fe0e98cbfc580d1ec3393d9302b4b1e96a77b3f5c91fdab899e6"}, - {file = "coverage-7.14.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:442cc9c952b2df400cda54bb04ab87330cf2cd08a8692cbbea36773531eb6f37"}, - {file = "coverage-7.14.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8270544c361ed405a27a060dbc9ed2c124b084d96dfdc2d9a2510482aef981ad"}, - {file = "coverage-7.14.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:48b283b1dd6372e8de2a7a9a4c4d5dc06f4d4fd209b876f3c88a7a205a0c8f84"}, - {file = "coverage-7.14.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5b0c99ba93a07d56f6df340bb79be53202a082b2fdb81bfe6190b741a3470d54"}, - {file = "coverage-7.14.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e471bc5769ff073b058cfadb0d736b56ce067c8560eabeb0da88462df98c23e7"}, - {file = "coverage-7.14.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f497a1ea81d4cd7c10ddcaa685135b9aabd291af3d55775a9ddf3cb7a364cdd9"}, - {file = "coverage-7.14.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2222be86d0b54f5dd5a38f45f17f315f737245e857bf0bdedc70734f84a13c02"}, - {file = "coverage-7.14.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:85e85586565842f6932abebd4c18bcb1074223dc0b3576e7d173ca710622813a"}, - {file = "coverage-7.14.1-cp312-cp312-win32.whl", hash = "sha256:4a28fd227808366b196a75476dced2eb35b351d6766ba9c858dc93319e87f4f1"}, - {file = "coverage-7.14.1-cp312-cp312-win_amd64.whl", hash = "sha256:54acdb6674a4661768d7bf7db32dfb9f46ab1d764f8aba6df75ce1a6a088724e"}, - {file = "coverage-7.14.1-cp312-cp312-win_arm64.whl", hash = "sha256:99cd41ff91afd94896fea3bc002706b6ae4ce95727d06e4a0f39c0a8d8bd8b1a"}, - {file = "coverage-7.14.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:be9f2c802dcfce3f71298303aa5dad0dce440a76c52f2f60dacd8656dab78793"}, - {file = "coverage-7.14.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6223a72fd0e4c7156353ec0f08a5f93623e1d3034d0e2683b9bb8ea674131b1d"}, - {file = "coverage-7.14.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7279d2110a28cebc738b6459ecda2771735a4c18465fbbd36b3288fe5ed92247"}, - {file = "coverage-7.14.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9eeb3fcbc13ba40dfbdb22d01d196a28e9cef9ed4c29b60061a1e0e823a9929d"}, - {file = "coverage-7.14.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f0cfc27c539f07cf5c0a4cfe211d0b6cae039f8f40526dbaa71944e64b50a7b"}, - {file = "coverage-7.14.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:221c70f316241a78e77e607c227cefc8808d4e08f28d99c04f35694690e940be"}, - {file = "coverage-7.14.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:da028256b04ec30e5e0114b6f76172938c313991f0a2d3d894271315cf5d5e43"}, - {file = "coverage-7.14.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76a085d7005236a767e3426148b2c407e53ad61695c562f8a81da2d373324901"}, - {file = "coverage-7.14.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b553d04b5e778a8e56d57eb134aff42a92718ecba45e79c4764ecfa40efd92ff"}, - {file = "coverage-7.14.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:46f714d2fb8ae2f4f29f23ada7f1e79b759fff5a70f94a1dac23af204c3ec9e4"}, - {file = "coverage-7.14.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:1896f5e19ff3f0431c7ce2172adc54890fd97f86b59ced8ca1649145d9ffe35d"}, - {file = "coverage-7.14.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:62fd185ef9df3c33d1c8178c5af105f762afbad96038de9a4ae100aa6297ca33"}, - {file = "coverage-7.14.1-cp313-cp313-win32.whl", hash = "sha256:ab4af6352741a604c431c6072fce5bee33bf0f20dc7a56618d6bf6bb89e9810c"}, - {file = "coverage-7.14.1-cp313-cp313-win_amd64.whl", hash = "sha256:7af486dabe8954d03b087f0021540897afe084f04e16ff5579e08cc46f871416"}, - {file = "coverage-7.14.1-cp313-cp313-win_arm64.whl", hash = "sha256:2224f89ffd0c5605ccce1ed7a584da162bc7c55f601ab1c946bc9de31a486b42"}, - {file = "coverage-7.14.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:de286598cc65d2b489411174b1faec2f5a7775fb3201fd925db2a76b4030f37d"}, - {file = "coverage-7.14.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:042c46ded7c288aeb07cf14a28b6c1e10b78fcba40171c3fa1e939377eeef0b5"}, - {file = "coverage-7.14.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f4ddbe407477f04c45115d1a4e5bc480f753553b534d338d4c3358b1cdd0ea52"}, - {file = "coverage-7.14.1-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d13e6725992e2d2fd7d81d4f5241952d13740121dfd501da09201be39b2c003a"}, - {file = "coverage-7.14.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f747dc8edcfe740130f28f32f3995e955494285717e86ee25af51db2219df08a"}, - {file = "coverage-7.14.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ced2f09ef276fd58611a1ef502164ad266d2b75174e5a40cabbdb4033f9f6cf2"}, - {file = "coverage-7.14.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b84800013769a78ccb9ef4659402e26d06867e337b61ec365f77ad008adea80e"}, - {file = "coverage-7.14.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:ea8cd6ca0ee9f616aaef3afc6882e32c2cbf18b00d96313ffd76af650574034d"}, - {file = "coverage-7.14.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:aa5e304a873fabddc11e484e9b6b738bd38bd7bed17b09aa84eecf5332e8b8bb"}, - {file = "coverage-7.14.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:5a1c5215be81035e629d5bc756650634d0bf31991038db7a0eccb90f025ce16d"}, - {file = "coverage-7.14.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:79058c47dae6788504b5effb319961bcd72d7240551464b91d474bc0ed186d69"}, - {file = "coverage-7.14.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:370c5afae3fa0658e11694a32b24c2778f6bc2d17718121f94ee185e69f26b54"}, - {file = "coverage-7.14.1-cp313-cp313t-win32.whl", hash = "sha256:3758dd0a7f1fa57365ef2e781df0f0731d38b6e3772259d13dae4bd8a958d4b1"}, - {file = "coverage-7.14.1-cp313-cp313t-win_amd64.whl", hash = "sha256:6ff665fb023a77386fe11685190cee1f60a7d635994a30d9b0a061533d470fce"}, - {file = "coverage-7.14.1-cp313-cp313t-win_arm64.whl", hash = "sha256:17a5a241e5997621a956a7f402a7433ef4221e5152809b785bec79e2323799f1"}, - {file = "coverage-7.14.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d5ed429d0b8edaac649e889b4ffcedb6c80b06629a3f93050e3dddfb99235bee"}, - {file = "coverage-7.14.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8011224a62280e50dab346960c03cf47aca1a1e09e608c0fb33fd6e0cc8e9500"}, - {file = "coverage-7.14.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:12c42ec1e14f553c4f817e989365982e646e27211f10a0f717855b94a79c8906"}, - {file = "coverage-7.14.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:06144cd511cf2624873a035c5069cf297144f6e77a73ee3d7a55b605ec5efb42"}, - {file = "coverage-7.14.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a311d8e1da24be5c1ccf85cbfb06315dbaa1703d5a1eab3f6432c72b837917c8"}, - {file = "coverage-7.14.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c79cead5b5bc584d9c71451cb984d0e3a84e0c0937379c8efcbf27c8d661b851"}, - {file = "coverage-7.14.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dcbf65f1f66a26cdd88c35cf68fb4729c5d1cd2e88added72420541dfb212034"}, - {file = "coverage-7.14.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fd86572566fb40189a8260446158235159bc7a82dfbc87a3b39cf4fb57fcec1c"}, - {file = "coverage-7.14.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:7771b601718fdde84832c3a434ca9bbf4ae9adbc49d84198b4110700c3c77c36"}, - {file = "coverage-7.14.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:39b21e212c55af06fa375e3dbf90a8a8e38792f3a910c580066d23563830ddd5"}, - {file = "coverage-7.14.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f2302660e32562a532b442480121aef8aa61a5bdb20b30bf0adab29f10a5a4b4"}, - {file = "coverage-7.14.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:03a6f93c1ec3b7f2e77b5dbcc5573a2c21f12529a5c6bbe0f16f72303cc2fa4d"}, - {file = "coverage-7.14.1-cp314-cp314-win32.whl", hash = "sha256:8a3ce026d73290f42f08dafecbd82c193a74df280461fbf97300fec51fd133ee"}, - {file = "coverage-7.14.1-cp314-cp314-win_amd64.whl", hash = "sha256:114c95ef29302423b87d159075805f4ab973254a2638a5d7d046c94887cc87d7"}, - {file = "coverage-7.14.1-cp314-cp314-win_arm64.whl", hash = "sha256:a07891c3f4805442b31b71e84ba3cf29ed1aa9a428284e06deeb4b23e5b46343"}, - {file = "coverage-7.14.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1101a5ebb083aecb625ebb6209d4105b58f647b093cb2dc8122d7b33f743cfe1"}, - {file = "coverage-7.14.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:851b9e1e4e8a4608e77c79714b2e77c0970d2ed7202a05e92ae407817481887b"}, - {file = "coverage-7.14.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d5b89cdfb2ee051b71e8c3c70bd81a9eff81100f736a269136fe1a68efe00474"}, - {file = "coverage-7.14.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0177614a0370f227888b4e436a7c55686d6a9f90eb1ade2b624ba685a1686e86"}, - {file = "coverage-7.14.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2d69af5dea2de76fc485a83032a630523f985198b7e25be901ec60181587b01e"}, - {file = "coverage-7.14.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:35ab22d91de736e8966b980dc355cbcdd2c6dbbcfe275f9a2991bc8a91b3df65"}, - {file = "coverage-7.14.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:357d4e32935c36588aaba057d734fa32428c360c9fc2e4442afbf1b646beee6e"}, - {file = "coverage-7.14.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:51bd64741cc6fa065abd300ede1afe5a5291ece9c31da8b24884deda48bcc3f8"}, - {file = "coverage-7.14.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:9132cd363a68a4c3daa7c8704a654b1e39d3360f6f5b8ddd470608a945236c07"}, - {file = "coverage-7.14.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:07c6290b1697b862c0478eab545eec949a0d0e4d6d03497f446d706da3b4f2de"}, - {file = "coverage-7.14.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:5ea0c297e27133853b4d8a3eb799bff5a2dbd9f2f41537a240d337ac9b4df890"}, - {file = "coverage-7.14.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:01b7733daad0237daa01ef80fe2dfceffc911e6a17fa7b55d14aa8214eaaaecd"}, - {file = "coverage-7.14.1-cp314-cp314t-win32.whl", hash = "sha256:6adc5a36984624a70bf11d7184e20fa0a49aa7c47ffab43804106a1a695ea22e"}, - {file = "coverage-7.14.1-cp314-cp314t-win_amd64.whl", hash = "sha256:ddf799247318f34dbcd2efa8c95a8d0642674e926bb1774cf9b63dfd2a389d1c"}, - {file = "coverage-7.14.1-cp314-cp314t-win_arm64.whl", hash = "sha256:145986fe66647eb489f18d9a997567a3fd358584c4b5a808769113abc07466af"}, - {file = "coverage-7.14.1-py3-none-any.whl", hash = "sha256:a252f21c27e38347e60111a3266b03827422a7d5525951aceee313aa68bab1d2"}, - {file = "coverage-7.14.1.tar.gz", hash = "sha256:30c08f7d90415aa98b3c990385dea2939b0da55f38515e5b369b83655f8523be"}, + {file = "coverage-7.15.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:50913d4bf5ddafa6ca3693da5e4dd833dd1b772e0283c99ca7f7d287db67331a"}, + {file = "coverage-7.15.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:359e141ccd33893ce3f1ad5525f8b96083003677c82182e5907d62d4ea5799fc"}, + {file = "coverage-7.15.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3200b6204935f928c64b2ca1f923ab8c1acb7c9de45ec61569711b34d25cccaf"}, + {file = "coverage-7.15.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:be616bf61346883b2cfdc5178669647e03531d81ab761a7e378558b7e8bcb628"}, + {file = "coverage-7.15.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cc7bafc3fe1059463a8fdd97ca79972d6e2bf819d775c7d54991b5b1971201d6"}, + {file = "coverage-7.15.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b713aa7fcf325a01d4184d848acb46fd84f78fdb0978470c636b23a06a753d91"}, + {file = "coverage-7.15.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e38e6fba2d56652fdfaf0231f8f78aeb805234a912de25dc291ee5cce5b8faa4"}, + {file = "coverage-7.15.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:884499f42e382675be80770391983b90e0c0c774d87dbeeebf5f991cf6612b20"}, + {file = "coverage-7.15.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:840481b12e083dbcbafab14794a8781a958edf327c8d3d70b4eee42f9b8253aa"}, + {file = "coverage-7.15.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:276646e9481703d09f854f3b2f018f24e19fd7049ae670a92570043eb97203b1"}, + {file = "coverage-7.15.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:4de4b4d3f5545aa6c60dc4efd9c63b5b5dcc3bf00fe83146b2bdfffb8f6613bd"}, + {file = "coverage-7.15.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5c504097b2a89b1e85bc6070d920df77daec701337e3aeef2c17775a5dd0ca90"}, + {file = "coverage-7.15.0-cp310-cp310-win32.whl", hash = "sha256:f6e80ed91f98316e86b9c137206b04b2bcfbffccbdff49bd2eb09dddb1cf14e0"}, + {file = "coverage-7.15.0-cp310-cp310-win_amd64.whl", hash = "sha256:b3b3e22030f3f6f5e01a5ce69936552a5c0f6992b7698777377b99041961031f"}, + {file = "coverage-7.15.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:003fff99412ea848c0aaebcc78ed2b6ce7d8a1227ed17e68470672770b78a02a"}, + {file = "coverage-7.15.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5cbd804bf2784ce7b45114516050f346ecd50f960c4bb630a7ee9e1d78fa2118"}, + {file = "coverage-7.15.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8773e15c23305b58882a4611fb9b2755977eae0dc2e515366a1b6c98866cc4c2"}, + {file = "coverage-7.15.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f50e40081494c1dc4239ebb202014cbcc3306ea96fb6302a34c8cc0967fc5ae8"}, + {file = "coverage-7.15.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:daf96f37f5fc3a7b6c6da862eb4aee61c426bd63da236ed4a73ef0e503b4bca5"}, + {file = "coverage-7.15.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:51aa20f6ae2788fd197747766edf4cd8234fd9423309b934257fa6b21a592723"}, + {file = "coverage-7.15.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:03d1f922757662eb7af586e77834792274cff776bc7b1d1a0b66a49ea9d84735"}, + {file = "coverage-7.15.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a6d6acc9a7666245e6133dd15144ca038a85a9cd5026bb06d6bbae9e77440dc9"}, + {file = "coverage-7.15.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1ac2c4c27c7df851dc9a017c2d7de00b69147e84ba3d96f37a530b0b6fb51035"}, + {file = "coverage-7.15.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:b761a1d504fd4bd1f20f418753964dca9f5862a511fc854dac58296b3b223671"}, + {file = "coverage-7.15.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:e43b045e11c16e897895758ae90e4a90cf99e93d58549e2f90c0e2272e155695"}, + {file = "coverage-7.15.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:589b54513e901739f4b4582c705ce96b80c96f57641b1464607e2367a270e540"}, + {file = "coverage-7.15.0-cp311-cp311-win32.whl", hash = "sha256:106781b8482749162d0b47056937ba0933508e5d9447f65a5e7d5c422f0d6bb4"}, + {file = "coverage-7.15.0-cp311-cp311-win_amd64.whl", hash = "sha256:821e92b3631d762a339695824cadbbc73020354eba2a23a551a99ad34938fbe6"}, + {file = "coverage-7.15.0-cp311-cp311-win_arm64.whl", hash = "sha256:309990eb5fb8014b9f67cb211f7fd41876ec8a88a88d3ae76de0ed1d611e3640"}, + {file = "coverage-7.15.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b75ee5e8cb7575636ac598719b4307ac529ec8fcd79608a35c3cd4d4dada812d"}, + {file = "coverage-7.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffb31267816b93b075302248cc1737506081b4f163df4401e9df1a6424aafabe"}, + {file = "coverage-7.15.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e4d0bb73455bf97ab243a8f12c37c686ccf1c13bb614b7b85f1d062f06f42b2c"}, + {file = "coverage-7.15.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:20d9ccc4ebd0edc434d86dfd2a1dd2a8efa6b6b3073d0485a394fee86459ebb4"}, + {file = "coverage-7.15.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:20c8a976c365c8cb12f0cbd099508772ea41fb5fa80657a8506df0e11bd278c5"}, + {file = "coverage-7.15.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f948fd5ba1b9cbca91f0ae08b4c1ce2b139509149a435e2585d056d57d70bf01"}, + {file = "coverage-7.15.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f58185f06edf6ad68ec9fb155d63ef650c82f3fbd7e1770e2867751fb13158f4"}, + {file = "coverage-7.15.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:02adc79a920c73c647c5d117f55747df7f2de94571884758ce8bc58e04f0a796"}, + {file = "coverage-7.15.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:6eb7c300fbed667fd6e3588eba71c1904cdb06110ca6fdf908c26bdd88b8e382"}, + {file = "coverage-7.15.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b5fb23fa2de9dce1f5c36c09066d8fcda16cd96e8e26686caa2d7cb9b567d65c"}, + {file = "coverage-7.15.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:cec79341dbe6281484024979976d0c7f22beae08b4a254655decd25d42cbe766"}, + {file = "coverage-7.15.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6c664c5444b1d970b1b2a450e21fb19ee5c9cfdf151ded2dda37260031cca0da"}, + {file = "coverage-7.15.0-cp312-cp312-win32.whl", hash = "sha256:5f764a3fa339bde6b3aa97657f5a6a3a9451e4a5b4ea98a2892c773a43525f77"}, + {file = "coverage-7.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:52f9a4d2c4c56c8848bc2f524916698354b0211488b38c49ad9ae54f6cafbff6"}, + {file = "coverage-7.15.0-cp312-cp312-win_arm64.whl", hash = "sha256:31e5c3e70c85307ea35a12964e2e40f56ca2ee4b1c8c721ccf4609d17071080b"}, + {file = "coverage-7.15.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5be4caf3b28836f078abe700f8944dac4a65d78f16d6c600c89cb624e5535782"}, + {file = "coverage-7.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:dd58ad1404704303ca8d4f4b8a1095e7cbc7040ef17a66df1e6619aa10176430"}, + {file = "coverage-7.15.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bbcbb317c2e5ded5b21104af81c29f391be2af98d065693ffbe8d23949b948e5"}, + {file = "coverage-7.15.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:27f31ecb458da3f859aab3f15ada871eb7a7768807d88df4a9f186bb17737970"}, + {file = "coverage-7.15.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13fb759be317fdc62e0f56bffdf61cfcb45c7761ad6b71e3e583e71a67ae753c"}, + {file = "coverage-7.15.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d5cf007add5ab4bb8fa9f4c77e3732127c9e6cad501d7db43355fbfafca0be84"}, + {file = "coverage-7.15.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cc78d9843bd576fbe2118248258d485e968dc535f95ed504a7b0867ba9b51389"}, + {file = "coverage-7.15.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a263060f1de0b4b74b4e089c2a70b8003b3781c733329a9c8fd54995328f9950"}, + {file = "coverage-7.15.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:c48decf16e0dfd5b049c7d5e82200c23c08126719142998d4f172444e3d0529e"}, + {file = "coverage-7.15.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:08fb028000ed0aaa0a4cbdfbb98be7cb42f370db973fbbb469733505ab20e13e"}, + {file = "coverage-7.15.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fb7dc0c3b7d8a1077abea0b8546ebc5e26d6ef6ecefc2f0f5ad2b8a53bdad837"}, + {file = "coverage-7.15.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6cb3602054ccbe9f0d8c2dc04bbeba90d5719236e2cd06e042ddd6d3fc7b6e37"}, + {file = "coverage-7.15.0-cp313-cp313-win32.whl", hash = "sha256:0bf781da64326b677be344df505171435b6f58716108606621d5d27d964fff8b"}, + {file = "coverage-7.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:2c57a275078ee3fa185f83e400f765bc764a549de66d99b47881645cbd4ea629"}, + {file = "coverage-7.15.0-cp313-cp313-win_arm64.whl", hash = "sha256:3812c61afc6685c7999b39320779ab8f43b7a3081fdb0def39976e56fbdb9a21"}, + {file = "coverage-7.15.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:41cb79af843222e11da87127ad0ecbfa878abadd0f770a4a99391a27d3887324"}, + {file = "coverage-7.15.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7d2008989ef8fe54188d3f3bfa2e3099b025af11e90a6a1b9e7dc433d04263d8"}, + {file = "coverage-7.15.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:769e8ece11a596315ebf5aa7ec383aeeed016c091d2bf6363ffb996d41529092"}, + {file = "coverage-7.15.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:65a6b6164ee5c39e2f3803f314292d6c61a607ba7fee253d1e03c42dc3903502"}, + {file = "coverage-7.15.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:75128817f95a5c45bb01d65fd2d8b9cb54bbe03d81608fb70e3e14b437ad56c2"}, + {file = "coverage-7.15.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9887bb428fe2d4cd4bee89bac1a6c9932f484afd5b36fbd4ff6ea5f825bb1f5e"}, + {file = "coverage-7.15.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0bfc0be1f702042207a93a00523b1065ee1fe951e96edf311581c0bbc2e34888"}, + {file = "coverage-7.15.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f64627d55def5a43282d70e08396672692f77e4da610a5bb8bb4060b432b6859"}, + {file = "coverage-7.15.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:2c6f0fa473003905c6d5bac328ee4eba9fbea654f15bc24b8a3274b23363fa99"}, + {file = "coverage-7.15.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2bcf9afaf064172c6ec3c58a325a9957ad1178c05dd934e25f253321776e0676"}, + {file = "coverage-7.15.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:baf06bc987115d6fb938d403f7eab684a057766c490367999a2b71a6883110c6"}, + {file = "coverage-7.15.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f0405f2ff97b1c4c0e782cb32e02f32369bcf2e6b618b591d67e1ea754575dfe"}, + {file = "coverage-7.15.0-cp314-cp314-win32.whl", hash = "sha256:ab282853ed5fbd64bbb162f19cb8fcb7087187508a6374b4f9c34ec1577c4e8f"}, + {file = "coverage-7.15.0-cp314-cp314-win_amd64.whl", hash = "sha256:3bb3040e9f4bbe26fcb0cd7cc85ac63e630d3f3a9c74f027abf4caa27e706663"}, + {file = "coverage-7.15.0-cp314-cp314-win_arm64.whl", hash = "sha256:346771144d34f7fa84ec28386f78e0f31653f33cf35e19d253d5b35f9e8201da"}, + {file = "coverage-7.15.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d34a010905fb6401324ba016b5da03d574967f7b21ce48ea41e66f0f1f95f641"}, + {file = "coverage-7.15.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bb25d825d885ca8036795dacfc3924d33091fc76d71ebc99420c6b79e77d96fa"}, + {file = "coverage-7.15.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:94c9686bfe8a9a6810297aecbd99beaa3445f9e8dc2f80b1382cca0d86b64461"}, + {file = "coverage-7.15.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9bd671c25f9d85f09d7ec481d0e43d5139f486c06a37139847a7ce569788af72"}, + {file = "coverage-7.15.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:110cbdf8d2e216577312cf06ccf85539c0e5a5420ef747e4a4719b5e483c88cd"}, + {file = "coverage-7.15.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2c5d4619214f1d9993e7b00a8600d14614b7e9d84e89507460b126aa5e6559e5"}, + {file = "coverage-7.15.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:781a704516e2d8346fbbd5be6c6f3412dd824785146528b3a01816f26c081007"}, + {file = "coverage-7.15.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd4a1b44bcb65ee29e947ac92bbee04956df3a6bfc6143641bb6cae7ede00fc9"}, + {file = "coverage-7.15.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0e4950c9d6d3e39c64c991814ff315e2d0b9cb8152363594212c9e55208c0a8f"}, + {file = "coverage-7.15.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:fe9c87ff42e5472d80d21704972e1f96e104a0a599d77c5e35db5a3c562e2571"}, + {file = "coverage-7.15.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f00d5ae1dd2fe13fb8186e3e7d37bcbd8b25c0d764ff7d1b32cef9be058510a8"}, + {file = "coverage-7.15.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:363ab38cc78b615f11c9cac3cf1d7eef950c18b9fdedfb9066f59461dcf84d68"}, + {file = "coverage-7.15.0-cp314-cp314t-win32.whl", hash = "sha256:54fd9c53a5fafff509195f1b6a3f9be615d8e8362a3629ff1de23d270c03c86b"}, + {file = "coverage-7.15.0-cp314-cp314t-win_amd64.whl", hash = "sha256:87b47553097ba185ed964866078e7e63adea9f5f51b5f39691c34f30afd21080"}, + {file = "coverage-7.15.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aeefb2dd178fe7eee79f0ad25d75855cb35ee9ed472db2c5ea06f5b4fd00cec5"}, + {file = "coverage-7.15.0-py3-none-any.whl", hash = "sha256:56da6a4cbe8f7e9e80bd072ca9cefe67d7106a440a7ec06519ec6507ac94ad19"}, + {file = "coverage-7.15.0.tar.gz", hash = "sha256:9ac3fe7a1435986463eaa8ee253ae2f2a268709ba4ae5c7dd1f52a05391ad78f"}, ] [package.extras] @@ -333,26 +282,26 @@ test = ["pytest (>=8.3.0,<8.4.0)", "pytest-benchmark (>=5.1.0,<5.2.0)", "pytest- [[package]] name = "distlib" -version = "0.4.0" +version = "0.4.3" description = "Distribution utilities" optional = false python-versions = "*" groups = ["dev"] files = [ - {file = "distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16"}, - {file = "distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d"}, + {file = "distlib-0.4.3-py2.py3-none-any.whl", hash = "sha256:4b0ce306c966eb73bc3a7b6abad017c556dadd92c44701562cd528ac7fde4d5b"}, + {file = "distlib-0.4.3.tar.gz", hash = "sha256:f152097224a0ae24be5a0f6bae1b9359af82133bce63f98a95f86cae1aede9ed"}, ] [[package]] name = "filelock" -version = "3.29.0" +version = "3.29.7" description = "A platform independent file lock." optional = false python-versions = ">=3.10" groups = ["dev"] files = [ - {file = "filelock-3.29.0-py3-none-any.whl", hash = "sha256:96f5f6344709aa1572bbf631c640e4ebeeb519e08da902c39a001882f30ac258"}, - {file = "filelock-3.29.0.tar.gz", hash = "sha256:69974355e960702e789734cb4871f884ea6fe50bd8404051a3530bc07809cf90"}, + {file = "filelock-3.29.7-py3-none-any.whl", hash = "sha256:987db6f789a3a2a59f55081801b2b3697cb97e2a736b5f1a9e99b559285fbc51"}, + {file = "filelock-3.29.7.tar.gz", hash = "sha256:5b481979797ae69e72f0b389d89a80bdd585c260c5b3f1fb9c0a5ba9bb3f195d"}, ] [[package]] @@ -372,14 +321,14 @@ license = ["ukkonen"] [[package]] name = "idna" -version = "3.16" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.16-py3-none-any.whl", hash = "sha256:cc246e3a3f89580c3a951b5ad298ca4638078b2cdd4f115654332b5c26daded5"}, - {file = "idna-3.16.tar.gz", hash = "sha256:d7a6da03db833450fca25d2358ac9ff06cd624577a4aea3a596d5c0f77b8e03d"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] @@ -442,14 +391,14 @@ files = [ [[package]] name = "platformdirs" -version = "4.9.6" +version = "4.10.0" description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." optional = false python-versions = ">=3.10" groups = ["dev"] files = [ - {file = "platformdirs-4.9.6-py3-none-any.whl", hash = "sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917"}, - {file = "platformdirs-4.9.6.tar.gz", hash = "sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a"}, + {file = "platformdirs-4.10.0-py3-none-any.whl", hash = "sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a"}, + {file = "platformdirs-4.10.0.tar.gz", hash = "sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7"}, ] [[package]] @@ -749,32 +698,16 @@ pytest = ">=7" [package.extras] testing = ["process-tests", "pytest-xdist", "virtualenv"] -[[package]] -name = "pytest-dotenv" -version = "0.5.2" -description = "A py.test plugin that parses environment files before running tests" -optional = false -python-versions = "*" -groups = ["test"] -files = [ - {file = "pytest-dotenv-0.5.2.tar.gz", hash = "sha256:2dc6c3ac6d8764c71c6d2804e902d0ff810fa19692e95fe138aefc9b1aa73732"}, - {file = "pytest_dotenv-0.5.2-py3-none-any.whl", hash = "sha256:40a2cece120a213898afaa5407673f6bd924b1fa7eafce6bda0e8abffe2f710f"}, -] - -[package.dependencies] -pytest = ">=5.0.0" -python-dotenv = ">=0.9.1" - [[package]] name = "python-discovery" -version = "1.3.1" +version = "1.4.4" description = "Python interpreter discovery" optional = false python-versions = ">=3.8" groups = ["dev"] files = [ - {file = "python_discovery-1.3.1-py3-none-any.whl", hash = "sha256:ed188687ebb3b82c01a17cd5ac62fc94d9f6487a7f1a0f9dfe89753fec91039c"}, - {file = "python_discovery-1.3.1.tar.gz", hash = "sha256:62f6db28064c9613e7ca76cb3f00c38c839a07c31c00dfe7ed0986493d2150a6"}, + {file = "python_discovery-1.4.4-py3-none-any.whl", hash = "sha256:abebe9120b43453b68c908acfb1e72a19d1a959ed2cb620ad38fc57d08056dbe"}, + {file = "python_discovery-1.4.4.tar.gz", hash = "sha256:5cad33982d412c1f3ffb8f9ca4ea292c9680bca3942451d30b69c37fce53a4a3"}, ] [package.dependencies] @@ -935,14 +868,14 @@ files = [ [[package]] name = "typing-extensions" -version = "4.15.0" +version = "4.16.0" description = "Backported and Experimental Type Hints for Python 3.9+" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, - {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, + {file = "typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8"}, + {file = "typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5"}, ] [[package]] @@ -980,23 +913,23 @@ zstd = ["backports-zstd (>=1.0.0) ; python_version < \"3.14\""] [[package]] name = "virtualenv" -version = "21.3.3" +version = "21.6.0" description = "Virtual Python Environment builder" optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" groups = ["dev"] files = [ - {file = "virtualenv-21.3.3-py3-none-any.whl", hash = "sha256:7d5987d8369e098e41406efb780a3d4ca79280097293899e351a6407ee153ab3"}, - {file = "virtualenv-21.3.3.tar.gz", hash = "sha256:f5bda277e553b1c2b3c1a8debfc30496e1288cc93ce6b7b71b3280047e317328"}, + {file = "virtualenv-21.6.0-py3-none-any.whl", hash = "sha256:bce9d097950fef9d81129b333babfb7767072850c2f1acce0ec536708401bfd1"}, + {file = "virtualenv-21.6.0.tar.gz", hash = "sha256:e18a4d750f2b64dea73e72ffde3922f3c52365fabdc8157ebd3da20d031c4734"}, ] [package.dependencies] distlib = ">=0.3.7,<1" filelock = {version = ">=3.24.2,<4", markers = "python_version >= \"3.10\""} platformdirs = ">=3.9.1,<5" -python-discovery = ">=1.3.1" +python-discovery = ">=1.4.2" [metadata] lock-version = "2.1" python-versions = ">=3.11" -content-hash = "3834715d55e7a30c87eae83f735f51328d3fc86b0324ca934084d37c11d8ab56" +content-hash = "be839f147f93f86d74e84ed19924b23b49d74c0601e5a0dce4d204394326cf57" diff --git a/pyproject.toml b/pyproject.toml index cb03eae..c4f295b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,13 +46,14 @@ pre-commit = "^4.6.0" [tool.poetry.group.test.dependencies] pytest = "^9.1.1" pytest-cov = "^7.1.0" -pytest-dotenv = "^0.5.2" +python-dotenv = "^1.1.0" [tool.pytest.ini_options] -addopts = "-x -p no:warnings --cov-report=term --cov-report=term-missing --no-cov-on-fail --cov=src --ignore=__intern" -env_override_existing_values = 0 -env_files = ["tests/.env.test"] - +addopts = "-x -p no:warnings --cov-report=term --cov-report=term-missing --no-cov-on-fail --cov=src --ignore=__intern -m \"not e2e\"" +markers = [ + "e2e: developer-run tests against a live VideoIPath instance (gated on VIPAT_E2E_ENABLED=1; excluded by default). Run with '-m e2e'.", + "incremental: sequential workflow steps; later steps are skipped when an earlier one fails.", +] [virtualenvs] in-project = true @@ -61,9 +62,12 @@ in-project = true set-videoipath-version = "vipat_cli_scripts.generate_all:main" get-videoipath-version = "vipat_cli_scripts.version_utils:get_videoipath_version" list-videoipath-versions = "vipat_cli_scripts.version_utils:list_videoipath_versions" +test-unit = "vipat_cli_scripts.test_runner:run_unit" +test-e2e = "vipat_cli_scripts.test_runner:run_e2e" +test = "vipat_cli_scripts.test_runner:run" [tool.ruff] -include = ["pyproject.toml", "src/**/*.py", "tests/**/*.py"] +include = ["pyproject.toml", "src/**/*.py", "tests/**/*.py", "docs/examples/**/*.py"] # Formatter config line-length = 120 diff --git a/src/videoipath_automation_tool/apps/__init__.py b/src/videoipath_automation_tool/apps/__init__.py index 624c414..dd26ecb 100644 --- a/src/videoipath_automation_tool/apps/__init__.py +++ b/src/videoipath_automation_tool/apps/__init__.py @@ -1,4 +1,5 @@ from videoipath_automation_tool.apps.inventory import * +from videoipath_automation_tool.apps.inspect import * from videoipath_automation_tool.apps.preferences import * from videoipath_automation_tool.apps.profile import * from videoipath_automation_tool.apps.topology import * diff --git a/src/videoipath_automation_tool/apps/inspect/__init__.py b/src/videoipath_automation_tool/apps/inspect/__init__.py new file mode 100644 index 0000000..e591354 --- /dev/null +++ b/src/videoipath_automation_tool/apps/inspect/__init__.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +from videoipath_automation_tool.apps.inspect import model as _model +from videoipath_automation_tool.apps.inspect.app.actions import ConflictStrategy as ConflictStrategy +from videoipath_automation_tool.apps.inspect.transaction import CommitResult as CommitResult +from videoipath_automation_tool.apps.inspect.transaction import InspectTransaction as InspectTransaction +from videoipath_automation_tool.apps.inspect.domain import InspectAlarm as InspectAlarm +from videoipath_automation_tool.apps.inspect.domain import InspectDevice as InspectDevice +from videoipath_automation_tool.apps.inspect.domain import InspectEdge as InspectEdge +from videoipath_automation_tool.apps.inspect.domain import InspectModule as InspectModule +from videoipath_automation_tool.apps.inspect.domain import InspectPort as InspectPort +from videoipath_automation_tool.apps.inspect.domain import InspectPortTemplate as InspectPortTemplate +from videoipath_automation_tool.apps.inspect.domain import InspectService as InspectService +from videoipath_automation_tool.apps.inspect.domain import PortFromTemplate as PortFromTemplate +from videoipath_automation_tool.apps.inspect.domain import VirtualDeviceSpec as VirtualDeviceSpec +from videoipath_automation_tool.apps.inspect.domain import VirtualModuleSpec as VirtualModuleSpec +from videoipath_automation_tool.apps.inspect.errors import InspectCommitConflictError as InspectCommitConflictError +from videoipath_automation_tool.apps.inspect.errors import InspectCommitError as InspectCommitError +from videoipath_automation_tool.apps.inspect.errors import InspectConflict as InspectConflict +from videoipath_automation_tool.apps.inspect.errors import InspectEntityNotFoundError as InspectEntityNotFoundError +from videoipath_automation_tool.apps.inspect.errors import InspectError as InspectError +from videoipath_automation_tool.apps.inspect.errors import InspectQueryTooLongError as InspectQueryTooLongError +from videoipath_automation_tool.apps.inspect.app import InspectApp as InspectApp +from videoipath_automation_tool.apps.inspect.model import * + +# InspectSnapshot is an internal implementation detail of InspectApp; it is not part of +# the public API. Interact with the topology entirely through ``app.inspect``. + +__all__ = [ + "CommitResult", + "ConflictStrategy", + "InspectAlarm", + "InspectApp", + "InspectCommitConflictError", + "InspectCommitError", + "InspectConflict", + "InspectDevice", + "InspectEdge", + "InspectEntityNotFoundError", + "InspectError", + "InspectModule", + "InspectPort", + "InspectPortTemplate", + "InspectQueryTooLongError", + "InspectService", + "InspectTransaction", + "PortFromTemplate", + "VirtualDeviceSpec", + "VirtualModuleSpec", + *_model.__all__, +] diff --git a/src/videoipath_automation_tool/apps/inspect/api/__init__.py b/src/videoipath_automation_tool/apps/inspect/api/__init__.py new file mode 100644 index 0000000..7d5e871 --- /dev/null +++ b/src/videoipath_automation_tool/apps/inspect/api/__init__.py @@ -0,0 +1,6 @@ +from __future__ import annotations + +from . import queries +from .inspect_api import InspectAPI + +__all__ = ["InspectAPI", "queries"] diff --git a/src/videoipath_automation_tool/apps/inspect/api/inspect_api.py b/src/videoipath_automation_tool/apps/inspect/api/inspect_api.py new file mode 100644 index 0000000..f56e426 --- /dev/null +++ b/src/videoipath_automation_tool/apps/inspect/api/inspect_api.py @@ -0,0 +1,246 @@ +"""Raw Inspect API layer: one method per verified endpoint, typed responses, no business logic. + +All reads use the collector namespace only; scoped queries come from +``queries.py``. Writes go through ``updateTopology`` and the network +actions (``addDevices``, ``syncDevices``, virtual-device actions). +""" + +from __future__ import annotations + +import logging +from typing import Any, Optional + +from . import queries +from videoipath_automation_tool.apps.inspect.model.actions import ( + InspectApiAddDevicesItem, + InspectApiAddDevicesRequest, + InspectApiLookupEdgesRequest, + InspectApiLookupEdgesResponse, + InspectApiLookupInspectDeviceRequest, + InspectApiLookupInspectDeviceResponse, + InspectApiLookupSyncInfoRequest, + InspectApiLookupSyncInfoResponse, + InspectApiLookupVerticesRequest, + InspectApiLookupVerticesResponse, + InspectApiSyncDevicesRequest, + InspectApiSyncDevicesRequestData, +) +from videoipath_automation_tool.apps.inspect.model.alarms import InspectApiAlarmItem +from videoipath_automation_tool.apps.inspect.model.collector import ( + InspectApiCollectorResponse, + InspectApiExternalEdgesByDeviceKeyItem, + InspectApiNodeStatusItem, + InspectApiPathItem, +) +from videoipath_automation_tool.apps.inspect.model.common import ( + InspectApiSimpleActionResponse, +) +from videoipath_automation_tool.apps.inspect.model.tags import ( + InspectApiAssignTagData, + InspectApiAssignTagRequest, +) +from videoipath_automation_tool.apps.inspect.model.update_topology import ( + InspectApiUpdateTopologyData, + InspectApiUpdateTopologyRequest, + InspectApiUpdateTopologyResponse, +) +from videoipath_automation_tool.apps.inspect.model.virtual import ( + InspectApiAddVirtualTopologyData, + InspectApiAddVirtualTopologyRequest, + InspectApiUpdateVirtualInstancesData, + InspectApiUpdateVirtualInstancesRequest, + InspectApiUpdateVirtualInstancesResponse, + InspectApiUpdateVirtualTemplatesData, + InspectApiUpdateVirtualTemplatesRequest, + InspectApiVirtualDeviceInstance, + InspectApiVirtualTemplateItem, +) +from videoipath_automation_tool.connector.vip_connector import VideoIPathConnector +from videoipath_automation_tool.utils.cross_app_utils import create_fallback_logger + + +class InspectAPI: + def __init__(self, vip_connector: VideoIPathConnector, logger: Optional[logging.Logger] = None) -> None: + self._logger = logger or create_fallback_logger("videoipath_automation_tool_inspect_api") + self.vip_connector = vip_connector + self._logger.debug("Inspect API initialized.") + + # --- Collector reads (scoped) --- + + def get_device_skeleton(self) -> list[InspectApiNodeStatusItem]: + """All devices without module/port detail (skeleton load).""" + response = self.vip_connector.rest.get(queries.device_skeleton(), allow_projection=True) + items = _extract_items(response.data, "status", "collector", "inspect", "nodeStatus") + return [InspectApiNodeStatusItem.model_validate(item) for item in items] + + def get_device_detail(self, device_id: str) -> Optional[InspectApiNodeStatusItem]: + """One device's full nodeStatus sub-tree (lazy hydration).""" + response = self.vip_connector.rest.get(queries.device_detail(device_id), allow_projection=True) + items = _extract_items(response.data, "status", "collector", "inspect", "nodeStatus") + if not items: + return None + return InspectApiNodeStatusItem.model_validate(items[0]) + + def get_edge_skeleton(self) -> list[InspectApiExternalEdgesByDeviceKeyItem]: + """All external-edge device pairs, lean projection.""" + response = self.vip_connector.rest.get(queries.edge_skeleton(), allow_projection=True) + items = _extract_items(response.data, "status", "collector", "externalEdgesByDeviceKey") + return [InspectApiExternalEdgesByDeviceKeyItem.model_validate(item) for item in items] + + def get_edge_pair(self, pair_id: str) -> Optional[InspectApiExternalEdgesByDeviceKeyItem]: + """A single external-edge device pair (targeted refresh).""" + response = self.vip_connector.rest.get(queries.edge_pair(pair_id), allow_projection=True) + items = _extract_items(response.data, "status", "collector", "externalEdgesByDeviceKey") + if not items: + return None + return InspectApiExternalEdgesByDeviceKeyItem.model_validate(items[0]) + + def get_paths_section(self) -> list[InspectApiPathItem]: + """The services/paths section.""" + response = self.vip_connector.rest.get(queries.paths_section(), allow_projection=True) + items = _extract_items(response.data, "status", "collector", "inspect", "paths") + return [InspectApiPathItem.model_validate(item) for item in items] + + def get_alarms_section(self) -> list[InspectApiAlarmItem]: + """The current-alarms section (``status/alarms/current``).""" + response = self.vip_connector.rest.get(queries.alarms_section(), allow_projection=True) + items = _extract_items(response.data, "status", "alarms", "current") + return [InspectApiAlarmItem.model_validate(item) for item in items] + + def get_collector_full(self) -> InspectApiCollectorResponse: + """The full collector aggregate (eager / fallback mode).""" + response = self.vip_connector.rest.get(queries.collector_full(), allow_projection=True) + return InspectApiCollectorResponse.model_validate({"data": response.data, "header": _header_dict(response)}) + + # --- Virtual device / port-template reads --- + + def get_virtual_templates(self) -> list[InspectApiVirtualTemplateItem]: + """All port templates (UI: Manage port templates).""" + response = self.vip_connector.rest.get(queries.virtual_templates(), allow_projection=True) + items = _extract_items(response.data, "status", "network", "virtualTemplates") + return [InspectApiVirtualTemplateItem.model_validate(item) for item in items] + + def get_virtual_devices(self) -> list[InspectApiVirtualDeviceInstance]: + """All virtual device module/port definitions.""" + response = self.vip_connector.rest.get(queries.virtual_devices(), allow_projection=True) + items = _extract_items(response.data, "status", "network", "virtualDevices") + return [InspectApiVirtualDeviceInstance.model_validate(item) for item in items] + + # --- Lookups (baselines for compare-and-commit) --- + + def lookup_inspect_device(self, device_id: str) -> InspectApiLookupInspectDeviceResponse: + request = InspectApiLookupInspectDeviceRequest(data=device_id) + response = self.vip_connector.rest.post("/rest/v2/actions/status/collector/lookupInspectDevice", request) + return InspectApiLookupInspectDeviceResponse.model_validate(_post_envelope(response)) + + def lookup_vertices(self, vertex_ids: list[str]) -> InspectApiLookupVerticesResponse: + request = InspectApiLookupVerticesRequest(data=vertex_ids) + response = self.vip_connector.rest.post("/rest/v2/actions/status/collector/lookupInspectVertexByIds", request) + return InspectApiLookupVerticesResponse.model_validate(_post_envelope(response)) + + def lookup_edges(self, edge_ids: list[str]) -> InspectApiLookupEdgesResponse: + request = InspectApiLookupEdgesRequest(data=edge_ids) + response = self.vip_connector.rest.post("/rest/v2/actions/status/collector/lookupInspectEdgesByIds", request) + return InspectApiLookupEdgesResponse.model_validate(_post_envelope(response)) + + def lookup_sync_info(self, device_ids: list[str]) -> InspectApiLookupSyncInfoResponse: + request = InspectApiLookupSyncInfoRequest(data=device_ids) + response = self.vip_connector.rest.post("/rest/v2/actions/status/collector/lookupSyncInfo", request) + return InspectApiLookupSyncInfoResponse.model_validate(_post_envelope(response)) + + # --- Writes --- + + def update_topology(self, delta: InspectApiUpdateTopologyData) -> InspectApiUpdateTopologyResponse: + request = InspectApiUpdateTopologyRequest(data=delta) + response = self.vip_connector.rest.post("/rest/v2/actions/status/collector/updateTopology", request) + return InspectApiUpdateTopologyResponse.model_validate(_post_envelope(response)) + + def assign_tag(self, tag_id: str, element_ids: list[str]) -> InspectApiSimpleActionResponse: + """Bind ``tag_id`` to one or more resource ids (e.g. ``device:{modulePid}``).""" + request = InspectApiAssignTagRequest(data=InspectApiAssignTagData(tagId=tag_id, elementIds=element_ids)) + response = self.vip_connector.rest.post("/rest/v2/actions/status/tags/assignTag", request) + return _tag_action_response(response) + + def unassign_tag(self, tag_id: str, element_ids: list[str]) -> InspectApiSimpleActionResponse: + """Remove ``tag_id`` from one or more resource ids (e.g. ``device:{modulePid}``).""" + request = InspectApiAssignTagRequest(data=InspectApiAssignTagData(tagId=tag_id, elementIds=element_ids)) + response = self.vip_connector.rest.post("/rest/v2/actions/status/tags/unassignTag", request) + return _tag_action_response(response) + + def add_devices(self, items: list[InspectApiAddDevicesItem]) -> InspectApiSimpleActionResponse: + request = InspectApiAddDevicesRequest(data=items) + response = self.vip_connector.rest.post("/rest/v2/actions/status/network/addDevices", request) + return InspectApiSimpleActionResponse.model_validate(_post_envelope(response)) + + def sync_devices( + self, device_ids: list[str], add_only: bool = True, conflict_strategy: int = 0 + ) -> InspectApiSimpleActionResponse: + request = InspectApiSyncDevicesRequest( + data=InspectApiSyncDevicesRequestData(ids=device_ids, addOnly=add_only, conflictStrategy=conflict_strategy) + ) + response = self.vip_connector.rest.post("/rest/v2/actions/status/network/syncDevices", request) + return InspectApiSimpleActionResponse.model_validate(_post_envelope(response)) + + def update_virtual_instances( + self, data: InspectApiUpdateVirtualInstancesData + ) -> InspectApiUpdateVirtualInstancesResponse: + """Create virtual devices (UI: Create virtual devices); wire also supports update/remove.""" + request = InspectApiUpdateVirtualInstancesRequest(data=data) + response = self.vip_connector.rest.post("/rest/v2/actions/status/network/updateVirtualInstances", request) + return InspectApiUpdateVirtualInstancesResponse.model_validate(_post_envelope(response)) + + def update_virtual_templates(self, data: InspectApiUpdateVirtualTemplatesData) -> InspectApiSimpleActionResponse: + """Add or remove port templates (UI: Manage port templates).""" + request = InspectApiUpdateVirtualTemplatesRequest(data=data) + response = self.vip_connector.rest.post("/rest/v2/actions/status/network/updateVirtualTemplates", request) + return InspectApiSimpleActionResponse.model_validate(_post_envelope(response)) + + def add_virtual_topology(self, data: InspectApiAddVirtualTopologyData) -> InspectApiSimpleActionResponse: + """Add ports from templates to an existing virtual-device module.""" + request = InspectApiAddVirtualTopologyRequest(data=data) + response = self.vip_connector.rest.post("/rest/v2/actions/status/network/addVirtualTopology", request) + return InspectApiSimpleActionResponse.model_validate(_post_envelope(response)) + + +# --- Internal --- + + +def _extract_items(data: dict[str, Any], *path: str) -> list[dict[str, Any]]: + """Walk ``data`` down ``path`` and return the ``_items`` list (empty if any node is absent).""" + node: Any = data + for key in path: + if not isinstance(node, dict): + return [] + node = node.get(key) + if node is None: + return [] + if isinstance(node, dict): + items = node.get("_items", []) + return items if isinstance(items, list) else [] + return [] + + +def _header_dict(response: Any) -> dict[str, Any]: + header = getattr(response, "header", None) + if header is None: + return {} + return header.model_dump(mode="json") if hasattr(header, "model_dump") else dict(header) + + +def _post_envelope(response: Any) -> dict[str, Any]: + """Reassemble a ``{data, header}`` dict from a ResponseV2Post for DTO validation.""" + return {"data": response.data, "header": _header_dict(response)} + + +def _tag_action_response(response: Any) -> InspectApiSimpleActionResponse: + """Normalize assignTag / unassignTag responses (server often returns ``data: null``).""" + header = _header_dict(response) + data = response.data + if not isinstance(data, dict): + data = {"ok": bool(header.get("ok")), "msg": list(header.get("msg") or [])} + elif "ok" not in data: + data = {**data, "ok": bool(header.get("ok")), "msg": list(data.get("msg") or header.get("msg") or [])} + return InspectApiSimpleActionResponse.model_validate({"data": data, "header": header}) + + +__all__ = ["InspectAPI"] diff --git a/src/videoipath_automation_tool/apps/inspect/api/queries.py b/src/videoipath_automation_tool/apps/inspect/api/queries.py new file mode 100644 index 0000000..d853562 --- /dev/null +++ b/src/videoipath_automation_tool/apps/inspect/api/queries.py @@ -0,0 +1,160 @@ +"""Scoped collector query catalogue for the Inspect app. + +Every query string here is **verified live against VideoIPath 2025.4.9** and is the +concrete basis for the skeleton-first + lazy-hydration loading model. +The Inspect UI issues the same paths over WebSocket subscriptions; the package +issues them as REST GETs (see docs/architecture/inspect-app/endpoints.md). + +Projection grammar used below (verified): +- ``*`` select all items of a collection +- ``"_noId"`` suppress a sub-tree (returns ``{}`` / omits the key) +- ``field/**`` select a field's full sub-tree +- ``a,b`` select several leaf fields at the current level +- ``/.../`` pop one level back up in the projection tree + +The full UI projection is too long for a REST GET (HTTP 414); the skeleton +projections here are deliberately trimmed to stay well within the URI limit. +""" + +from __future__ import annotations + +import urllib.parse + +from videoipath_automation_tool.apps.inspect.errors import InspectQueryTooLongError + +# Base for all collector data reads. +_DATA = "/rest/v2/data" + +# Conservative URI length ceiling (path only). Verified queries are ~200-370 chars; +# the full UI projection (thousands of chars) triggers HTTP 414 behind the proxy. +MAX_QUERY_LENGTH = 4000 + + +def encode(path: str) -> str: + """Percent-encode a collector query path, preserving projection-grammar characters.""" + return urllib.parse.quote(path, safe=_SAFE) + + +def device_skeleton() -> str: + """GET path for the device skeleton (all devices, no module/port detail).""" + return _build(_DEVICE_SKELETON) + + +def device_detail(device_id: str) -> str: + """GET path for one device's full nodeStatus sub-tree (modules, ports, vertexInfo, ...).""" + return _build(f"/status/collector/inspect/nodeStatus/{device_id}/**") + + +def edge_skeleton() -> str: + """GET path for the lean edge skeleton (all device pairs, connectivity + status severities).""" + return _build(_EDGE_SKELETON) + + +def edge_pair(pair_id: str) -> str: + """GET path for a single external-edge device pair (targeted refresh).""" + return _build(f"/status/collector/externalEdgesByDeviceKey/{pair_id}" + _EDGE_LEAN_TAIL) + + +def paths_section() -> str: + """GET path for the services/paths section.""" + return _build(_PATHS_SECTION) + + +def alarms_section() -> str: + """GET path for the current-alarms section (``status/alarms/current``).""" + return _build(_ALARMS_SECTION) + + +def collector_full() -> str: + """GET path for the full collector aggregate (eager / fallback mode).""" + return _build(_COLLECTOR_FULL) + + +def virtual_templates() -> str: + """GET path for all port templates (``status/network/virtualTemplates``).""" + return _build(_VIRTUAL_TEMPLATES) + + +def virtual_devices() -> str: + """GET path for all virtual device definitions (``status/network/virtualDevices``).""" + return _build(_VIRTUAL_DEVICES) + + +# --- Internal --- + +# Characters that are meaningful in the projection grammar and must survive encoding. +# Everything else (spaces, double quotes, ...) is percent-encoded. +_SAFE = "/*,'=()" + +# Device skeleton: identity + descriptor + meta (incl. coordinates) + status + syncSeverity +# + tags, with the module sub-tree suppressed ("_noId"). ~200 char URL, ~30 KB / 30 devices. +_DEVICE_SKELETON = ( + "/status/collector/inspect/nodeStatus/*" + "/deviceId,resourceId,syncSeverity" + "/.../descriptor/**" + "/.../.../meta/**" + "/.../.../status/**" + "/.../.../tags/*" + '/.../.../modules/"_noId"' +) + +# Edge skeleton (lean): device-pair keys, edge ids, endpoint port context+labels, and the +# pair-level status severities. No pathDescriptions, no bandwidth values. ~370 char URL. +_EDGE_LEAN_TAIL = ( + "/primary,secondary/devicePid,label" + "/.../.../status/alarm,bandwidth,maintenance,ptp" + "/.../.../primary/data/*/id" + "/.../fromStatus,toStatus/label" + "/.../context/devicePid,modulePid,portPid" + "/.../.../.../.../.../.../secondary/data/*/id" + "/.../fromStatus,toStatus/label" + "/.../context/devicePid,modulePid,portPid" +) +_EDGE_SKELETON = "/status/collector/externalEdgesByDeviceKey/*" + _EDGE_LEAN_TAIL + +# Services / paths section: serviceFields (endpoints, labels, status) + per-hop path structure. +_PATHS_SECTION = ( + "/status/collector/inspect/paths/*" + "/serviceFields/bid,from,fromLabel,isMain,to,toLabel" + "/.../generic/descriptor/**" + "/.../.../serviceStatus/**" + "/.../.../.../path/*/bid,ipDesc" + "/.../structure/deviceId,deviceLabel,devicePid" + "/.../inputStatus,outputStatus/label,pid" +) + +# Current alarms: lean projection of identity, acknowledgement, point labels, and severity/message. +# Verified 2026.2.0: two `/.../` pops after each selected sub-tree (same grammar as the collector +# skeleton); three pops omit ``info``. +_ALARMS_SECTION = ( + "/status/alarms/current/*/acked,hidden/.../id/**/.../.../desc/**/.../.../info/details,severity,sa,headSeverity,time" +) + +# Full aggregate (eager / fallback mode). +_COLLECTOR_FULL = "/status/collector/**" + +# Virtual device / port-template definitions (network status, not collector). +_VIRTUAL_TEMPLATES = "/status/network/virtualTemplates/**" +_VIRTUAL_DEVICES = "/status/network/virtualDevices/**" + + +def _build(path: str) -> str: + encoded = encode(_DATA + path) + if len(encoded) > MAX_QUERY_LENGTH: + raise InspectQueryTooLongError(len(encoded), MAX_QUERY_LENGTH) + return encoded + + +__all__ = [ + "MAX_QUERY_LENGTH", + "encode", + "device_skeleton", + "device_detail", + "edge_skeleton", + "edge_pair", + "paths_section", + "alarms_section", + "collector_full", + "virtual_templates", + "virtual_devices", +] diff --git a/src/videoipath_automation_tool/apps/inspect/app/__init__.py b/src/videoipath_automation_tool/apps/inspect/app/__init__.py new file mode 100644 index 0000000..250e9fb --- /dev/null +++ b/src/videoipath_automation_tool/apps/inspect/app/__init__.py @@ -0,0 +1,5 @@ +from __future__ import annotations + +from videoipath_automation_tool.apps.inspect.app.app import InspectApp + +__all__ = ["InspectApp"] diff --git a/src/videoipath_automation_tool/apps/inspect/app/actions.py b/src/videoipath_automation_tool/apps/inspect/app/actions.py new file mode 100644 index 0000000..b76d6ce --- /dev/null +++ b/src/videoipath_automation_tool/apps/inspect/app/actions.py @@ -0,0 +1,317 @@ +"""Topology device/sync network actions (addDevices, syncDevices, lookupSyncInfo) and +virtual-device create / port-template helpers (updateVirtualInstances, +updateVirtualTemplates, addVirtualTopology). + +These wrap the ``actions/status/network/*`` and ``lookupSyncInfo`` endpoints used by the Inspect +device-onboarding-into-topology workflows. Placement, metadata edits, connections, and removal of +virtual devices go through the same write/transaction path as physical devices; only +creating a virtual device (and managing port templates / adding ports from templates) uses these +dedicated network actions. +""" + +from __future__ import annotations + +import logging +from enum import IntEnum +from typing import TYPE_CHECKING, Iterable, Mapping, Optional, Protocol, Union + +from videoipath_automation_tool.apps.inspect.api import InspectAPI +from videoipath_automation_tool.apps.inspect.domain.device import VirtualDeviceSpec +from videoipath_automation_tool.apps.inspect.domain.port import ( + InspectPortTemplate, + PortFromTemplate, + _ports_to_count_by_template, +) +from videoipath_automation_tool.apps.inspect.errors import InspectError +from videoipath_automation_tool.apps.inspect.model.actions import ( + InspectApiAddDevicesItem, + InspectApiLookupSyncInfoItem, +) +from videoipath_automation_tool.apps.inspect.model.virtual import ( + InspectApiAddVirtualTopologyData, + InspectApiUpdateVirtualInstancesData, + InspectApiUpdateVirtualTemplatesData, + InspectApiVirtualTemplateWriteBody, +) +from videoipath_automation_tool.validators.virtual_device_id import validate_virtual_device_id + +if TYPE_CHECKING: + from videoipath_automation_tool.apps.inspect.domain.device import InspectDevice + from videoipath_automation_tool.apps.inspect.snapshot import InspectSnapshot + + +class ConflictStrategy(IntEnum): + """Conflict handling for ``syncDevices`` (values verified from the Inspect UI bundle).""" + + STRICT = 0 + INVALIDATE_SERVICES = 1 + CANCEL_SERVICES = 2 + + +# (device_id, x, y) or just device_id (placed at 0,0). +AddDeviceSpec = Union[str, tuple[str, float, float]] + + +class _HasInspectApi(Protocol): + _inspect_api: InspectAPI + _logger: logging.Logger + _snapshot: Optional[InspectSnapshot] + + +class InspectActionsMixin: + _inspect_api: InspectAPI + _logger: logging.Logger + _snapshot: Optional[InspectSnapshot] + + def get_sync_info(self: _HasInspectApi, device_ids: list[str]) -> dict[str, InspectApiLookupSyncInfoItem]: + """Per-device sync differences (what would be added/removed/updated on the next sync).""" + if not device_ids: + raise ValueError("device_ids must not be empty.") + return self._inspect_api.lookup_sync_info(device_ids).data + + def add_devices_to_topology( + self: _HasInspectApi, + devices: Iterable[AddDeviceSpec], + *, + sync: bool = True, + add_only: bool = True, + conflict_strategy: ConflictStrategy = ConflictStrategy.STRICT, + ) -> bool: + """Add onboarded devices to the topology graph (``addDevices`` network action). + + By default also runs ``syncDevices`` so driver-reported ports/vertices appear in the + graph — the usual onboarding sequence in one call. Pass ``sync=False`` to place only. + + Args: + devices: device ids, or ``(device_id, x, y)`` tuples to place them. + sync: when ``True`` (default), synchronize driver-reported topology after adding. + add_only: forwarded to ``syncDevices`` (only add new elements; do not remove/update). + conflict_strategy: forwarded to ``syncDevices`` for conflicts with active services. + + Returns: + bool: whether every action that ran reported success (``data.ok``). On sync failure + after a successful add, the snapshot is still refreshed for the added devices. + """ + items = [_to_add_item(spec) for spec in devices] + device_ids = [item.id for item in items] + response = self._inspect_api.add_devices(items) + if not response.data.ok: + self._logger.warning(f"addDevices reported failure: {response.data.msg}") + return False + if sync and device_ids: + sync_response = self._inspect_api.sync_devices( + device_ids, add_only=add_only, conflict_strategy=int(conflict_strategy) + ) + if not sync_response.data.ok: + self._logger.warning(f"syncDevices reported failure: {sync_response.data.msg}") + self._refresh_after_network_action(device_ids) + return False + self._refresh_after_network_action(device_ids) + return True + + def sync_devices( + self: _HasInspectApi, + device_ids: list[str], + add_only: bool = True, + conflict_strategy: ConflictStrategy = ConflictStrategy.STRICT, + ) -> bool: + """Synchronize devices' driver-reported topology into the graph (``syncDevices`` action). + + Args: + device_ids: devices to synchronize. + add_only: only add new elements; do not remove/update existing ones. + conflict_strategy: how to handle conflicts with active services. + + Returns: + bool: whether the action reported success (``data.ok``). + """ + if not device_ids: + raise ValueError("device_ids must not be empty.") + response = self._inspect_api.sync_devices( + device_ids, add_only=add_only, conflict_strategy=int(conflict_strategy) + ) + if not response.data.ok: + self._logger.warning(f"syncDevices reported failure: {response.data.msg}") + return False + self._refresh_after_network_action(device_ids) + return True + + # --- Virtual devices / port templates (UI: Create virtual devices) --- + + def list_port_templates(self: _HasInspectApi) -> list[InspectPortTemplate]: + """List port templates available when building virtual devices.""" + return [InspectPortTemplate.from_wire(item) for item in self._inspect_api.get_virtual_templates()] + + def create_port_template( + self: _HasInspectApi, + template_id: str, + label: str, + vertex: dict, + *, + force: bool = False, + ) -> bool: + """Create or replace a port template (UI: Manage port templates). + + Args: + template_id: client-chosen template id (non-empty). + label: display label in the Add port dropdown. + vertex: vertex configuration payload (same shape as template ``vertex`` on read). + force: pass-through to the network action. + """ + if not template_id: + raise ValueError("template_id must not be empty.") + if not label: + raise ValueError("label must not be empty.") + response = self._inspect_api.update_virtual_templates( + InspectApiUpdateVirtualTemplatesData( + add={template_id: InspectApiVirtualTemplateWriteBody(label=label, vertex=vertex)}, + remove=[], + force=force, + ) + ) + if not response.data.ok: + self._logger.warning(f"updateVirtualTemplates reported failure: {response.data.msg}") + return False + return True + + def delete_port_templates(self: _HasInspectApi, template_ids: Iterable[str], *, force: bool = False) -> bool: + """Remove port templates by id.""" + ids = list(template_ids) + if not ids: + raise ValueError("template_ids must not be empty.") + if any(not template_id for template_id in ids): + raise ValueError("template_ids must not contain empty ids.") + response = self._inspect_api.update_virtual_templates( + InspectApiUpdateVirtualTemplatesData(add={}, remove=ids, force=force) + ) + if not response.data.ok: + self._logger.warning(f"updateVirtualTemplates reported failure: {response.data.msg}") + return False + return True + + def create_virtual_device(self: _HasInspectApi, spec: VirtualDeviceSpec) -> "InspectDevice": + """Create one virtual device from a module/port-template spec. + + The device is created unplaced (``coordinates`` null); use ``place_device`` / + ``update_device`` / ``remove_device_from_topology`` afterwards — the same methods as for + physical devices. ``InspectDevice.is_virtual`` is ``True`` on the returned object. + """ + return self.create_virtual_devices(spec, copies=1)[0] + + def create_virtual_devices( + self: _HasInspectApi, + spec: VirtualDeviceSpec, + *, + copies: int = 1, + ) -> list["InspectDevice"]: + """Create one or more virtual devices from a module/port-template spec. + + Devices are created unplaced (``coordinates`` null); use ``place_device`` to position them + and the normal device write methods for metadata edits / removal. + + Args: + spec: module/port definition (UI: Virtual Devices dialog). + copies: number of identical devices to create (UI: Number of copies). + + Returns: + Created :class:`InspectDevice` objects (server-assigned ``virtual.N`` ids). + + Raises: + InspectError: the network action failed, or created devices are not yet in the snapshot. + """ + if copies < 1: + raise ValueError("copies must be at least 1.") + body = spec.to_wire() + response = self._inspect_api.update_virtual_instances( + InspectApiUpdateVirtualInstancesData( + add=[body for _ in range(copies)], + update={}, + remove=[], + force=False, + ) + ) + if not (response.header.ok and response.data.res.ok and response.data.validation.result.ok): + msgs = response.data.res.msg or response.data.validation.result.msg + detail = "; ".join(m for m in msgs if m) or "updateVirtualInstances reported failure" + raise InspectError(f"create_virtual_devices failed: {detail}") + created_ids = list(response.data.addedDeviceLabels) + snapshot = self._ensure_snapshot() + snapshot.upsert_devices_from_skeleton(created_ids) + devices: list[InspectDevice] = [] + for device_id in created_ids: + device = snapshot.get_device(device_id) + if device is None: + self._logger.warning( + "Virtual device '%s' was created but is not yet visible in the Inspect snapshot.", + device_id, + ) + continue + devices.append(device) + if not devices: + raise InspectError( + f"Virtual device(s) created ({', '.join(created_ids) or 'none'}) " + "but not visible in the Inspect snapshot." + ) + return devices + + def add_virtual_ports( + self: _HasInspectApi, + device_id: str, + module_number: int, + ports: Mapping[str, int] | list[PortFromTemplate], + ) -> bool: + """Add ports from templates to an existing virtual-device module. + + Args: + device_id: virtual device id (``virtual.N``). + module_number: target module index (UI module number). + ports: ``{template_id: count}`` or a list of :class:`PortFromTemplate`. + """ + validate_virtual_device_id(device_id) + if module_number < 0: + raise ValueError("module_number must be non-negative.") + count_by_template = _ports_to_count_by_template(ports) + if not count_by_template: + raise ValueError("ports must not be empty.") + response = self._inspect_api.add_virtual_topology( + InspectApiAddVirtualTopologyData( + deviceId=device_id, + moduleId=module_number, + countByVertexTemplate=count_by_template, + ) + ) + if not response.data.ok: + self._logger.warning(f"addVirtualTopology reported failure: {response.data.msg}") + return False + self._refresh_after_network_action([device_id]) + return True + + def _ensure_snapshot(self: _HasInspectApi) -> "InspectSnapshot": + """Return the app snapshot, building it lazily when the read mixin is available.""" + get_snapshot = getattr(self, "_get_snapshot", None) + if callable(get_snapshot): + return get_snapshot() + if self._snapshot is not None: + return self._snapshot + raise InspectError( + "Inspect snapshot is not available; load the topology (e.g. access app.inspect.devices) " + "before creating virtual devices, or use InspectApp rather than a bare actions mixin." + ) + + def _refresh_after_network_action(self: _HasInspectApi, device_ids: list[str]) -> None: + """Update the internal snapshot for the affected devices after a successful network action. + + Only refreshes when a snapshot is already loaded, so a pure-action workflow never triggers + an unnecessary topology read (mirrors the write path).""" + if self._snapshot is not None: + self._snapshot.apply_network_refresh(device_ids) + + +def _to_add_item(spec: AddDeviceSpec) -> InspectApiAddDevicesItem: + if isinstance(spec, str): + return InspectApiAddDevicesItem(id=spec, x=0, y=0) + device_id, x, y = spec + return InspectApiAddDevicesItem(id=device_id, x=x, y=y) + + +__all__ = ["InspectActionsMixin", "ConflictStrategy", "AddDeviceSpec"] diff --git a/src/videoipath_automation_tool/apps/inspect/app/app.py b/src/videoipath_automation_tool/apps/inspect/app/app.py new file mode 100644 index 0000000..77e9688 --- /dev/null +++ b/src/videoipath_automation_tool/apps/inspect/app/app.py @@ -0,0 +1,89 @@ +"""InspectApp — the user-facing entry point for the VideoIPath Inspect surface. + +Read-only monitoring plus commit-style topology writes, built entirely on the collector API. +Composed from focused mixins, mirroring the Inventory/Topology app layout. + +This app is currently in beta; the API and behaviour may change in future releases. +""" + +from __future__ import annotations + +import logging +import warnings +from typing import TYPE_CHECKING, Optional + +from videoipath_automation_tool.apps.inspect.api import InspectAPI +from videoipath_automation_tool.connector.vip_connector import VideoIPathConnector + +if TYPE_CHECKING: + from videoipath_automation_tool.apps.inspect.snapshot import InspectSnapshot + +from .actions import InspectActionsMixin +from .read import InspectReadMixin, LoadMode +from .write import InspectWriteMixin + +_BETA_MESSAGE = ( + "InspectApp is in beta. The API and behaviour may change in future releases; use with care in production workflows." +) + + +class InspectApp(InspectReadMixin, InspectWriteMixin, InspectActionsMixin): + def __init__( + self, + vip_connector: VideoIPathConnector, + logger: Optional[logging.Logger] = None, + load: LoadMode = "skeleton", + ) -> None: + """Inspect App (beta): read the topology/status and apply commit-style topology changes. + + The app keeps a single internal topology view that is loaded lazily on the first read and + kept up to date across writes; interact with it entirely through this app (``app.inspect``), + the same way as the other apps. Call :meth:`refresh` to reload it from the server. + + .. note:: + InspectApp is in beta. Construction emits a :class:`UserWarning` and a log warning. + + Args: + vip_connector (VideoIPathConnector): connector handling the VideoIPath connection. + logger (Optional[logging.Logger]): logger instance. + load (LoadMode): how the internal view is loaded — ``"skeleton"`` (default; fast, with + lazy per-device detail) or ``"full"`` (eager, point-in-time). + """ + self._logger = logger or logging.getLogger("videoipath_automation_tool_inspect_app") + self._inspect_api = InspectAPI(vip_connector=vip_connector, logger=self._logger) + self._vip_connector = vip_connector + self._load_mode: LoadMode = load + self._snapshot: Optional[InspectSnapshot] = None + self._warn_beta() + self._warn_if_version_unverified() + self._logger.debug("Inspect APP initialized.") + + def _warn_beta(self) -> None: + warnings.warn(_BETA_MESSAGE, UserWarning, stacklevel=3) + self._logger.warning(_BETA_MESSAGE) + + def _warn_if_version_unverified(self) -> None: + version = self._vip_connector.videoipath_version + parsed = _parse_version(version) + if parsed is not None and parsed < _MIN_VERIFIED_VERSION: + self._logger.warning( + f"Inspect app: VideoIPath version '{version}' predates the first verified Inspect " + f"surface ({_MIN_VERIFIED_VERSION[0]}.{_MIN_VERIFIED_VERSION[1]}). Behaviour is unverified." + ) + + +# First VideoIPath version the Inspect collector surface was verified against. +_MIN_VERIFIED_VERSION = (2025, 4) + + +def _parse_version(version: str) -> Optional[tuple[int, int]]: + parts = version.split(".") + if len(parts) < 2: + return None + try: + return int(parts[0]), int(parts[1]) + except ValueError: + return None + + +__all__ = ["InspectApp"] diff --git a/src/videoipath_automation_tool/apps/inspect/app/read.py b/src/videoipath_automation_tool/apps/inspect/app/read.py new file mode 100644 index 0000000..8012d8d --- /dev/null +++ b/src/videoipath_automation_tool/apps/inspect/app/read.py @@ -0,0 +1,132 @@ +"""Read-side user methods for the Inspect app. + +The Inspect app owns a single internal :class:`InspectSnapshot`; users never handle it +directly. It is built lazily on the first read and reused (skeleton-first, then hydrated on demand). +Writes update it in place; :meth:`refresh` rebuilds it from the server. +""" + +from __future__ import annotations + +import logging +from concurrent.futures import ThreadPoolExecutor +from datetime import datetime +from typing import TYPE_CHECKING, Literal, Optional, Protocol + +from videoipath_automation_tool.apps.inspect.api import InspectAPI +from videoipath_automation_tool.apps.inspect.snapshot import InspectSnapshot + +if TYPE_CHECKING: + from videoipath_automation_tool.apps.inspect.domain.device import InspectDevice + from videoipath_automation_tool.apps.inspect.domain.edge import InspectEdge + from videoipath_automation_tool.apps.inspect.domain.service import InspectService + +LoadMode = Literal["skeleton", "full"] + + +class _HasInspectState(Protocol): + _inspect_api: InspectAPI + _logger: logging.Logger + _snapshot: Optional[InspectSnapshot] + _load_mode: LoadMode + + +class InspectReadMixin: + _inspect_api: InspectAPI + _logger: logging.Logger + _snapshot: Optional[InspectSnapshot] + _load_mode: LoadMode + + def refresh(self: _HasInspectState, load: Optional[LoadMode] = None) -> None: + """Reload the topology from the server, discarding the current internal view. + + Args: + load: ``"skeleton"`` (fast; lazy detail) or ``"full"`` (eager, point-in-time). Defaults + to the mode the app was last using. + """ + if load is not None: + self._load_mode = load + self._snapshot = self._load_snapshot(self._load_mode) + + # --- Devices --- + + @property + def devices(self: _HasInspectState) -> list["InspectDevice"]: + """All devices in the topology (skeleton-backed; no per-device detail I/O).""" + return self._get_snapshot().devices + + def get_device(self: _HasInspectState, device_id: str) -> Optional["InspectDevice"]: + """A device by id, or ``None`` if it is not in the topology.""" + return self._get_snapshot().get_device(device_id) + + # Backwards-compatible alias. + get_device_by_id = get_device + + def find_device_by_label(self: _HasInspectState, label: str) -> Optional["InspectDevice"]: + """The first device whose (effective) label matches exactly, or ``None``.""" + return self._get_snapshot().find_device_by_label(label) + + def find_devices_by_label(self: _HasInspectState, label: str) -> list["InspectDevice"]: + """All devices whose (effective) label matches exactly.""" + return self._get_snapshot().find_devices_by_label(label) + + def find_device_id_by_label(self: _HasInspectState, label: str) -> Optional[str]: + """Resolve a device id from its display label.""" + device = self._get_snapshot().find_device_by_label(label) + return device.id if device is not None else None + + def preload(self: _HasInspectState, devices: Optional[list[str]] = None) -> None: + """Hydrate device detail for many devices in parallel (avoids N+1 on bulk detail access).""" + self._get_snapshot().preload(devices) + + def is_device_hydrated(self: _HasInspectState, device_id: str) -> bool: + """Whether a device's full detail (modules/ports) has been loaded.""" + return self._get_snapshot().is_device_hydrated(device_id) + + def fetched_at(self: _HasInspectState, device_id: str) -> Optional[datetime]: + """When the given device's current data was fetched (freshness introspection).""" + return self._get_snapshot().fetched_at(device_id) + + # --- Edges --- + + @property + def edges(self: _HasInspectState) -> list["InspectEdge"]: + """All external edges (device-pair connectivity).""" + return self._get_snapshot().edges + + # --- Services --- + + @property + def services(self: _HasInspectState) -> list["InspectService"]: + """All services/paths (loads the services section on first access).""" + return self._get_snapshot().services + + def get_service_by_booking_id(self: _HasInspectState, booking_id: str) -> Optional["InspectService"]: + """A service by its booking id, or ``None``.""" + return self._get_snapshot().get_service_by_booking_id(booking_id) + + def get_services_for_device(self: _HasInspectState, device_id: str) -> list["InspectService"]: + """All services whose path traverses the given device.""" + return self._get_snapshot().get_services_for_device(device_id) + + # --- Internal snapshot lifecycle --- + + def _get_snapshot(self: _HasInspectState) -> InspectSnapshot: + """Return the internal snapshot, building it on first access.""" + if self._snapshot is None: + self._snapshot = self._load_snapshot(self._load_mode) + return self._snapshot + + def _load_snapshot(self: _HasInspectState, load: LoadMode) -> InspectSnapshot: + if load == "full": + self._logger.debug("Loading full (eager) Inspect snapshot.") + return InspectSnapshot.from_full_response(self._inspect_api.get_collector_full(), fetcher=self._inspect_api) + self._logger.debug("Loading skeleton Inspect snapshot (devices + edges in parallel).") + with ThreadPoolExecutor(max_workers=2) as pool: + devices_future = pool.submit(self._inspect_api.get_device_skeleton) + edges_future = pool.submit(self._inspect_api.get_edge_skeleton) + devices = devices_future.result() + edges = edges_future.result() + return InspectSnapshot(fetcher=self._inspect_api, device_items=devices, edge_items=edges) + + +__all__ = ["InspectReadMixin", "LoadMode"] diff --git a/src/videoipath_automation_tool/apps/inspect/app/write.py b/src/videoipath_automation_tool/apps/inspect/app/write.py new file mode 100644 index 0000000..e4272be --- /dev/null +++ b/src/videoipath_automation_tool/apps/inspect/app/write.py @@ -0,0 +1,323 @@ +"""User-facing topology writes: direct auto-commit sugar + the explicit transaction. + +Direct methods (``place_device``, ``update_device``, ``connect`` …) each open a single-change +transaction and commit it immediately. For batched, atomic changes use ``transaction()`` as a +context manager and call ``commit()`` explicitly. + +Domain objects also support a unit-of-work pattern: mutate attributes via setters (pending edits +stage on the snapshot), then call ``update(device)`` / ``update(vertex)`` / ``update(edge)`` to +auto-commit, or ``tx.update(...)`` to stage into an open transaction. + +Every write is bound to the app's internal snapshot: on a successful commit the touched entities are +refreshed in place — but only if the snapshot has already been loaded, so a pure-write +workflow never triggers an unnecessary topology read. +""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Any, Optional, Protocol, Sequence + +from videoipath_automation_tool.apps.inspect.api import InspectAPI +from videoipath_automation_tool.apps.inspect.model.common import ( + InspectCodecFormat, + InspectConfigPriority, + InspectControl, + InspectIconSize, + InspectIconType, + InspectRedundancyMode, + InspectSdpStrategy, + InspectSipsMode, +) +from videoipath_automation_tool.apps.inspect.transaction import ( + CommitResult, + Editable, + InspectTransaction, + _is_single_editable, + _stage_editable, +) + +if TYPE_CHECKING: + from videoipath_automation_tool.apps.inspect.snapshot import InspectSnapshot + + +class _HasInspectState(Protocol): + _inspect_api: InspectAPI + _logger: logging.Logger + _snapshot: Optional[InspectSnapshot] + + +class InspectWriteMixin: + _inspect_api: InspectAPI + _logger: logging.Logger + _snapshot: Optional[InspectSnapshot] + + def transaction(self: _HasInspectState) -> InspectTransaction: + """Open a batched, atomic transaction bound to the app's internal snapshot.""" + return InspectTransaction(self._inspect_api, snapshot=self._snapshot, logger=self._logger) + + def update(self: _HasInspectState, obj: Editable | Sequence[Editable]) -> CommitResult: + """Flush pending domain-object edits through a new auto-committed transaction. + + Accepts an :class:`InspectDevice`, :class:`InspectVertex`, :class:`InspectEdge`, + :class:`InspectModule`, or a sequence of them. For a device, also cascades every dirty + vertex/edge/module whose id belongs to that device (unit of work). + + For batched changes, mutate domain objects then call ``tx.update(obj)`` on an open + :meth:`transaction` and ``commit()`` yourself. + """ + objects = list(obj) if isinstance(obj, Sequence) and not _is_single_editable(obj) else [obj] # type: ignore[list-item] + if not objects: + raise ValueError("Nothing to update.") + if self._snapshot is None: + raise RuntimeError( + "Inspect snapshot is not available; load the topology (e.g. access app.inspect.devices) " + "before updating domain objects." + ) + + txn = self.transaction() + flushed_keys: list[tuple[str, str]] = [] + for item in objects: + flushed_keys.extend(_stage_editable(txn, self._snapshot, item)) + + if not flushed_keys and len(txn) == 0: + raise ValueError("No pending edits to flush.") + result = txn.commit() + for kind, entity_id in flushed_keys: + self._snapshot.clear_staged(kind=kind, entity_id=entity_id) + return result + + def place_device(self: _HasInspectState, device_id: str, x: float, y: float) -> CommitResult: + """Move a device to grid coordinates (single auto-committed change).""" + with self.transaction() as tx: + tx.place_device(device_id, x, y) + return tx.commit() + + def update_device( + self: _HasInspectState, + device_id: str, + *, + label: Optional[str] = None, + description: Optional[str] = None, + icon_type: Optional[InspectIconType | str] = None, + icon_size: Optional[InspectIconSize | str] = None, + sdp_strategy: Optional[InspectSdpStrategy | str] = None, + site_id: Optional[str] = None, + tags: Optional[list[str]] = None, + local_assigned_tags: Optional[list[str]] = None, + coordinates: Optional[dict[str, float]] = None, + ) -> CommitResult: + """Edit a device's "Edit Device" dialog fields (single auto-committed change).""" + with self.transaction() as tx: + tx.update_device( + device_id, + label=label, + description=description, + icon_type=icon_type, + icon_size=icon_size, + sdp_strategy=sdp_strategy, + site_id=site_id, + tags=tags, + local_assigned_tags=local_assigned_tags, + coordinates=coordinates, + ) + return tx.commit() + + def update_module( + self: _HasInspectState, + module_id: str, + *, + tags: Optional[list[str]] = None, + ) -> CommitResult: + """Edit a module's locally assigned tags (single auto-committed change). + + Diffs the desired list against the current local tags and calls ``assignTag`` / + ``unassignTag``. Requires a loaded inspect snapshot (module detail is hydrated on demand). + """ + if self._snapshot is None: + raise RuntimeError( + "Inspect snapshot is not available; load the topology (e.g. access app.inspect.devices) " + "before updating modules." + ) + with self.transaction() as tx: + tx.update_module(module_id, tags=tags) + return tx.commit() + + def update_vertex( + self: _HasInspectState, + vertex_id: str, + *, + use_as_endpoint: Optional[bool] = None, + label: Optional[str] = None, + tags: Optional[list[str]] = None, + form_tags: Optional[list[str]] = None, + description: Optional[str] = None, + active: Optional[bool] = None, + sips_mode: Optional[InspectSipsMode | str] = None, + control: Optional[InspectControl | str] = None, + control_props: Optional[Any] = None, + extra_alert_filters: Optional[list[Any]] = None, + custom: Optional[dict[str, Any]] = None, + queueable: Optional[bool] = None, + destination_monitor_leader: Optional[bool] = None, + park_port: Optional[int] = None, + ip_address: Optional[str] = None, + ip_netmask: Optional[str] = None, + public: Optional[bool] = None, + vlan_id: Optional[str] = None, + vrf_id: Optional[str] = None, + supports_cpipe: Optional[bool] = None, + supports_igmp: Optional[bool] = None, + supports_mac_forwarding: Optional[bool] = None, + supports_nso: Optional[bool] = None, + supports_openflow: Optional[bool] = None, + supports_static_igmp: Optional[bool] = None, + supports_vlan: Optional[bool] = None, + supports_vpls: Optional[bool] = None, + sdp_support: Optional[bool] = None, + is_igmp_source: Optional[bool] = None, + specific_type: Optional[str] = None, + codec_format: Optional[InspectCodecFormat | str] = None, + multiplicity: Optional[int] = None, + codec_public: Optional[bool] = None, + extra_formats: Optional[list[Any]] = None, + bidir_partner_id: Optional[str] = None, + partner_config: Optional[Any] = None, + service_id: Optional[Any] = None, + main_src_info: Optional[dict[str, Any]] = None, + main_dst_info: Optional[dict[str, Any]] = None, + spare_src_info: Optional[dict[str, Any]] = None, + spare_dst_info: Optional[dict[str, Any]] = None, + main_destination_port: Optional[int] = None, + spare_destination_port: Optional[int] = None, + ) -> CommitResult: + """Edit a vertex (single auto-committed change; update-only).""" + with self.transaction() as tx: + tx.update_vertex( + vertex_id, + use_as_endpoint=use_as_endpoint, + label=label, + tags=tags, + form_tags=form_tags, + description=description, + active=active, + sips_mode=sips_mode, + control=control, + control_props=control_props, + extra_alert_filters=extra_alert_filters, + custom=custom, + queueable=queueable, + destination_monitor_leader=destination_monitor_leader, + park_port=park_port, + ip_address=ip_address, + ip_netmask=ip_netmask, + public=public, + vlan_id=vlan_id, + vrf_id=vrf_id, + supports_cpipe=supports_cpipe, + supports_igmp=supports_igmp, + supports_mac_forwarding=supports_mac_forwarding, + supports_nso=supports_nso, + supports_openflow=supports_openflow, + supports_static_igmp=supports_static_igmp, + supports_vlan=supports_vlan, + supports_vpls=supports_vpls, + sdp_support=sdp_support, + is_igmp_source=is_igmp_source, + specific_type=specific_type, + codec_format=codec_format, + multiplicity=multiplicity, + codec_public=codec_public, + extra_formats=extra_formats, + bidir_partner_id=bidir_partner_id, + partner_config=partner_config, + service_id=service_id, + main_src_info=main_src_info, + main_dst_info=main_dst_info, + spare_src_info=spare_src_info, + spare_dst_info=spare_dst_info, + main_destination_port=main_destination_port, + spare_destination_port=spare_destination_port, + ) + return tx.commit() + + def update_edge( + self: _HasInspectState, + edge_id: str, + *, + label: Optional[str] = None, + description: Optional[str] = None, + weight: Optional[int] = None, + capacity: Optional[int] = None, + bandwidth: Optional[float] = None, + redundancy_mode: Optional[InspectRedundancyMode | str] = None, + conflict_priority: Optional[InspectConfigPriority | int | str] = None, + include_formats: Optional[list[str]] = None, + exclude_formats: Optional[list[str]] = None, + bandwidth_weight_factor: Optional[int] = None, + weight_per_service: Optional[int] = None, + active: Optional[bool] = None, + tags: Optional[list[str]] = None, + also_opposite: bool = False, + ) -> CommitResult: + """Edit an existing edge's "Edit Edge" dialog fields (single auto-committed change). + + With ``also_opposite`` the same changes are applied to the opposite directed edge too. + """ + with self.transaction() as tx: + tx.update_edge( + edge_id, + label=label, + description=description, + weight=weight, + capacity=capacity, + bandwidth=bandwidth, + redundancy_mode=redundancy_mode, + conflict_priority=conflict_priority, + include_formats=include_formats, + exclude_formats=exclude_formats, + bandwidth_weight_factor=bandwidth_weight_factor, + weight_per_service=weight_per_service, + active=active, + tags=tags, + also_opposite=also_opposite, + ) + return tx.commit() + + def connect( + self: _HasInspectState, + from_vertex: str, + to_vertex: str, + *, + bidirectional: bool = True, + overwrite: bool = False, + **edge_fields: Any, + ) -> CommitResult: + """Create an edge (and its reverse if bidirectional) between two vertices.""" + with self.transaction() as tx: + tx.connect(from_vertex, to_vertex, bidirectional=bidirectional, overwrite=overwrite, **edge_fields) + return tx.commit() + + def disconnect( + self: _HasInspectState, + from_vertex: str, + to_vertex: str, + *, + bidirectional: bool = True, + ) -> CommitResult: + """Remove the edge (and its reverse if bidirectional) between two vertices.""" + with self.transaction() as tx: + tx.disconnect(from_vertex, to_vertex, bidirectional=bidirectional) + return tx.commit() + + def remove_device_from_topology(self: _HasInspectState, device_id: str) -> CommitResult: + """Remove a device (its baseDevice element) from the topology graph. + + Works for both physical and virtual (``virtual.N``) devices via ``updateTopology``. + """ + with self.transaction() as tx: + tx.remove_device(device_id) + return tx.commit() + + +__all__ = ["InspectWriteMixin"] diff --git a/src/videoipath_automation_tool/apps/inspect/domain/__init__.py b/src/videoipath_automation_tool/apps/inspect/domain/__init__.py new file mode 100644 index 0000000..0087641 --- /dev/null +++ b/src/videoipath_automation_tool/apps/inspect/domain/__init__.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +from videoipath_automation_tool.apps.inspect.domain.alarm import InspectAlarm +from videoipath_automation_tool.apps.inspect.domain.device import InspectDevice, VirtualDeviceSpec +from videoipath_automation_tool.apps.inspect.domain.edge import InspectEdge +from videoipath_automation_tool.apps.inspect.domain.module import InspectModule, VirtualModuleSpec +from videoipath_automation_tool.apps.inspect.domain.port import InspectPort, InspectPortTemplate, PortFromTemplate +from videoipath_automation_tool.apps.inspect.domain.service import InspectService +from videoipath_automation_tool.apps.inspect.domain.vertex import ( + InspectCodecVertex, + InspectGenericVertex, + InspectIpVertex, + InspectResourceTransformVertex, + InspectVertex, +) + +__all__ = [ + "InspectAlarm", + "InspectCodecVertex", + "InspectDevice", + "InspectEdge", + "InspectGenericVertex", + "InspectIpVertex", + "InspectModule", + "InspectPort", + "InspectPortTemplate", + "InspectResourceTransformVertex", + "InspectService", + "InspectVertex", + "PortFromTemplate", + "VirtualDeviceSpec", + "VirtualModuleSpec", +] diff --git a/src/videoipath_automation_tool/apps/inspect/domain/alarm.py b/src/videoipath_automation_tool/apps/inspect/domain/alarm.py new file mode 100644 index 0000000..8214225 --- /dev/null +++ b/src/videoipath_automation_tool/apps/inspect/domain/alarm.py @@ -0,0 +1,82 @@ +from __future__ import annotations + +from videoipath_automation_tool.apps.inspect.model.alarms import InspectApiAlarmItem +from videoipath_automation_tool.apps.inspect.model.common import InspectFrozenModel, InspectSeverity, format_repr + +_ALARM_MESSAGE_REPR_LIMIT = 60 + + +class InspectAlarm(InspectFrozenModel): + """One active alarm from ``status/alarms/current``, correlated onto a topology resource.""" + + item: InspectApiAlarmItem + + @property + def id(self) -> str | None: + return self.item.id_field + + @property + def message(self) -> str | None: + info = self.item.info + return info.details if info is not None else None + + @property + def severity(self) -> InspectSeverity | int | str | None: + info = self.item.info + return info.severity if info is not None else None + + @property + def sa(self) -> InspectSeverity | int | str | None: + """Service-affecting severity of this alarm (``info.sa``).""" + info = self.item.info + return info.sa if info is not None else None + + @property + def service_affecting(self) -> InspectSeverity | int | str | None: + return self.sa + + @property + def acknowledged(self) -> bool | None: + return self.item.acked + + @property + def hidden(self) -> bool | None: + return self.item.hidden + + @property + def time(self) -> int | None: + info = self.item.info + return info.time if info is not None else None + + @property + def alert_id(self) -> str | None: + alarm_id = self.item.id + return alarm_id.alertId if alarm_id is not None else None + + @property + def component(self) -> int | None: + alarm_id = self.item.id + return alarm_id.component if alarm_id is not None else None + + @property + def point_id(self) -> list[str]: + alarm_id = self.item.id + return list(alarm_id.pointId) if alarm_id is not None else [] + + @property + def point_labels(self) -> list[str]: + desc = self.item.desc + if desc is None: + return [] + return [entry.label for entry in desc.pointId if entry.label] + + def __repr__(self) -> str: + message = self.message + if message is not None and len(message) > _ALARM_MESSAGE_REPR_LIMIT: + message = message[:_ALARM_MESSAGE_REPR_LIMIT] + "…" + return format_repr(self, id=self.id, severity=self.severity, message=message) + + __str__ = __repr__ + + +__all__ = ["InspectAlarm"] diff --git a/src/videoipath_automation_tool/apps/inspect/domain/device.py b/src/videoipath_automation_tool/apps/inspect/domain/device.py new file mode 100644 index 0000000..eb48c30 --- /dev/null +++ b/src/videoipath_automation_tool/apps/inspect/domain/device.py @@ -0,0 +1,430 @@ +from __future__ import annotations + +from datetime import datetime +from typing import TYPE_CHECKING, Any, Literal, Self + +from pydantic import Field + +from videoipath_automation_tool.apps.inspect.domain.module import VirtualModuleSpec +from videoipath_automation_tool.apps.inspect.domain.port import PortFromTemplate +from videoipath_automation_tool.apps.inspect.model.common import ( + InspectApiStatusSummary, + InspectEditableModel, + InspectIconSize, + InspectIconType, + InspectInternalModel, + InspectSdpStrategy, + InspectSeverity, + InspectVertexKind, + InspectVertexType, + format_repr, +) +from videoipath_automation_tool.apps.inspect.model.virtual import InspectApiVirtualDeviceWriteBody +from videoipath_automation_tool.apps.inspect.snapshot import InspectSnapshot +from videoipath_automation_tool.validators.virtual_device_id import is_virtual_device_id + +if TYPE_CHECKING: + from videoipath_automation_tool.apps.inspect.domain.alarm import InspectAlarm + from videoipath_automation_tool.apps.inspect.domain.edge import InspectEdge + from videoipath_automation_tool.apps.inspect.domain.module import InspectModule + from videoipath_automation_tool.apps.inspect.domain.port import InspectPort + from videoipath_automation_tool.apps.inspect.domain.service import InspectService + from videoipath_automation_tool.apps.inspect.domain.vertex import InspectVertex + from videoipath_automation_tool.apps.inspect.snapshot import _DeviceRecord + + +class InspectDevice(InspectEditableModel): + """A topology device/node. Skeleton fields (id, label, coordinates, status, sync, tags) are + available immediately; ``ports`` and ``services`` lazily hydrate from the server on first + access. The record is resolved live from the snapshot, so a held reference sees + hydrated/refreshed data transparently. + + Editable attributes use property setters that stage pending intents on the snapshot + (read-your-writes). Flush with ``app.inspect.update(device)`` or ``tx.update(device)`` + inside a transaction. + """ + + snapshot: InspectSnapshot + id: str + + @property + def _edit_kind(self) -> Literal["device"]: + return "device" + + @property + def label(self) -> str | None: + return self._staged_or("descriptor.label", lambda: self._record().label) + + @label.setter + def label(self, value: str) -> None: + self._stage("descriptor.label", value) + + @property + def description(self) -> str | None: + return self._staged_or( + "descriptor.desc", + lambda: self._record().node.effective_description, + ) + + @description.setter + def description(self, value: str) -> None: + self._stage("descriptor.desc", value) + + @property + def factory_label(self) -> str | None: + """Device-reported factory label (``fDescriptor.label`` / collector ``label``).""" + node = self._record().node + return node.label + + @property + def pid(self) -> str | None: + return self._record().pid + + @property + def is_virtual(self) -> bool | None: + """Whether this is a topology virtual device (``virtual.N``). + + ``virtual.N`` ids are always virtual; otherwise the collector ``meta.isVirtual`` flag is + used when present. + """ + if is_virtual_device_id(self.id): + return True + meta = self._record().node.meta + return meta.isVirtual if meta is not None else None + + @property + def icon_type(self) -> InspectIconType | str | None: + return self._staged_or("iconType", lambda: self._meta_get("iconType")) + + @icon_type.setter + def icon_type(self, value: InspectIconType | str) -> None: + self._stage("iconType", value) + + @property + def icon_size(self) -> InspectIconSize | str | None: + """Device icon size ("Device icon size" in the UI): ``"auto"`` / ``"large"`` / ``"medium"`` / + ``"small"``.""" + return self._staged_or("iconSize", lambda: self._meta_get("iconSize")) + + @icon_size.setter + def icon_size(self, value: InspectIconSize | str) -> None: + self._stage("iconSize", value) + + @property + def sdp_strategy(self) -> InspectSdpStrategy | str | None: + """SDP polling strategy ("SDP polling strategy" in the UI): ``"always"`` (Continuous) / + ``"once"`` (Fetch and Confirm) / ``"video"`` (Continuous Video, Confirm Others).""" + return self._staged_or("sdpStrategy", lambda: self._meta_get("sdpStrategy")) + + @sdp_strategy.setter + def sdp_strategy(self, value: InspectSdpStrategy | str) -> None: + self._stage("sdpStrategy", value) + + @property + def site_id(self) -> str | None: + """The id of the site this device is located at ("Site ID" in the UI).""" + return self._staged_or("siteId", lambda: self._meta_get("siteId")) + + @site_id.setter + def site_id(self, value: str) -> None: + self._stage("siteId", value) + + @property + def status(self) -> InspectApiStatusSummary | None: + return self._record().node.status + + @property + def sync_severity(self) -> InspectSeverity | int | str | None: + return self._record().node.syncSeverity + + @property + def alarms(self) -> list[InspectAlarm]: + """Active alarms correlated to this device (worst severity first).""" + return self.snapshot.get_alarms_for_device(self.id) + + @property + def status_message(self) -> str | None: + """Message of the worst active alarm on this device, if any.""" + alarms = self.alarms + return alarms[0].message if alarms else None + + @property + def tags(self) -> list[str]: + return self._staged_or("tags", lambda: list(self._record().node.tags), adapt=list) + + @tags.setter + def tags(self, value: list[str] | tuple[str, ...]) -> None: + self._stage("tags", list(value)) + + @property + def local_assigned_tags(self) -> list[str]: + """Device ``localAssignedTags`` (distinct from collector ``tags`` when both are present).""" + return self._staged_or("localAssignedTags", lambda: [], adapt=list) + + @local_assigned_tags.setter + def local_assigned_tags(self, value: list[str]) -> None: + self._stage("localAssignedTags", list(value)) + + @property + def coordinates(self) -> dict[str, float | int | str | None] | None: + return self._staged_or( + "coordinates", + lambda: self._record().node.coordinates, + adapt=lambda value: dict(value) if value is not None else None, + ) + + @coordinates.setter + def coordinates(self, value: dict[str, float | int] | None) -> None: + self._stage("coordinates", dict(value) if value is not None else None) + + @property + def is_hydrated(self) -> bool: + return self.snapshot.is_device_hydrated(self.id) + + @property + def fetched_at(self) -> datetime | None: + return self.snapshot.fetched_at(self.id) + + @property + def modules(self) -> list[InspectModule]: + """The device's modules / slots, each owning many ports/vertices.""" + return self.snapshot.get_modules_for_device(self.id) + + def get_module(self, module_id: str) -> InspectModule | None: + return self.snapshot.get_module(self.id, module_id) + + @property + def ports(self) -> list[InspectPort]: + """All port rows across the device's modules (flattened). Use :attr:`modules` for the + module → port grouping.""" + return self.snapshot.get_ports_for_device(self.id) + + @property + def codec_vertices(self) -> list[InspectVertex]: + """All codec vertices across this device's ports (hydrates + batches vertex lookups).""" + return self._vertices_of_kind("codec") + + @property + def ip_vertices(self) -> list[InspectVertex]: + """All IP vertices across this device's ports (hydrates + batches vertex lookups).""" + return self._vertices_of_kind("ip") + + @property + def generic_vertices(self) -> list[InspectVertex]: + """All generic vertices across this device's ports (hydrates + batches vertex lookups).""" + return self._vertices_of_kind("generic") + + def find_vertex_by_factory_label( + self, + label: str, + *, + kind: InspectVertexKind | str | None = None, + vertex_type: InspectVertexType | str | None = None, + ) -> InspectVertex | None: + """First vertex whose owning port's factory label equals ``label`` (optionally filtered).""" + for vertex in self._all_vertices(): + if vertex.factory_label != label: + continue + if kind is not None and vertex.vertex_kind != kind: + continue + if vertex_type is not None and vertex.vertex_type != vertex_type: + continue + return vertex + return None + + def get_vertices_by_module_label( + self, + module_label: str, + *, + kind: InspectVertexKind | str | None = None, + ) -> list[InspectVertex]: + """Vertices belonging to modules whose label equals ``module_label`` (e.g. ``Slot 3``).""" + module_ids = {m.id for m in self.modules if m.label == module_label} + if not module_ids: + return [] + ports = [port for port in self.ports if port.indexed.module_id in module_ids] + sides = [side for port in ports for side in port._vertex_sides()] + self.snapshot.get_vertex_details_many([vid for vid, _ in sides]) + result: list[InspectVertex] = [] + for port in ports: + for vertex in port._vertices(): + if kind is not None and vertex.vertex_kind != kind: + continue + result.append(vertex) + return result + + def get_vertex_by_id(self, vertex_id: str) -> InspectVertex | None: + """A vertex of this device by id, or ``None`` if it is not on any port.""" + for vertex in self._all_vertices(): + if vertex.id == vertex_id: + return vertex + if vertex_id.startswith(self.id + "."): + return self.snapshot.get_vertex(vertex_id) + return None + + def filter_ports( + self, + *, + module_id: str | None = None, + vertex_type: InspectVertexType | str | None = None, + kind: InspectVertexKind | str | None = None, + active: bool | None = None, + controlled: bool | None = None, + endpoint: bool | None = None, + ) -> list[InspectPort]: + """Filter this device's ports by their vertices' attributes. + + ``vertex_type`` is the port-level direction (``"BiDirectional"`` for a two-vertex port, else + the single vertex's direction); ``active`` / ``controlled`` / ``endpoint`` aggregate the + port's vertices (True if any vertex is True, False only if all are known False). These are + evaluated offline from the already-hydrated ``vertexInfo``. ``kind`` (``"generic"`` / ``"ip"`` + / ``"codec"`` / ``"router"``) requires the vertex edit form: uncached vertices are resolved + with a single batched ``lookupInspectVertexByIds`` call. A port whose value for an explicit + filter is unknown never matches. + """ + result: list[InspectPort] = [] + for port in self.ports: + if module_id is not None and port.indexed.module_id != module_id: + continue + vertices = port._offline_vertices() + if vertex_type is not None and _port_direction(vertices) != vertex_type: + continue + if active is not None and _aggregate_flag(vertices, "is_active") is not active: + continue + if controlled is not None and _aggregate_flag(vertices, "is_controlled") is not controlled: + continue + if endpoint is not None and _aggregate_flag(vertices, "is_endpoint") is not endpoint: + continue + result.append(port) + + if kind is not None: + first_sides = [sides[0] for p in result if (sides := p._vertex_sides())] + self.snapshot.get_vertex_details_many([vid for vid, _ in first_sides]) + result = [ + p + for p in result + if (sides := p._vertex_sides()) + and (v := self.snapshot.get_vertex(sides[0][0], vertex_info=sides[0][1])) is not None + and v.vertex_kind == kind + ] + return result + + @property + def edges(self) -> list[InspectEdge]: + return self.snapshot.get_edges_for_device(self.id) + + @property + def services(self) -> list[InspectService]: + return self.snapshot.get_services_for_device(self.id) + + @property + def linked_devices(self) -> list[InspectDevice]: + return self.snapshot.get_linked_devices(self.id) + + def __repr__(self) -> str: + return format_repr( + self, + id=self.id, + label=lambda: self._record().label, + virtual=True if is_virtual_device_id(self.id) else None, + ) + + __str__ = __repr__ + + def _record(self) -> "_DeviceRecord": + record = self.snapshot.get_device_record(self.id) + if record is None: + raise KeyError(f"Device '{self.id}' is no longer present in the snapshot.") + return record + + def _meta_get(self, attr: str, default: Any = None) -> Any: + meta = self._record().node.meta + return getattr(meta, attr, default) if meta is not None else default + + def _all_vertices(self) -> list[InspectVertex]: + ports = self.ports + sides = [side for port in ports for side in port._vertex_sides()] + self.snapshot.get_vertex_details_many([vid for vid, _ in sides]) + result: list[InspectVertex] = [] + for port in ports: + result.extend(port._vertices()) + return result + + def _vertices_of_kind(self, kind: str) -> list[InspectVertex]: + return [v for v in self._all_vertices() if v.vertex_kind == kind] + + +class VirtualDeviceSpec(InspectInternalModel): + """Declarative definition of a virtual device, matching the Create Virtual Devices dialog. + + Mutable so fluent helpers (``add_module`` / ``add_port``) can mirror the UI workflow. + """ + + modules: list[VirtualModuleSpec] = Field(default_factory=lambda: [VirtualModuleSpec()]) + + @classmethod + def empty(cls) -> VirtualDeviceSpec: + """One empty module, as in the UI default.""" + return cls(modules=[VirtualModuleSpec()]) + + def add_module(self) -> Self: + """Append an empty module (UI: + Add module).""" + self.modules.append(VirtualModuleSpec()) + return self + + def add_port(self, template_id: str, count: int = 1, *, module_index: int = -1) -> Self: + """Add a port from a template to a module (UI: Add port).""" + if not self.modules: + self.modules.append(VirtualModuleSpec()) + idx = module_index if module_index >= 0 else len(self.modules) + module_index + if idx < 0 or idx >= len(self.modules): + raise IndexError(f"module_index {module_index} is out of range for {len(self.modules)} module(s).") + self.modules[idx].ports.append(PortFromTemplate(template_id=template_id, count=count)) + return self + + def to_wire(self) -> InspectApiVirtualDeviceWriteBody: + return InspectApiVirtualDeviceWriteBody(modules=[module.to_wire() for module in self.modules]) + + def __repr__(self) -> str: + return format_repr(self, modules=len(self.modules)) + + __str__ = __repr__ + + @classmethod + def from_ports(cls, *ports: PortFromTemplate | tuple[str, int] | str) -> VirtualDeviceSpec: + """Build a single-module device from port specs.""" + resolved: list[PortFromTemplate] = [] + for port in ports: + if isinstance(port, PortFromTemplate): + resolved.append(port) + elif isinstance(port, str): + resolved.append(PortFromTemplate(template_id=port)) + else: + template_id, count = port + resolved.append(PortFromTemplate(template_id=template_id, count=count)) + return cls(modules=[VirtualModuleSpec(ports=resolved)]) + + +# --- Internal --- + + +def _port_direction(vertices: list[InspectVertex]) -> str | None: + """Port-level direction: ``"BiDirectional"`` for a two-vertex port, else the single vertex's + direction (None for a port without vertices).""" + if len(vertices) > 1: + return "BiDirectional" + return vertices[0].vertex_type if vertices else None + + +def _aggregate_flag(vertices: list[InspectVertex], attr: str) -> bool | None: + """Aggregate a vertex boolean flag across a port: True if any vertex is True, False only if all + are known False, else None (unknown).""" + values = [getattr(vertex, attr) for vertex in vertices] + if any(value is True for value in values): + return True + if values and all(value is False for value in values): + return False + return None + + +__all__ = ["InspectDevice", "VirtualDeviceSpec"] diff --git a/src/videoipath_automation_tool/apps/inspect/domain/edge.py b/src/videoipath_automation_tool/apps/inspect/domain/edge.py new file mode 100644 index 0000000..6272f5c --- /dev/null +++ b/src/videoipath_automation_tool/apps/inspect/domain/edge.py @@ -0,0 +1,290 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Literal + +from videoipath_automation_tool.apps.inspect.model.collector import InspectApiExternalEdgeLiveStatus +from videoipath_automation_tool.apps.inspect.model.common import ( + CONFLICT_PRIORITY_BY_INT, + CONFLICT_PRIORITY_TO_INT, + InspectConfigPriority, + InspectEditableModel, + InspectRedundancyMode, + format_repr, +) +from videoipath_automation_tool.apps.inspect.snapshot import InspectSnapshot, _IndexedEdge + +if TYPE_CHECKING: + from videoipath_automation_tool.apps.inspect.domain.alarm import InspectAlarm + from videoipath_automation_tool.apps.inspect.domain.device import InspectDevice + from videoipath_automation_tool.apps.inspect.domain.port import InspectPort + from videoipath_automation_tool.apps.inspect.domain.service import InspectService + from videoipath_automation_tool.apps.inspect.model.actions import InspectApiEdgeForm + + +class InspectEdge(InspectEditableModel): + """A directed external edge. Live status fields come from the collector skeleton; Edit Edge + dialog fields resolve from the lazily-fetched edit form, with pending setter edits taking + precedence (read-your-writes). Flush with ``app.inspect.update(edge)`` or ``tx.update(edge)`` + inside a transaction.""" + + snapshot: InspectSnapshot + indexed: _IndexedEdge + + @property + def _edit_kind(self) -> Literal["edge"]: + return "edge" + + @property + def id(self) -> str: + return self.indexed.edge_id + + @property + def pair_id(self) -> str: + return self.indexed.pair_id + + @property + def from_device(self) -> InspectDevice | None: + if self.indexed.from_device_id is None: + return None + return self.snapshot.get_device_by_id(self.indexed.from_device_id) + + @property + def from_port(self) -> InspectPort | None: + if self.indexed.from_device_id is None or self.indexed.from_port_id is None: + return None + return self.snapshot.get_port(self.indexed.from_device_id, self.indexed.from_port_id) + + @property + def to_device(self) -> InspectDevice | None: + if self.indexed.to_device_id is None: + return None + return self.snapshot.get_device_by_id(self.indexed.to_device_id) + + @property + def to_port(self) -> InspectPort | None: + if self.indexed.to_device_id is None or self.indexed.to_port_id is None: + return None + return self.snapshot.get_port(self.indexed.to_device_id, self.indexed.to_port_id) + + @property + def bandwidth(self) -> float | int | None: + """Live bandwidth status. For the configured capacity use :attr:`bandwidth_capacity`.""" + return self.indexed.edge.bandwidth + + @property + def max_bandwidth(self) -> float | int | None: + return self.indexed.edge.maxBandwidth + + @property + def status(self) -> InspectApiExternalEdgeLiveStatus | None: + """Live status for this edge. In the lean skeleton only the pair-level status is present; + the per-edge status (per direction) appears in the full edge shape.""" + return self.indexed.edge.status or self.indexed.pair_status + + @property + def alarms(self) -> list[InspectAlarm]: + """Active alarms correlated to this edge or its pair (worst severity first).""" + return self.snapshot.get_alarms_for_edge(self.id, pair_id=self.pair_id) + + @property + def services(self) -> list[InspectService]: + services: list[InspectService] = [] + seen_booking_ids: set[str] = set() + for device in (self.from_device, self.to_device): + if device is None: + continue + for service in self.snapshot.get_services_for_device(device.id): + if service.booking_id in seen_booking_ids: + continue + seen_booking_ids.add(service.booking_id) + services.append(service) + return services + + # --- Config (the "Edit Edge" dialog fields; lazily fetched via lookupInspectEdgesByIds) --- + + @property + def label(self) -> str | None: + """Manual edge label ("Label" in the Edit Edge dialog).""" + return self._staged_or( + "descriptor.label", + lambda: f.descriptor.label if (f := self._edit_form()) else None, + ) + + @label.setter + def label(self, value: str) -> None: + self._stage("descriptor.label", value) + + @property + def description(self) -> str | None: + """Edge description ("Description" in the Edit Edge dialog).""" + return self._staged_or( + "descriptor.desc", + lambda: f.descriptor.desc if (f := self._edit_form()) else None, + ) + + @description.setter + def description(self, value: str) -> None: + self._stage("descriptor.desc", value) + + @property + def tags(self) -> list[str]: + return self._staged_or("tags", lambda: list(self._form_get("tags") or []), adapt=list) + + @tags.setter + def tags(self, value: list[str]) -> None: + self._stage("tags", list(value)) + + @property + def active(self) -> bool | None: + return self._staged_or("active", lambda: self._form_get("active")) + + @active.setter + def active(self, value: bool) -> None: + self._stage("active", value) + + @property + def include_formats(self) -> list[str]: + return self._staged_or( + "includeFormats", + lambda: list(self._form_get("includeFormats") or []), + adapt=list, + ) + + @include_formats.setter + def include_formats(self, value: list[str]) -> None: + self._stage("includeFormats", list(value)) + + @property + def exclude_formats(self) -> list[str]: + return self._staged_or( + "excludeFormats", + lambda: list(self._form_get("excludeFormats") or []), + adapt=list, + ) + + @exclude_formats.setter + def exclude_formats(self, value: list[str]) -> None: + self._stage("excludeFormats", list(value)) + + @property + def conflict_priority(self) -> InspectConfigPriority | int | str | None: + """Conflict priority ("Conflict priority" in the UI): ``"off"`` / ``"high"`` / ``"normal"`` / + ``"low"`` (mapped from the on-wire int), or the raw value if unrecognized.""" + return self._staged_or( + "conflictPri", + lambda: self._map_conflict_priority(self._form_get("conflictPri")), + adapt=self._map_conflict_priority, + ) + + @conflict_priority.setter + def conflict_priority(self, value: InspectConfigPriority | int | str) -> None: + wire = CONFLICT_PRIORITY_TO_INT.get(value, value) if isinstance(value, str) else value + self._stage("conflictPri", wire) + + @property + def redundancy_mode(self) -> InspectRedundancyMode | str | None: + return self._staged_or("redundancyMode", lambda: self._form_get("redundancyMode")) + + @redundancy_mode.setter + def redundancy_mode(self, value: InspectRedundancyMode | str) -> None: + self._stage("redundancyMode", value) + + @property + def fixed_weight(self) -> int | None: + """Fixed routing weight/cost ("Fixed weight" in the UI).""" + return self._staged_or("weight", lambda: self._form_get("weight")) + + @fixed_weight.setter + def fixed_weight(self, value: int) -> None: + self._stage("weight", value) + + @property + def weight(self) -> int | None: + return self.fixed_weight + + @weight.setter + def weight(self, value: int) -> None: + self.fixed_weight = value + + @property + def bandwidth_capacity(self) -> float | int | None: + """Configured max bandwidth in Mbit/s ("Bandwidth capacity" in the UI); ``-1.0`` = disabled. + Distinct from the live status bandwidth when no edit is staged.""" + return self._staged_or("bandwidth", lambda: self._form_get("bandwidth")) + + @bandwidth_capacity.setter + def bandwidth_capacity(self, value: float | int) -> None: + self._stage("bandwidth", value) + + @property + def services_capacity(self) -> int | None: + """Max number of simultaneous services ("Services capacity" in the UI); ``65535`` = unlimited.""" + return self._staged_or("capacity", lambda: self._form_get("capacity")) + + @services_capacity.setter + def services_capacity(self, value: int) -> None: + self._stage("capacity", value) + + @property + def capacity(self) -> int | None: + return self.services_capacity + + @capacity.setter + def capacity(self, value: int) -> None: + self.services_capacity = value + + @property + def bandwidth_weight_factor(self) -> int | None: + """Bandwidth-based weight factor ("Bandwidth weight factor" in the UI).""" + return self._staged_or( + "weightFactors.bandwidth.weight", + lambda: self._weight_factor("bandwidth"), + ) + + @bandwidth_weight_factor.setter + def bandwidth_weight_factor(self, value: int) -> None: + self._stage("weightFactors.bandwidth.weight", value) + + @property + def weight_per_service(self) -> int | None: + """Service-based weight factor ("Weight per service" in the UI).""" + return self._staged_or( + "weightFactors.service.weight", + lambda: self._weight_factor("service"), + ) + + @weight_per_service.setter + def weight_per_service(self, value: int) -> None: + self._stage("weightFactors.service.weight", value) + + def __repr__(self) -> str: + return format_repr( + self, + id=self.id, + from_device=self.indexed.from_device_id, + to_device=self.indexed.to_device_id, + ) + + __str__ = __repr__ + + def _edit_form(self) -> InspectApiEdgeForm | None: + return self.snapshot.get_edge_details(self.id) + + def _form_get(self, attr: str, default: Any = None) -> Any: + form = self._edit_form() + return getattr(form, attr, default) if form is not None else default + + def _weight_factor(self, key: str) -> int | None: + form = self._edit_form() + if form is None: + return None + return (form.weightFactors.get(key) or {}).get("weight") + + @staticmethod + def _map_conflict_priority(raw: Any) -> InspectConfigPriority | int | str | None: + if raw is None: + return None + return CONFLICT_PRIORITY_BY_INT.get(raw, raw) if isinstance(raw, int) else raw + + +__all__ = ["InspectEdge"] diff --git a/src/videoipath_automation_tool/apps/inspect/domain/module.py b/src/videoipath_automation_tool/apps/inspect/domain/module.py new file mode 100644 index 0000000..fc9fd09 --- /dev/null +++ b/src/videoipath_automation_tool/apps/inspect/domain/module.py @@ -0,0 +1,138 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Literal + +from pydantic import Field + +from videoipath_automation_tool.apps.inspect.domain.port import PortFromTemplate +from videoipath_automation_tool.apps.inspect.model.common import ( + InspectApiStatusSummary, + InspectEditableModel, + InspectInternalModel, + format_repr, +) +from videoipath_automation_tool.apps.inspect.model.virtual import InspectApiVirtualModule +from videoipath_automation_tool.apps.inspect.snapshot import InspectSnapshot, _STAGED_MISSING + +if TYPE_CHECKING: + from videoipath_automation_tool.apps.inspect.domain.alarm import InspectAlarm + from videoipath_automation_tool.apps.inspect.domain.device import InspectDevice + from videoipath_automation_tool.apps.inspect.domain.port import InspectPort + from videoipath_automation_tool.apps.inspect.domain.vertex import InspectVertex + from videoipath_automation_tool.apps.inspect.model.collector import InspectApiModuleStatus + + +class VirtualModuleSpec(InspectInternalModel): + """One module on a virtual device (UI: Module 1, Module 2, …). Mutable for fluent building.""" + + ports: list[PortFromTemplate] = Field(default_factory=list) + module_number: int | None = None + + def to_wire(self) -> InspectApiVirtualModule: + return InspectApiVirtualModule( + moduleNumber=self.module_number, + vertices=[port.to_wire() for port in self.ports], + ) + + def __repr__(self) -> str: + return format_repr(self, ports=len(self.ports), module_number=self.module_number) + + __str__ = __repr__ + + +class InspectModule(InspectEditableModel): + """A device module / slot. A module owns many ports, each of which carries one or more vertices, so a module + holds many vertices in total. The module status is resolved live from the snapshot, so a held + reference sees hydrated/refreshed data transparently. + + Prefer :attr:`id` and :attr:`device` (``module.device.id``) over the constructor fields + ``module_id`` / ``device_id``. + + Editable attributes use property setters that stage pending intents on the snapshot + (read-your-writes). Flush with ``app.inspect.update(module)``, ``app.inspect.update(device)``, + or ``tx.update(...)`` inside a transaction. Module tags are committed via ``assignTag`` / + ``unassignTag`` (not ``updateTopology``). + """ + + snapshot: InspectSnapshot + device_id: str + module_id: str + + @property + def _edit_kind(self) -> Literal["module"]: + return "module" + + @property + def id(self) -> str: + return self.module_id + + @property + def label(self) -> str | None: + """The module label.""" + status = self._status() + return status.effective_label if status is not None else None + + @property + def description(self) -> str | None: + status = self._status() + return status.effective_description if status is not None else None + + @property + def status(self) -> InspectApiStatusSummary | None: + status = self._status() + return status.status if status is not None else None + + @property + def alarms(self) -> list[InspectAlarm]: + """Active alarms correlated to this module (worst severity first).""" + return self.snapshot.get_alarms_for_module(self.device_id, self.module_id) + + @property + def tags(self) -> list[str]: + """Locally assigned module tags (``tagsInfo.assigned.local``; writable via assign/unassign).""" + staged = self._staged("tags") + if staged is not _STAGED_MISSING: + return list(staged) + status = self._status() + if status is None: + return [] + local = status.local_assigned_tags + return list(local) if local else list(status.assigned_tags) + + @tags.setter + def tags(self, value: list[str]) -> None: + self._stage("tags", list(value)) + + @property + def device(self) -> InspectDevice | None: + """The owning device; use ``module.device.id`` for the device id.""" + return self.snapshot.get_device_by_id(self.device_id) + + @property + def ports(self) -> list[InspectPort]: + """The port rows in this module (e.g. "Router In 11.1", "Router Out 11.1", …).""" + return self.snapshot.get_ports_for_module(self.device_id, self.module_id) + + @property + def vertices(self) -> list[InspectVertex]: + """Every vertex across the module's ports (hydrates + batches vertex lookups).""" + ports = self.ports + sides = [side for port in ports for side in port._vertex_sides()] + self.snapshot.get_vertex_details_many([vid for vid, _ in sides]) + return [vertex for port in ports for vertex in port._vertices()] + + def __repr__(self) -> str: + return format_repr( + self, + id=self.id, + device=self.device_id, + label=lambda: status.effective_label if (status := self._status()) is not None else None, + ) + + __str__ = __repr__ + + def _status(self) -> InspectApiModuleStatus | None: + return self.snapshot.get_module_status(self.device_id, self.module_id) + + +__all__ = ["InspectModule", "VirtualModuleSpec"] diff --git a/src/videoipath_automation_tool/apps/inspect/domain/port.py b/src/videoipath_automation_tool/apps/inspect/domain/port.py new file mode 100644 index 0000000..37d4b26 --- /dev/null +++ b/src/videoipath_automation_tool/apps/inspect/domain/port.py @@ -0,0 +1,239 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Mapping, Self + +from pydantic import Field, model_validator + +from videoipath_automation_tool.apps.inspect.model.collector import ( + InspectApiDoubleVertexInfo, + InspectApiSingleVertexInfo, +) +from videoipath_automation_tool.apps.inspect.model.common import ( + InspectCodecFormat, + InspectFrozenModel, + InspectVertexType, + format_repr, +) +from videoipath_automation_tool.apps.inspect.model.ngraph import InspectApiNGraphElementType +from videoipath_automation_tool.apps.inspect.model.virtual import ( + InspectApiVirtualPortFromTemplate, + InspectApiVirtualTemplateItem, +) +from videoipath_automation_tool.apps.inspect.snapshot import ( + InspectSnapshot, + _IndexedPort, + _port_id_from_status, +) + +if TYPE_CHECKING: + from videoipath_automation_tool.apps.inspect.domain.alarm import InspectAlarm + from videoipath_automation_tool.apps.inspect.domain.device import InspectDevice + from videoipath_automation_tool.apps.inspect.domain.edge import InspectEdge + from videoipath_automation_tool.apps.inspect.domain.module import InspectModule + from videoipath_automation_tool.apps.inspect.domain.vertex import InspectVertex + from videoipath_automation_tool.apps.inspect.model.common import InspectApiStatusSummary + + +class PortFromTemplate(InspectFrozenModel): + """One port (or set of ports) instantiated from a port template (virtual-device create).""" + + template_id: str + count: int = 1 + + @model_validator(mode="after") + def _validate_count(self) -> Self: + if not self.template_id: + raise ValueError("template_id must not be empty.") + if self.count < 1: + raise ValueError("count must be at least 1.") + return self + + def to_wire(self) -> InspectApiVirtualPortFromTemplate: + return InspectApiVirtualPortFromTemplate(templateId=self.template_id, count=self.count) + + def __repr__(self) -> str: + return format_repr(self, template_id=self.template_id, count=self.count) + + __str__ = __repr__ + + +class InspectPortTemplate(InspectFrozenModel): + """A port template (UI term; API: virtual template).""" + + id: str + label: str + kind: InspectApiNGraphElementType | str | None = None + direction: InspectVertexType | str | None = None + codec_format: InspectCodecFormat | str | None = None + vertex: dict[str, Any] = Field(default_factory=dict) + + @classmethod + def from_wire(cls, wire: InspectApiVirtualTemplateItem) -> InspectPortTemplate: + vertex = wire.vertex.model_dump(mode="json", exclude_none=False) + return cls( + id=wire.id, + label=wire.label, + kind=wire.vertex.type, + direction=wire.vertex.vertexType, + codec_format=wire.vertex.codecFormat, + vertex=vertex, + ) + + def __repr__(self) -> str: + return format_repr( + self, + id=self.id, + label=self.label, + kind=self.kind, + direction=self.direction, + ) + + __str__ = __repr__ + + +class InspectPort(InspectFrozenModel): + """A port (the Inspect UI's "Module" edit modal): a lean container that owns one or more vertices. + + Its own editable attributes are ``label``, ``description`` and ``tags``; ``id``, ``status`` and + ``factory_label`` are read-only. Navigate to the owning module via :attr:`module` (use + ``port.module.id``). All vertex-specific configuration (direction, active/controlled/endpoint, + IP/codec fields, SIPS, control, …) lives on the vertices reachable via :attr:`vertex_out` / + :attr:`vertex_in`. + """ + + snapshot: InspectSnapshot + indexed: _IndexedPort + + @property + def id(self) -> str | None: + return _port_id_from_status(self.indexed.port) + + @property + def label(self) -> str | None: + """The label the UI shows: the manual override (``descriptor.label``), falling back to the + device-reported factory label.""" + return self.indexed.port.effective_label + + @property + def factory_label(self) -> str | None: + """The device-reported (factory) port label, even when a manual override is set.""" + return self.indexed.port.label + + @property + def description(self) -> str | None: + return self.indexed.port.effective_description + + @property + def status(self) -> InspectApiStatusSummary | None: + return self.indexed.port.status + + @property + def alarms(self) -> list[InspectAlarm]: + """Active alarms correlated to this port (worst severity first).""" + return self.snapshot.get_alarms_for_port(self.id, device_id=self.indexed.device_id) + + @property + def tags(self) -> list[str]: + """Tags assigned to this port (as ``Category~~name`` ids). Assign them with + ``app.inspect.update_vertex(v.id, tags=[...])`` for a vertex ``v`` from + :attr:`vertex_out` / :attr:`vertex_in`.""" + return self.indexed.port.assigned_tags + + @property + def device(self) -> InspectDevice | None: + return self.snapshot.get_device_by_id(self.indexed.device_id) + + @property + def module(self) -> InspectModule | None: + """The module / slot this port belongs to.""" + module_id = self.indexed.module_id + if module_id is None: + return None + return self.snapshot.get_module(self.indexed.device_id, module_id) + + @property + def is_bidirectional(self) -> bool: + """True when this port carries a ``double`` ``vertexInfo`` (separate out and in vertices).""" + return isinstance(self.indexed.port.parsed_vertex_info, InspectApiDoubleVertexInfo) + + @property + def vertex_out(self) -> InspectVertex | None: + """The ``Out`` vertex, if this port has one; ``None`` otherwise.""" + return self._vertex_by_type("Out") + + @property + def vertex_in(self) -> InspectVertex | None: + """The ``In`` vertex, if this port has one; ``None`` otherwise.""" + return self._vertex_by_type("In") + + @property + def edges(self) -> list[InspectEdge]: + """The edges incident on this port. The Inspect read view (``externalEdgesByDeviceKey``) keys + edge endpoints by *port* — with a UUID edge id and only a direction hint in the endpoint label + — so edges are exposed at the port level here, not per vertex.""" + port_id = self.id + if not port_id: + return [] + return self.snapshot.get_edges_for_port(self.indexed.device_id, port_id) + + def __repr__(self) -> str: + return format_repr( + self, + id=self.id, + label=self.label, + device=self.indexed.device_id, + ) + + __str__ = __repr__ + + def _vertex_by_type(self, vertex_type: str) -> InspectVertex | None: + for vid, side in self._vertex_sides(): + if side.vertexType == vertex_type: + return self.snapshot.get_vertex(vid, vertex_info=side, port_factory_label=self.factory_label) + return None + + def _vertices(self) -> list[InspectVertex]: + """All typed vertex views this port carries (including Internal/Undecided). Prefer + :attr:`vertex_out` / :attr:`vertex_in` for the public API.""" + return [ + self.snapshot.get_vertex(vid, vertex_info=side, port_factory_label=self.factory_label) + for vid, side in self._vertex_sides() + ] + + def _vertex_sides(self) -> list[tuple[str, InspectApiSingleVertexInfo]]: + """(vertex id, its offline ``vertexInfo`` side) for each vertex the port carries.""" + info = self.indexed.port.parsed_vertex_info + if info is None: + return [] + if isinstance(info, InspectApiSingleVertexInfo): + return [(info.id, info)] if info.id else [] + return [(side.id, side) for side in (info.out, info.in_) if side is not None and side.id] + + def _offline_vertices(self) -> list[InspectVertex]: + """Base vertex views built purely from the offline ``vertexInfo`` (no lookup). Used for + offline filtering; direction/active/controlled/endpoint resolve without a fetch.""" + from videoipath_automation_tool.apps.inspect.domain.vertex import InspectVertex + + return [InspectVertex(snapshot=self.snapshot, id=vid, vertex_info=side) for vid, side in self._vertex_sides()] + + +def _ports_to_count_by_template( + ports: Mapping[str, int] | list[PortFromTemplate], +) -> dict[str, int]: + """Normalize port specs to the ``countByVertexTemplate`` wire map.""" + result: dict[str, int] + if isinstance(ports, Mapping): + result = {template_id: count for template_id, count in ports.items()} + else: + result = {} + for port in ports: + result[port.template_id] = result.get(port.template_id, 0) + port.count + for template_id, count in result.items(): + if not template_id: + raise ValueError("template_id must not be empty.") + if count < 1: + raise ValueError(f"count for template {template_id!r} must be at least 1.") + return result + + +__all__ = ["InspectPort", "InspectPortTemplate", "PortFromTemplate"] diff --git a/src/videoipath_automation_tool/apps/inspect/domain/service.py b/src/videoipath_automation_tool/apps/inspect/domain/service.py new file mode 100644 index 0000000..9b59bbc --- /dev/null +++ b/src/videoipath_automation_tool/apps/inspect/domain/service.py @@ -0,0 +1,122 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from videoipath_automation_tool.apps.inspect.model.collector import InspectApiPathItem, InspectServiceStatus +from videoipath_automation_tool.apps.inspect.model.common import InspectFrozenModel, format_repr +from videoipath_automation_tool.apps.inspect.snapshot import InspectSnapshot, _port_id_from_endpoint + +if TYPE_CHECKING: + from videoipath_automation_tool.apps.inspect.domain.alarm import InspectAlarm + from videoipath_automation_tool.apps.inspect.domain.device import InspectDevice + from videoipath_automation_tool.apps.inspect.domain.port import InspectPort + + +class InspectService(InspectFrozenModel): + snapshot: InspectSnapshot + path_item: InspectApiPathItem + + @property + def booking_id(self) -> str: + return self.path_item.serviceFields.bid + + @property + def label(self) -> str | None: + generic = self.path_item.serviceFields.generic + if generic is not None and generic.descriptor is not None: + return generic.descriptor.label or None + return self.path_item.serviceFields.fromLabel or self.path_item.serviceFields.toLabel + + @property + def source(self) -> str | None: + return self.path_item.serviceFields.fromLabel + + @property + def destination(self) -> str | None: + return self.path_item.serviceFields.toLabel + + @property + def source_port(self) -> InspectPort | None: + return self._resolve_endpoint_port(self.path_item.serviceFields.fromPid) + + @property + def destination_port(self) -> InspectPort | None: + return self._resolve_endpoint_port(self.path_item.serviceFields.toPid) + + @property + def source_device(self) -> InspectDevice | None: + port = self.source_port + if port is not None: + return port.device + devices = self.path_devices + return devices[0] if devices else None + + @property + def destination_device(self) -> InspectDevice | None: + port = self.destination_port + if port is not None: + return port.device + devices = self.path_devices + return devices[-1] if devices else None + + @property + def is_main(self) -> bool | None: + return self.path_item.serviceFields.isMain + + @property + def status(self) -> InspectServiceStatus | None: + return self.path_item.serviceFields.serviceStatus + + @property + def alarms(self) -> list[InspectAlarm]: + """Active alarms correlated to this service/booking (worst severity first).""" + return self.snapshot.get_alarms_for_service(self.booking_id) + + @property + def path_devices(self) -> list[InspectDevice]: + devices: list[InspectDevice] = [] + seen: set[str] = set() + for segment in self.path_item.path: + structure = segment.structure + if structure is None or not structure.deviceId or structure.deviceId in seen: + continue + device = self.snapshot.get_device_by_id(structure.deviceId) + if device is not None: + devices.append(device) + seen.add(structure.deviceId) + return devices + + @property + def path_ports(self) -> list[InspectPort]: + ports: list[InspectPort] = [] + seen: set[tuple[str, str]] = set() + for segment in self.path_item.path: + structure = segment.structure + if structure is None or not structure.deviceId: + continue + for endpoint in (structure.inputStatus, structure.outputStatus): + port_id = _port_id_from_endpoint(endpoint) + if not port_id: + continue + key = (structure.deviceId, port_id) + if key in seen: + continue + port = self.snapshot.get_port(structure.deviceId, port_id) + if port is not None: + ports.append(port) + seen.add(key) + return ports + + def __repr__(self) -> str: + return format_repr(self, booking_id=self.booking_id, label=self.label) + + __str__ = __repr__ + + def _resolve_endpoint_port(self, port_id: str | None) -> InspectPort | None: + if not port_id: + return None + for device in self.path_devices: + port = self.snapshot.get_port(device.id, port_id) + if port is not None: + return port + return self.snapshot.find_port_by_id(port_id) diff --git a/src/videoipath_automation_tool/apps/inspect/domain/vertex.py b/src/videoipath_automation_tool/apps/inspect/domain/vertex.py new file mode 100644 index 0000000..8f7e070 --- /dev/null +++ b/src/videoipath_automation_tool/apps/inspect/domain/vertex.py @@ -0,0 +1,657 @@ +"""Typed vertex domain views. + +A vertex is one directed endpoint of a port (a port carries one vertex, or an ``out``/``in`` pair). +``InspectVertex`` is the base read/write view over the vertex edit form (``lookupInspectVertexById``); +concrete kinds subclass it to expose their kind-specific attributes: + +- ``InspectIpVertex`` — IP config (address, netmask, VLAN, VRF, config-support flags). +- ``InspectCodecVertex`` — codec config (``typeFields.generic`` / ``typeFields.specific``). +- ``InspectGenericVertex`` — base fields only. +- ``InspectResourceTransformVertex`` — base fields only (kind-specific fields await a live sample). + +Router vertices are surfaced as the base ``InspectVertex`` (their ``park_port`` lives on the base). +Instances are built by :meth:`InspectSnapshot.get_vertex`, which picks the subclass from the edit +form's ``typeFields.type``. + +Editable attributes use property setters that stage pending wire-field intents on the snapshot +(read-your-writes). Flush with ``app.inspect.update(vertex)``, ``app.inspect.update(device)``, +or ``tx.update(...)`` inside a transaction. +""" + +from __future__ import annotations + +import warnings +from typing import TYPE_CHECKING, Any, Literal + +from videoipath_automation_tool.apps.inspect.model.collector import InspectApiSingleVertexInfo +from videoipath_automation_tool.apps.inspect.model.common import ( + InspectCodecFormat, + InspectControl, + InspectEditableModel, + InspectSipsMode, + InspectVertexKind, + InspectVertexType, + _STAGED_MISSING, + format_repr, +) +from videoipath_automation_tool.apps.inspect.snapshot import InspectSnapshot + +if TYPE_CHECKING: + from videoipath_automation_tool.apps.inspect.model.actions import ( + InspectApiCodecGeneric, + InspectApiCodecSpecific, + InspectApiLookupVertexResponseData, + InspectApiVertexControlProps, + InspectApiVertexEditForm, + InspectApiVertexTypeFields, + ) + + +class InspectVertex(InspectEditableModel): + """Base read/write view of a single vertex. + + Direction and the ``isActive`` / ``isControlled`` / ``isEndpoint`` status flags resolve offline + from the owning port's ``vertexInfo`` (``vertex_info``, set when built via a port); the config + fields resolve live from the snapshot's cached edit form, with pending setter edits taking + precedence (read-your-writes).""" + + snapshot: InspectSnapshot + id: str + vertex_info: InspectApiSingleVertexInfo | None = None + port_factory_label: str | None = None + + @property + def _edit_kind(self) -> Literal["vertex"]: + return "vertex" + + # --- Offline / identity --- + + @property + def vertex_type(self) -> InspectVertexType | str | None: + """Vertex direction: ``"In"`` / ``"Out"`` / ``"Internal"`` / …. Offline from the port's + ``vertexInfo``; falls back to the lookup response when built without a port.""" + if self.vertex_info is not None: + return self.vertex_info.vertexType + lookup = self._lookup() + return lookup.vertexType if lookup else None + + @property + def factory_label(self) -> str | None: + """Device-reported factory label of the owning port (set when built via a port).""" + return self.port_factory_label + + @property + def is_active(self) -> bool | None: + return self._info_flag("isActive") + + @property + def is_controlled(self) -> bool | None: + return self._info_flag("isControlled") + + @property + def is_endpoint(self) -> bool | None: + """Whether the vertex is usable as a service endpoint ("Use as endpoint" in the UI).""" + return self._info_flag("isEndpoint") + + # --- Base edit-form fields --- + + @property + def label(self) -> str | None: + return self._staged_or("label", lambda: self._form_get("label")) + + @label.setter + def label(self, value: str) -> None: + self._stage("label", value) + + @property + def description(self) -> str | None: + return self._staged_or("desc", lambda: self._form_get("desc")) + + @description.setter + def description(self, value: str) -> None: + self._stage("desc", value) + + @property + def tags(self) -> list[str]: + """Tags assigned to this vertex (``localAssignedTags``).""" + return self._staged_or( + "localAssignedTags", + lambda: list(self._form_get("localAssignedTags") or []), + adapt=list, + ) + + @tags.setter + def tags(self, value: list[str]) -> None: + self._stage("localAssignedTags", list(value)) + + @property + def form_tags(self) -> list[str]: + """The vertex form's ``tags`` list (distinct from :attr:`tags` / ``localAssignedTags``).""" + return self._staged_or("tags", lambda: list(self._form_get("tags") or []), adapt=list) + + @form_tags.setter + def form_tags(self, value: list[str]) -> None: + self._stage("tags", list(value)) + + @property + def active(self) -> bool | None: + return self._staged_or("active", lambda: self._form_get("active")) + + @active.setter + def active(self, value: bool) -> None: + self._stage("active", value) + + @property + def use_as_endpoint(self) -> bool | None: + """Whether the vertex is usable as a service endpoint ("Use as endpoint" in the UI).""" + return self._staged_or("useAsEndpoint", lambda: self._form_get("useAsEndpoint")) + + @use_as_endpoint.setter + def use_as_endpoint(self, value: bool) -> None: + self._stage("useAsEndpoint", value) + + @property + def sips_mode(self) -> InspectSipsMode | str | None: + return self._staged_or("sipsMode", lambda: self._form_get("sipsMode")) + + @sips_mode.setter + def sips_mode(self, value: InspectSipsMode | str) -> None: + self._stage("sipsMode", value) + + @property + def control_props(self) -> InspectApiVertexControlProps | None: + return self._staged_or("controlProps", lambda: self._form_get("controlProps"), adapt=self._as_control_props) + + @control_props.setter + def control_props(self, value: Any) -> None: + self._stage("controlProps", value) + + @property + def control(self) -> InspectControl | str | None: + """Best-effort ``control`` scalar (``"full"`` / ``"off"`` / ``"semi"``). + + The verified 2025.4.9 vertex edit form exposes ``controlProps`` rather than a ``control`` + scalar; this setter stages a top-level ``control`` intent (allowed by ``extra="allow"``) + for servers that accept it. Prefer :attr:`control_props` when targeting the verified form. + """ + return self._staged_or("control", lambda: self._form_get("control")) + + @control.setter + def control(self, value: InspectControl | str) -> None: + warnings.warn( + "InspectVertex.control stages a best-effort top-level 'control' field; the verified " + "2025.4.9 edit form exposes controlProps instead. Prefer control_props when possible.", + UserWarning, + stacklevel=2, + ) + self._stage("control", value) + + @property + def extra_alert_filters(self) -> list[Any]: + return self._staged_or( + "extraAlertFilters", + lambda: list(self._form_get("extraAlertFilters") or []), + adapt=list, + ) + + @extra_alert_filters.setter + def extra_alert_filters(self, value: list[Any]) -> None: + self._stage("extraAlertFilters", list(value)) + + @property + def custom(self) -> dict[str, Any]: + return self._staged_or("custom", lambda: dict(self._form_get("custom") or {}), adapt=dict) + + @custom.setter + def custom(self, value: dict[str, Any]) -> None: + self._stage("custom", dict(value)) + + @property + def queueable(self) -> bool | None: + return self._staged_or("queueable", lambda: self._form_get("queueable")) + + @queueable.setter + def queueable(self, value: bool) -> None: + self._stage("queueable", value) + + @property + def destination_monitor_leader(self) -> bool | None: + return self._staged_or( + "destinationMonitorLeader", + lambda: self._form_get("destinationMonitorLeader"), + ) + + @destination_monitor_leader.setter + def destination_monitor_leader(self, value: bool) -> None: + self._stage("destinationMonitorLeader", value) + + @property + def vertex_kind(self) -> InspectVertexKind | str | None: + """Vertex kind from ``typeFields.type``: ``"generic"`` / ``"ip"`` / ``"codec"`` / ``"router"``.""" + type_fields = self.type_fields + return type_fields.type if type_fields is not None else None + + @property + def custom_schemas(self) -> dict[str, Any]: + lookup = self._lookup() + return dict(lookup.customSchemas) if lookup else {} + + @property + def is_virtual(self) -> bool | None: + lookup = self._lookup() + return lookup.isVirtual if lookup else None + + @property + def park_port(self) -> int | None: + """Park port (router vertices): ``typeFields.parkPort``.""" + return self._staged_or( + "typeFields.parkPort", + lambda: self.type_fields.parkPort if self.type_fields is not None else None, + ) + + @park_port.setter + def park_port(self, value: int) -> None: + self._stage("typeFields.parkPort", value) + + @property + def type_fields(self) -> InspectApiVertexTypeFields | None: + return self._form_get("typeFields") + + def __repr__(self) -> str: + return format_repr( + self, + id=self.id, + type=self.vertex_info.vertexType if self.vertex_info is not None else None, + ) + + __str__ = __repr__ + + # --- Internal --- + + def _info_flag(self, name: str) -> bool | None: + info = self.vertex_info + if info is None or info.fields is None: + return None + return getattr(info.fields, name, None) + + def _lookup(self) -> InspectApiLookupVertexResponseData | None: + return self.snapshot.get_vertex_details(self.id) + + def _form(self) -> InspectApiVertexEditForm | None: + lookup = self._lookup() + return lookup.fields if lookup else None + + def _form_get(self, attr: str, default: Any = None) -> Any: + form = self._form() + return getattr(form, attr, default) if form is not None else default + + @staticmethod + def _as_control_props(value: Any) -> InspectApiVertexControlProps | None: + from videoipath_automation_tool.apps.inspect.model.actions import InspectApiVertexControlProps + + if value is None or isinstance(value, InspectApiVertexControlProps): + return value + return InspectApiVertexControlProps.model_validate(value) + + +class InspectGenericVertex(InspectVertex): + """A generic vertex (``typeFields.type == "generic"``); exposes the base fields only.""" + + +class InspectIpVertex(InspectVertex): + """An IP vertex (``typeFields.type == "ip"``): IP addressing and config-support flags.""" + + @property + def ip_address(self) -> str | None: + return self._tf("ipAddress", "typeFields.ipAddress") + + @ip_address.setter + def ip_address(self, value: str) -> None: + self._stage("typeFields.ipAddress", value) + + @property + def ip_netmask(self) -> str | None: + return self._tf("ipNetmask", "typeFields.ipNetmask") + + @ip_netmask.setter + def ip_netmask(self, value: str) -> None: + self._stage("typeFields.ipNetmask", value) + + @property + def public(self) -> bool | None: + return self._tf("public", "typeFields.public") + + @public.setter + def public(self, value: bool) -> None: + self._stage("typeFields.public", value) + + @property + def vlan_id(self) -> str | None: + return self._tf("vlanId", "typeFields.vlanId") + + @vlan_id.setter + def vlan_id(self, value: str) -> None: + self._stage("typeFields.vlanId", value) + + @property + def vrf_id(self) -> str | None: + return self._tf("vrfId", "typeFields.vrfId") + + @vrf_id.setter + def vrf_id(self, value: str) -> None: + self._stage("typeFields.vrfId", value) + + @property + def supports_cpipe(self) -> bool | None: + return self._tf("supportsCpipeCfg", "typeFields.supportsCpipeCfg") + + @supports_cpipe.setter + def supports_cpipe(self, value: bool) -> None: + self._stage("typeFields.supportsCpipeCfg", value) + + @property + def supports_igmp(self) -> bool | None: + return self._tf("supportsIgmpCfg", "typeFields.supportsIgmpCfg") + + @supports_igmp.setter + def supports_igmp(self, value: bool) -> None: + self._stage("typeFields.supportsIgmpCfg", value) + + @property + def supports_mac_forwarding(self) -> bool | None: + return self._tf("supportsMacForwardingCfg", "typeFields.supportsMacForwardingCfg") + + @supports_mac_forwarding.setter + def supports_mac_forwarding(self, value: bool) -> None: + self._stage("typeFields.supportsMacForwardingCfg", value) + + @property + def supports_nso(self) -> bool | None: + return self._tf("supportsNsoCfg", "typeFields.supportsNsoCfg") + + @supports_nso.setter + def supports_nso(self, value: bool) -> None: + self._stage("typeFields.supportsNsoCfg", value) + + @property + def supports_openflow(self) -> bool | None: + return self._tf("supportsOpenflowCfg", "typeFields.supportsOpenflowCfg") + + @supports_openflow.setter + def supports_openflow(self, value: bool) -> None: + self._stage("typeFields.supportsOpenflowCfg", value) + + @property + def supports_static_igmp(self) -> bool | None: + return self._tf("supportsStaticIgmpCfg", "typeFields.supportsStaticIgmpCfg") + + @supports_static_igmp.setter + def supports_static_igmp(self, value: bool) -> None: + self._stage("typeFields.supportsStaticIgmpCfg", value) + + # Alias matching the topology app's naming used by vertex processors. + @property + def supports_static_igmp_config(self) -> bool | None: + return self.supports_static_igmp + + @supports_static_igmp_config.setter + def supports_static_igmp_config(self, value: bool) -> None: + self.supports_static_igmp = value + + @property + def supports_vlan(self) -> bool | None: + return self._tf("supportsVlanCfg", "typeFields.supportsVlanCfg") + + @supports_vlan.setter + def supports_vlan(self, value: bool) -> None: + self._stage("typeFields.supportsVlanCfg", value) + + @property + def supports_vpls(self) -> bool | None: + return self._tf("supportsVplsCfg", "typeFields.supportsVplsCfg") + + @supports_vpls.setter + def supports_vpls(self, value: bool) -> None: + self._stage("typeFields.supportsVplsCfg", value) + + def _tf(self, name: str, staged_field: str) -> Any: + return self._staged_or( + staged_field, + lambda: getattr(self.type_fields, name, None) if self.type_fields is not None else None, + ) + + +class InspectCodecVertex(InspectVertex): + """A codec vertex (``typeFields.type == "codec"``): codec format and source/destination config, + read from the ``typeFields.generic`` / ``typeFields.specific`` blocks (verified 2025.4.9).""" + + @property + def codec_format(self) -> InspectCodecFormat | str | None: + return self._generic_get("codecFormat", "typeFields.generic.codecFormat") + + @codec_format.setter + def codec_format(self, value: InspectCodecFormat | str) -> None: + self._stage("typeFields.generic.codecFormat", value) + + @property + def public(self) -> bool | None: + return self._generic_get("public", "typeFields.generic.public") + + @public.setter + def public(self, value: bool) -> None: + self._stage("typeFields.generic.public", value) + + @property + def multiplicity(self) -> int | None: + return self._generic_get("multiplicity", "typeFields.generic.multiplicity") + + @multiplicity.setter + def multiplicity(self, value: int) -> None: + self._stage("typeFields.generic.multiplicity", value) + + @property + def extra_formats(self) -> list[Any]: + return self._staged_or( + "typeFields.generic.extraFormats", + lambda: list(g.extraFormats) if (g := self.generic) else [], + adapt=list, + ) + + @extra_formats.setter + def extra_formats(self, value: list[Any]) -> None: + self._stage("typeFields.generic.extraFormats", list(value)) + + @property + def bidir_partner_id(self) -> str | None: + return self._generic_get("bidirPartnerId", "typeFields.generic.bidirPartnerId") + + @bidir_partner_id.setter + def bidir_partner_id(self, value: str | None) -> None: + self._stage("typeFields.generic.bidirPartnerId", value) + + @property + def partner_config(self) -> Any: + return self._generic_get("partnerConfig", "typeFields.generic.partnerConfig") + + @partner_config.setter + def partner_config(self, value: Any) -> None: + self._stage("typeFields.generic.partnerConfig", value) + + @property + def service_id(self) -> Any: + return self._generic_get("serviceId", "typeFields.generic.serviceId") + + @service_id.setter + def service_id(self, value: Any) -> None: + self._stage("typeFields.generic.serviceId", value) + + @property + def main_src_info(self) -> dict[str, Any] | None: + return self._endpoint_info("mainSrcInfo") + + @main_src_info.setter + def main_src_info(self, value: dict[str, Any] | None) -> None: + self._stage("typeFields.generic.mainSrcInfo", value) + + @property + def main_dst_info(self) -> dict[str, Any] | None: + return self._endpoint_info("mainDstInfo") + + @main_dst_info.setter + def main_dst_info(self, value: dict[str, Any] | None) -> None: + self._stage("typeFields.generic.mainDstInfo", value) + + @property + def spare_src_info(self) -> dict[str, Any] | None: + return self._endpoint_info("spareSrcInfo") + + @spare_src_info.setter + def spare_src_info(self, value: dict[str, Any] | None) -> None: + self._stage("typeFields.generic.spareSrcInfo", value) + + @property + def spare_dst_info(self) -> dict[str, Any] | None: + return self._endpoint_info("spareDstInfo") + + @spare_dst_info.setter + def spare_dst_info(self, value: dict[str, Any] | None) -> None: + self._stage("typeFields.generic.spareDstInfo", value) + + @property + def main_destination_port(self) -> int | None: + return self._staged_or( + "typeFields.generic.mainDstInfo.port", + lambda: self._port_from_endpoint_info(self.main_dst_info), + ) + + @main_destination_port.setter + def main_destination_port(self, value: int) -> None: + self._stage("typeFields.generic.mainDstInfo.port", value) + + @property + def spare_destination_port(self) -> int | None: + return self._staged_or( + "typeFields.generic.spareDstInfo.port", + lambda: self._port_from_endpoint_info(self.spare_dst_info), + ) + + @spare_destination_port.setter + def spare_destination_port(self, value: int) -> None: + self._stage("typeFields.generic.spareDstInfo.port", value) + + @property + def is_igmp_source(self) -> bool | None: + return self._specific_get("isIgmpSource", "typeFields.specific.isIgmpSource") + + @is_igmp_source.setter + def is_igmp_source(self, value: bool) -> None: + self._stage("typeFields.specific.isIgmpSource", value) + + @property + def sdp_support(self) -> bool | None: + return self._specific_get("sdpSupport", "typeFields.specific.sdpSupport") + + @sdp_support.setter + def sdp_support(self, value: bool) -> None: + self._stage("typeFields.specific.sdpSupport", value) + + @property + def specific_type(self) -> str | None: + return self._specific_get("type", "typeFields.specific.type") + + @specific_type.setter + def specific_type(self, value: str) -> None: + self._stage("typeFields.specific.type", value) + + @property + def generic(self) -> InspectApiCodecGeneric | None: + """The typed ``typeFields.generic`` block (parsed from the lossless-preserved extra).""" + from videoipath_automation_tool.apps.inspect.model.actions import InspectApiCodecGeneric + + raw = self._type_fields_extra("generic") + return InspectApiCodecGeneric.model_validate(raw) if isinstance(raw, dict) else None + + @property + def specific(self) -> InspectApiCodecSpecific | None: + """The typed ``typeFields.specific`` block (parsed from the lossless-preserved extra).""" + from videoipath_automation_tool.apps.inspect.model.actions import InspectApiCodecSpecific + + raw = self._type_fields_extra("specific") + return InspectApiCodecSpecific.model_validate(raw) if isinstance(raw, dict) else None + + def _generic_get(self, attr: str, staged_field: str) -> Any: + return self._staged_or( + staged_field, + lambda: getattr(g, attr, None) if (g := self.generic) else None, + ) + + def _specific_get(self, attr: str, staged_field: str) -> Any: + return self._staged_or( + staged_field, + lambda: getattr(s, attr, None) if (s := self.specific) else None, + ) + + @staticmethod + def _port_from_endpoint_info(info: dict[str, Any] | None) -> int | None: + port = info.get("port") if info else None + return int(port) if isinstance(port, (int, float)) else port + + def _endpoint_info(self, name: str) -> dict[str, Any] | None: + staged = self._staged(f"typeFields.generic.{name}") + if staged is not _STAGED_MISSING: + return dict(staged) if isinstance(staged, dict) else staged + # Merge leaf-level staged ports into the baseline block for read-your-writes. + generic = self.generic + baseline = getattr(generic, name, None) if generic else None + merged: dict[str, Any] = dict(baseline) if isinstance(baseline, dict) else {} + for leaf in ("ip", "mac", "port", "vlan", "gateway", "netmask"): + leaf_staged = self._staged(f"typeFields.generic.{name}.{leaf}") + if leaf_staged is not _STAGED_MISSING: + merged[leaf] = leaf_staged + return merged or (baseline if isinstance(baseline, dict) else None) + + def _type_fields_extra(self, name: str) -> Any: + type_fields = self.type_fields + return getattr(type_fields, name, None) if type_fields is not None else None + + +class InspectResourceTransformVertex(InspectVertex): + """A resource-transform vertex. Exposes the base fields; kind-specific attributes await a live + sample (none present on the verified 2025.4.9 server).""" + + +def build_vertex( + snapshot: InspectSnapshot, + vertex_id: str, + kind: InspectVertexKind | str | None, + vertex_info: InspectApiSingleVertexInfo | None = None, + port_factory_label: str | None = None, +) -> InspectVertex: + """Construct the typed vertex view for ``vertex_id`` from its ``typeFields.type`` kind (falls + back to the base :class:`InspectVertex` when the kind is unknown, e.g. no fetcher).""" + cls = _VERTEX_CLASS_BY_KIND.get(kind, InspectVertex) + return cls( + snapshot=snapshot, + id=vertex_id, + vertex_info=vertex_info, + port_factory_label=port_factory_label, + ) + + +_VERTEX_CLASS_BY_KIND: dict[str | None, type[InspectVertex]] = { + "ip": InspectIpVertex, + "codec": InspectCodecVertex, + "generic": InspectGenericVertex, + "resourceTransform": InspectResourceTransformVertex, + "nGraphResourceTransform": InspectResourceTransformVertex, +} + + +__all__ = [ + "InspectVertex", + "InspectGenericVertex", + "InspectIpVertex", + "InspectCodecVertex", + "InspectResourceTransformVertex", + "build_vertex", +] diff --git a/src/videoipath_automation_tool/apps/inspect/errors.py b/src/videoipath_automation_tool/apps/inspect/errors.py new file mode 100644 index 0000000..53f51a3 --- /dev/null +++ b/src/videoipath_automation_tool/apps/inspect/errors.py @@ -0,0 +1,103 @@ +"""Typed exceptions for the Inspect app. + +The Inspect surface has semantics that a raw HTTP envelope cannot express: +commit success is a three-flag check, concurrent writes are +detected client-side, and over-long projection URLs are +rejected by the proxy before they reach the server. These exceptions carry the +structured detail callers need to react without parsing raw responses. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from videoipath_automation_tool.apps.inspect.model.update_topology import ( + InspectApiUpdateTopologyResponse, + ) + + +class InspectError(Exception): + """Base class for all Inspect app errors.""" + + +class InspectEntityNotFoundError(InspectError): + """A device, vertex, or edge referenced by a read or write does not exist on the server.""" + + def __init__(self, entity_id: str, kind: str = "entity") -> None: + self.entity_id = entity_id + self.kind = kind + super().__init__(f"Inspect {kind} '{entity_id}' was not found on the server.") + + +class InspectQueryTooLongError(InspectError): + """A scoped collector query URL exceeds the server/proxy URI length limit (HTTP 414). + + The Inspect package trims its skeleton projections to stay within the limit; this is + raised only if a caller-built query is too long. Fall back to ``refresh(load="full")``. + """ + + def __init__(self, length: int, limit: int) -> None: + self.length = length + self.limit = limit + super().__init__( + f"Collector query URL is {length} characters, exceeding the {limit}-character limit. " + f"Use a shorter projection or 'app.inspect.refresh(load=\"full\")'." + ) + + +class InspectCommitError(InspectError): + """An ``updateTopology`` commit failed (validation gate or apply gate). + + HTTP/envelope success is not commit success: the server can return ``header.ok == true`` + while ``data.res.ok`` or ``data.validation.result.ok`` is ``false``. This carries the full + typed response so callers can inspect ``validation.details`` and ``res.msg``. + """ + + def __init__(self, response: "InspectApiUpdateTopologyResponse") -> None: + self.response = response + self.result = response.data.res + self.validation = response.data.validation + messages = list(self.result.msg) + list(self.validation.result.msg) + detail = "; ".join(m for m in messages if m) or "commit rejected by the server" + super().__init__(f"Inspect commit failed: {detail}") + + +class InspectConflict: + """One entity whose server state changed between staging and commit.""" + + def __init__(self, entity_id: str, kind: str, field_diffs: dict[str, tuple[object, object]]) -> None: + self.entity_id = entity_id + self.kind = kind + # field -> (staged_baseline_value, current_server_value) + self.field_diffs = field_diffs + + def __repr__(self) -> str: + return f"InspectConflict(entity_id={self.entity_id!r}, kind={self.kind!r}, fields={list(self.field_diffs)})" + + +class InspectCommitConflictError(InspectError): + """A concurrent modification was detected before the commit was sent; nothing was written. + + The commit is aborted as a whole (matching the server's all-or-nothing apply). Callers can + inspect ``conflicts``, then either ``transaction.rebase()`` onto fresh state and retry, or + re-commit with ``check_conflicts=False`` to force last-writer-wins. + """ + + def __init__(self, conflicts: list[InspectConflict]) -> None: + self.conflicts = conflicts + ids = ", ".join(c.entity_id for c in conflicts) + super().__init__( + f"Concurrent modification detected for {len(conflicts)} entity(ies) [{ids}]; commit aborted. " + f"Rebase the transaction or commit with check_conflicts=False to override." + ) + + +__all__ = [ + "InspectError", + "InspectEntityNotFoundError", + "InspectQueryTooLongError", + "InspectCommitError", + "InspectConflict", + "InspectCommitConflictError", +] diff --git a/src/videoipath_automation_tool/apps/inspect/model/__init__.py b/src/videoipath_automation_tool/apps/inspect/model/__init__.py new file mode 100644 index 0000000..bed24fe --- /dev/null +++ b/src/videoipath_automation_tool/apps/inspect/model/__init__.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +from videoipath_automation_tool.apps.inspect.model import ( + actions, + alarms, + collector, + common, + ngraph, + tags, + update_topology, + virtual, +) +from videoipath_automation_tool.apps.inspect.model.actions import * +from videoipath_automation_tool.apps.inspect.model.alarms import * +from videoipath_automation_tool.apps.inspect.model.collector import * +from videoipath_automation_tool.apps.inspect.model.common import * +from videoipath_automation_tool.apps.inspect.model.ngraph import * +from videoipath_automation_tool.apps.inspect.model.tags import * +from videoipath_automation_tool.apps.inspect.model.update_topology import * +from videoipath_automation_tool.apps.inspect.model.virtual import * + +__all__ = [ + *actions.__all__, + *alarms.__all__, + *collector.__all__, + *common.__all__, + *ngraph.__all__, + *tags.__all__, + *update_topology.__all__, + *virtual.__all__, +] diff --git a/src/videoipath_automation_tool/apps/inspect/model/actions.py b/src/videoipath_automation_tool/apps/inspect/model/actions.py new file mode 100644 index 0000000..017f26c --- /dev/null +++ b/src/videoipath_automation_tool/apps/inspect/model/actions.py @@ -0,0 +1,273 @@ +from __future__ import annotations + +from typing import Any, Literal + +from pydantic import Field + +from videoipath_automation_tool.apps.inspect.model.common import ( + InspectApiBaseModel, + InspectApiDescriptor, + InspectApiPostRequestHeader, + InspectApiRestV2Header, + InspectCodecFormat, + InspectConfigPriority, + InspectIconSize, + InspectIconType, + InspectRedundancyMode, + InspectSdpStrategy, + InspectSipsMode, + InspectVertexKind, + InspectVertexType, +) +from videoipath_automation_tool.apps.inspect.model.virtual import InspectApiVirtualDeviceFields + + +class InspectApiLookupInspectDeviceRequest(InspectApiBaseModel): + header: InspectApiPostRequestHeader = Field(default_factory=InspectApiPostRequestHeader) + data: str + + +class InspectApiAssignedTags(InspectApiBaseModel): + all: list[str] = Field(default_factory=list) + inherited: dict[str, Any] = Field(default_factory=dict) + inheritedConflict: bool = False + local: dict[str, Any] = Field(default_factory=dict) + + +class InspectApiLookupInspectDeviceFields(InspectApiBaseModel): + coordinates: dict[str, float | int] | None = None + descriptor: InspectApiDescriptor + iconSize: InspectIconSize | str | None = None + iconType: InspectIconType | str | None = None + localAssignedTags: list[str] = Field(default_factory=list) + sdpStrategy: InspectSdpStrategy | str | None = None + siteId: str | None = None + tags: list[str] = Field(default_factory=list) + virtualDeviceFields: InspectApiVirtualDeviceFields | None = None + + +class InspectApiLookupInspectDeviceResponseData(InspectApiBaseModel): + assignedTags: InspectApiAssignedTags + fields: InspectApiLookupInspectDeviceFields + + +class InspectApiLookupInspectDeviceResponse(InspectApiBaseModel): + data: InspectApiLookupInspectDeviceResponseData + header: InspectApiRestV2Header + + +# --- Vertex lookup / edit form (also the replaceVertices write shape, verified 2025.4.9) --- + + +class InspectApiCodecGeneric(InspectApiBaseModel): + """The ``typeFields.generic`` block of a codec vertex edit form (verified 2025.4.9). Nested + endpoint blocks are kept as dicts for lossless round-tripping (``extra="allow"`` on the base).""" + + bidirPartnerId: str | None = None + codecFormat: InspectCodecFormat | str | None = None + extraFormats: list[Any] = Field(default_factory=list) + mainDstInfo: dict[str, Any] | None = None + mainSrcInfo: dict[str, Any] | None = None + multiplicity: int | None = None + partnerConfig: Any | None = None + public: bool | None = None + serviceId: Any | None = None + spareDstInfo: dict[str, Any] | None = None + spareSrcInfo: dict[str, Any] | None = None + + +class InspectApiCodecSpecific(InspectApiBaseModel): + """The ``typeFields.specific`` block of a codec vertex edit form (verified 2025.4.9).""" + + isIgmpSource: bool | None = None + sdpSupport: bool | None = None + type: str | None = None + + +class InspectApiVertexTypeFields(InspectApiBaseModel): + ipAddress: str | None = None + ipNetmask: str | None = None + parkPort: int | None = None + public: bool | None = None + supportsCpipeCfg: bool | None = None + supportsIgmpCfg: bool | None = None + supportsMacForwardingCfg: bool | None = None + supportsNsoCfg: bool | None = None + supportsOpenflowCfg: bool | None = None + supportsStaticIgmpCfg: bool | None = None + supportsVlanCfg: bool | None = None + supportsVplsCfg: bool | None = None + type: InspectVertexKind | str | None = None + vlanId: str | None = None + vrfId: str | None = None + # Codec vertices additionally carry ``generic`` / ``specific`` blocks here; they are preserved + # losslessly by ``extra="allow"`` (declaring them would emit ``null`` on non-codec vertices and + # break the byte-for-byte ``replaceVertices`` round-trip) and read back typed via + # ``InspectApiCodecGeneric`` / ``InspectApiCodecSpecific`` in the codec vertex view. + + +class InspectApiVertexControlProps(InspectApiBaseModel): + """Control properties of a controlled vertex (verified against a live 2025.4.9 server).""" + + configPriority: InspectConfigPriority | str | None = None + onlyInitial: bool | None = None + + +class InspectApiVertexEditForm(InspectApiBaseModel): + """The vertex ``fields`` object returned by ``lookupInspectVertexById`` — this exact shape is + what ``replaceVertices`` accepts (update-only; verified 2025.4.9).""" + + active: bool | None = None + controlProps: InspectApiVertexControlProps | None = None + custom: dict[str, Any] = Field(default_factory=dict) + desc: str = "" + destinationMonitorLeader: bool | None = None + extraAlertFilters: list[Any] = Field(default_factory=list) + label: str = "" + localAssignedTags: list[str] = Field(default_factory=list) + queueable: bool | None = None + sipsMode: InspectSipsMode | str | None = None + tags: list[str] = Field(default_factory=list) + typeFields: InspectApiVertexTypeFields | None = None + useAsEndpoint: bool | None = None + + +class InspectApiLookupVertexRequest(InspectApiBaseModel): + header: InspectApiPostRequestHeader = Field(default_factory=InspectApiPostRequestHeader) + data: str + + +class InspectApiLookupVerticesRequest(InspectApiBaseModel): + header: InspectApiPostRequestHeader = Field(default_factory=InspectApiPostRequestHeader) + data: list[str] + + +class InspectApiLookupVertexResponseData(InspectApiBaseModel): + assignedTags: InspectApiAssignedTags | None = None + context: dict[str, Any] | None = None + customSchemas: dict[str, Any] = Field(default_factory=dict) + fields: InspectApiVertexEditForm + id: str + isVirtual: bool | None = None + vertexType: InspectVertexType | str | None = None + + +class InspectApiLookupVertexResponse(InspectApiBaseModel): + data: InspectApiLookupVertexResponseData + header: InspectApiRestV2Header + + +class InspectApiLookupVerticesResponse(InspectApiBaseModel): + data: dict[str, InspectApiLookupVertexResponseData] + header: InspectApiRestV2Header + + +# --- Edge lookup (also the replaceEdges write shape, verified 2025.4.9) --- + + +class InspectApiEdgeForm(InspectApiBaseModel): + """The persisted edge object returned by ``lookupInspectEdgesByIds`` — this exact shape is what + ``replaceEdges`` accepts (verified 2025.4.9). No ``_id`` / ``_rev`` / ``type`` in the write form.""" + + active: bool = True + bandwidth: float | int = -1.0 + capacity: int = 65535 + conflictPri: int | str = 0 + descriptor: InspectApiDescriptor = Field(default_factory=InspectApiDescriptor) + excludeFormats: list[str] = Field(default_factory=list) + fDescriptor: InspectApiDescriptor = Field(default_factory=InspectApiDescriptor) + fromId: str + includeFormats: list[str] = Field(default_factory=list) + redundancyMode: InspectRedundancyMode | str = "Any" + tags: list[str] = Field(default_factory=list) + toId: str + weight: int = 1 + weightFactors: dict[str, Any] = Field( + default_factory=lambda: {"bandwidth": {"weight": 0}, "service": {"max": 100, "weight": 0}} + ) + + +class InspectApiLookupEdgesRequest(InspectApiBaseModel): + header: InspectApiPostRequestHeader = Field(default_factory=InspectApiPostRequestHeader) + data: list[str] + + +class InspectApiLookupEdgeResponseItem(InspectApiBaseModel): + edge: InspectApiEdgeForm + fromDevice: str | None = None + toDevice: str | None = None + + +class InspectApiLookupEdgesResponse(InspectApiBaseModel): + data: dict[str, InspectApiLookupEdgeResponseItem] + header: InspectApiRestV2Header + + +class InspectApiLookupSyncInfoRequest(InspectApiBaseModel): + header: InspectApiPostRequestHeader = Field(default_factory=InspectApiPostRequestHeader) + data: list[str] + + +class InspectApiLookupSyncInfoItem(InspectApiBaseModel): + add: dict[str, Any] = Field(default_factory=dict) + label: str + remove: dict[str, Any] = Field(default_factory=dict) + severity: int | str | None = None + update: dict[str, Any] = Field(default_factory=dict) + + +class InspectApiLookupSyncInfoResponse(InspectApiBaseModel): + data: dict[str, InspectApiLookupSyncInfoItem] + header: InspectApiRestV2Header + + +class InspectApiAddDevicesItem(InspectApiBaseModel): + id: str + x: float | int + y: float | int + + +class InspectApiAddDevicesRequest(InspectApiBaseModel): + header: InspectApiPostRequestHeader = Field(default_factory=InspectApiPostRequestHeader) + data: list[InspectApiAddDevicesItem] + + +class InspectApiSyncDevicesRequestData(InspectApiBaseModel): + ids: list[str] = Field(default_factory=list) + addOnly: bool + conflictStrategy: Literal[0, 1, 2] + + +class InspectApiSyncDevicesRequest(InspectApiBaseModel): + header: InspectApiPostRequestHeader = Field(default_factory=InspectApiPostRequestHeader) + data: InspectApiSyncDevicesRequestData + + +__all__ = [ + "InspectApiAddDevicesItem", + "InspectApiAddDevicesRequest", + "InspectApiAssignedTags", + "InspectApiCodecGeneric", + "InspectApiCodecSpecific", + "InspectApiEdgeForm", + "InspectApiLookupEdgeResponseItem", + "InspectApiLookupEdgesRequest", + "InspectApiLookupEdgesResponse", + "InspectApiLookupInspectDeviceFields", + "InspectApiLookupInspectDeviceRequest", + "InspectApiLookupInspectDeviceResponse", + "InspectApiLookupInspectDeviceResponseData", + "InspectApiLookupSyncInfoItem", + "InspectApiLookupSyncInfoRequest", + "InspectApiLookupSyncInfoResponse", + "InspectApiLookupVerticesRequest", + "InspectApiLookupVerticesResponse", + "InspectApiLookupVertexRequest", + "InspectApiLookupVertexResponse", + "InspectApiLookupVertexResponseData", + "InspectApiSyncDevicesRequest", + "InspectApiSyncDevicesRequestData", + "InspectApiVertexControlProps", + "InspectApiVertexEditForm", + "InspectApiVertexTypeFields", +] diff --git a/src/videoipath_automation_tool/apps/inspect/model/alarms.py b/src/videoipath_automation_tool/apps/inspect/model/alarms.py new file mode 100644 index 0000000..10b3e63 --- /dev/null +++ b/src/videoipath_automation_tool/apps/inspect/model/alarms.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +from typing import Any + +from pydantic import Field, field_validator + +from videoipath_automation_tool.apps.inspect.model.common import ( + InspectApiBaseModel, + InspectApiDescriptor, + InspectSeverity, + map_severity, +) + + +class InspectApiAlarmId(InspectApiBaseModel): + alertId: str | None = None + component: int | None = None + pointId: list[str] = Field(default_factory=list) + + +class InspectApiAlarmDesc(InspectApiBaseModel): + alertId: InspectApiDescriptor | None = None + pointId: list[InspectApiDescriptor] = Field(default_factory=list) + + +class InspectApiAlarmInfo(InspectApiBaseModel): + details: str | None = None + evtType: int | None = None + headId: str | None = None + headSeverity: InspectSeverity | int | str | None = None + headTime: int | None = None + id: str | None = None + links: Any = None + oTime: int | None = None + origin: Any = None + relations: list[Any] = Field(default_factory=list) + sa: InspectSeverity | int | str | None = None + seqno: int | None = None + severity: InspectSeverity | int | str | None = None + time: int | None = None + + @field_validator("headSeverity", "sa", "severity", mode="before") + @classmethod + def _map_severity_fields(cls, value: Any) -> Any: + return map_severity(value) + + +class InspectApiAlarmItem(InspectApiBaseModel): + id_field: str | None = Field(default=None, alias="_id") + vid: str | None = Field(default=None, alias="_vid") + acked: bool | None = None + desc: InspectApiAlarmDesc | None = None + hidden: bool | None = None + history: list[Any] = Field(default_factory=list) + id: InspectApiAlarmId | None = None + info: InspectApiAlarmInfo | None = None + + +__all__ = [ + "InspectApiAlarmDesc", + "InspectApiAlarmId", + "InspectApiAlarmInfo", + "InspectApiAlarmItem", +] diff --git a/src/videoipath_automation_tool/apps/inspect/model/collector.py b/src/videoipath_automation_tool/apps/inspect/model/collector.py new file mode 100644 index 0000000..2eedeb8 --- /dev/null +++ b/src/videoipath_automation_tool/apps/inspect/model/collector.py @@ -0,0 +1,402 @@ +from __future__ import annotations + +from typing import Any, Literal + +from pydantic import Field, field_validator + +from videoipath_automation_tool.apps.inspect.model.common import ( + InspectApiBaseModel, + InspectApiCollection, + InspectApiDescriptor, + InspectApiEndpointStatus, + InspectApiRestV2Header, + InspectApiStatusContext, + InspectIconSize, + InspectIconType, + InspectSdpStrategy, + InspectServiceStatus, + InspectApiStatusSummary, + InspectSeverity, + InspectVertexType, + map_severity, +) + + +class InspectApiGenericServiceFields(InspectApiBaseModel): + allocationState: int | None = None + cancelTime: str | None = None + descriptor: InspectApiDescriptor | None = None + locked: bool | None = None + state: int | None = None + tags: list[str] = Field(default_factory=list) + + +class InspectApiPathServiceFields(InspectApiBaseModel): + bid: str + ctype: int | None = None + formatSubState: int | None = None + from_: str | None = Field(default=None, alias="from") + fromLabel: str | None = None + fromPid: str | None = None + fromStatus: InspectApiStatusSummary | None = None + generic: InspectApiGenericServiceFields | None = None + isMain: bool | None = None + serviceStatus: InspectServiceStatus | None = None + to: str | None = None + toLabel: str | None = None + toPid: str | None = None + toStatus: InspectApiStatusSummary | None = None + + +class InspectApiPathStructure(InspectApiBaseModel): + deviceId: str | None = None + deviceLabel: str | None = None + devicePid: str | None = None + expectConfig: bool | None = None + inputStatus: InspectApiEndpointStatus | None = None + moduleAndDeviceStatus: InspectApiStatusSummary | None = None + outputStatus: InspectApiEndpointStatus | None = None + + +class InspectApiPathSegment(InspectApiBaseModel): + bid: str + ipDesc: str | None = None + structure: InspectApiPathStructure | None = None + + +class InspectApiPathItem(InspectApiBaseModel): + id: str = Field(alias="_id") + vid: str = Field(alias="_vid") + path: list[InspectApiPathSegment] = Field(default_factory=list) + serviceFields: InspectApiPathServiceFields + + +class InspectApiNodeMeta(InspectApiBaseModel): + coordinates: dict[str, float | int | str | None] | None = None + hwPanelType: str | None = None + iconSize: InspectIconSize | str | None = None + iconType: InspectIconType | str | None = None + isCore: bool | None = None + isVirtual: bool | None = None + sdpStrategy: InspectSdpStrategy | str | None = None + siteId: str | None = None + tags: list[str] = Field(default_factory=list) + + +class InspectApiVertexInfoFields(InspectApiBaseModel): + isActive: bool | None = None + isControlled: bool | None = None + isEndpoint: bool | None = None + + +class InspectApiSingleVertexInfo(InspectApiBaseModel): + type: Literal["single"] = "single" + id: str | None = None + label: str | None = None + vertexType: InspectVertexType | str | None = None + fields: InspectApiVertexInfoFields | None = None + + +class InspectApiDoubleVertexInfo(InspectApiBaseModel): + type: Literal["double"] = "double" + in_: InspectApiSingleVertexInfo | None = Field(default=None, alias="in") + out: InspectApiSingleVertexInfo | None = None + + +class InspectApiPathDescriptionItem(InspectApiBaseModel): + bookingId: str | None = None + deviceLevel: dict[str, Any] | None = None + fromStatus: InspectApiStatusSummary | None = None + isMain: bool | None = None + serviceLabel: str | None = None + serviceLevel: dict[str, Any] | None = None + serviceStatus: InspectServiceStatus | InspectApiStatusSummary | None = None + toStatus: InspectApiStatusSummary | None = None + + +class InspectPortStatus(InspectApiBaseModel): + id: str | None = Field(default=None, alias="_id") + vid: str | None = Field(default=None, alias="_vid") + context: InspectApiStatusContext | None = None + descriptor: InspectApiDescriptor | None = None + label: str | None = None + pid: str | None = None + relatedNodeTags: list[str] = Field(default_factory=list) + resourceId: str | None = None + status: InspectApiStatusSummary | None = None + tagsInfo: dict[str, Any] | None = None + vertexInfo: InspectApiSingleVertexInfo | InspectApiDoubleVertexInfo | dict[str, Any] | None = None + pathDescriptions: dict[str, InspectApiPathDescriptionItem] = Field(default_factory=dict) + + @property + def assigned_tags(self) -> list[str]: + """Effective tags assigned to this port (from ``tagsInfo.assigned.all``).""" + if not self.tagsInfo: + return [] + assigned = self.tagsInfo.get("assigned") + if isinstance(assigned, dict) and isinstance(assigned.get("all"), list): + return list(assigned["all"]) + return [] + + @property + def effective_label(self) -> str | None: + if self.descriptor is not None and self.descriptor.label: + return self.descriptor.label + return self.label + + @property + def effective_description(self) -> str | None: + """The user-set port description (``descriptor.desc``), if any.""" + if self.descriptor is not None and self.descriptor.desc: + return self.descriptor.desc + return None + + @property + def parsed_vertex_info(self) -> InspectApiSingleVertexInfo | InspectApiDoubleVertexInfo | None: + """``vertexInfo`` coerced to its typed form (handles the raw-dict fallback branch).""" + vertex_info = self.vertexInfo + if isinstance(vertex_info, (InspectApiSingleVertexInfo, InspectApiDoubleVertexInfo)): + return vertex_info + if isinstance(vertex_info, dict): + model = {"single": InspectApiSingleVertexInfo, "double": InspectApiDoubleVertexInfo}.get( + vertex_info.get("type", "") + ) + if model is not None: + try: + return model.model_validate(vertex_info) + except ValueError: + return None + return None + + +class InspectApiModuleStatus(InspectApiBaseModel): + id: str | None = Field(default=None, alias="_id") + vid: str | None = Field(default=None, alias="_vid") + context: InspectApiStatusContext | None = None + descriptor: InspectApiDescriptor | None = None + label: str | None = None + pid: str | None = None + ports: dict[str, InspectPortStatus] | list[InspectPortStatus] = Field(default_factory=dict) + status: InspectApiStatusSummary | None = None + tagsInfo: dict[str, Any] | None = None + + @property + def assigned_tags(self) -> list[str]: + """Effective tags assigned to this module (from ``tagsInfo.assigned.all``).""" + if not self.tagsInfo: + return [] + assigned = self.tagsInfo.get("assigned") + if isinstance(assigned, dict) and isinstance(assigned.get("all"), list): + return list(assigned["all"]) + return [] + + @property + def local_assigned_tags(self) -> list[str]: + """Locally bound tags on this module (keys of ``tagsInfo.assigned.local``).""" + if not self.tagsInfo: + return [] + assigned = self.tagsInfo.get("assigned") + if not isinstance(assigned, dict): + return [] + local = assigned.get("local") + if isinstance(local, dict): + return list(local.keys()) + return [] + + @property + def effective_label(self) -> str | None: + if self.descriptor is not None and self.descriptor.label: + return self.descriptor.label + return self.label + + @property + def effective_description(self) -> str | None: + """The user-set module description (``descriptor.desc``), if any.""" + if self.descriptor is not None and self.descriptor.desc: + return self.descriptor.desc + return None + + +class InspectApiNodeStatusItem(InspectApiBaseModel): + id: str = Field(alias="_id") + vid: str | None = Field(default=None, alias="_vid") + context: InspectApiStatusContext | None = None + descriptor: InspectApiDescriptor | None = None + deviceId: str | None = None + fDescriptor: InspectApiDescriptor | None = None + hasEndpoints: bool | None = None + label: str | None = None + meta: InspectApiNodeMeta | None = None + modules: dict[str, InspectApiModuleStatus] | list[InspectApiModuleStatus] = Field(default_factory=dict) + pathDescriptions: dict[str, InspectApiPathDescriptionItem] = Field(default_factory=dict) + pid: str | None = None + ptpDeviceStatus: dict[str, Any] | None = None + relatedNodeTags: list[str] = Field(default_factory=list) + resourceId: str | None = None + status: InspectApiStatusSummary | None = None + syncSeverity: InspectSeverity | int | str | None = None + tags: list[str] = Field(default_factory=list) + tagsInfo: dict[str, Any] | None = None + + @field_validator("syncSeverity", mode="before") + @classmethod + def _map_sync_severity(cls, value: Any) -> Any: + return map_severity(value) + + @property + def effective_label(self) -> str | None: + """The label the UI shows: user ``descriptor.label``, falling back to the device-reported + ``fDescriptor.label`` (and finally the legacy top-level ``label`` field, if present).""" + if self.descriptor is not None and self.descriptor.label: + return self.descriptor.label + if self.fDescriptor is not None and self.fDescriptor.label: + return self.fDescriptor.label + return self.label + + @property + def effective_description(self) -> str | None: + """The description the UI shows: user ``descriptor.desc``, falling back to the + device-reported ``fDescriptor.desc``.""" + if self.descriptor is not None and self.descriptor.desc: + return self.descriptor.desc + if self.fDescriptor is not None and self.fDescriptor.desc: + return self.fDescriptor.desc + return None + + @property + def coordinates(self) -> dict[str, float | int | str | None] | None: + return self.meta.coordinates if self.meta is not None else None + + +class InspectApiExternalEdgeLiveStatus(InspectApiBaseModel): + alarm: InspectSeverity | int | str | None = None + bandwidth: InspectSeverity | int | float | str | None = None + maintenance: InspectSeverity | int | str | None = None + ptp: InspectSeverity | int | str | None = None + + @field_validator("alarm", "bandwidth", "maintenance", "ptp", mode="before") + @classmethod + def _map_severity_fields(cls, value: Any) -> Any: + return map_severity(value) + + +class InspectApiExternalEdgeStatus(InspectApiBaseModel): + bandwidth: float | int | None = None + fromStatus: InspectApiEndpointStatus | None = None + id: str + maxBandwidth: float | int | None = None + pathDescriptions: dict[str, InspectApiPathDescriptionItem] = Field(default_factory=dict) + ratio: float | int | None = None + status: InspectApiExternalEdgeLiveStatus | None = None + toStatus: InspectApiEndpointStatus | None = None + + +class InspectApiExternalEdgeSide(InspectApiBaseModel): + data: dict[str, InspectApiExternalEdgeStatus] = Field(default_factory=dict) + devicePid: str | None = None + label: str | None = None + + +class InspectApiExternalEdgesByDeviceKeyItem(InspectApiBaseModel): + id: str = Field(alias="_id") + vid: str = Field(alias="_vid") + primary: InspectApiExternalEdgeSide + secondary: InspectApiExternalEdgeSide + status: InspectApiExternalEdgeLiveStatus | None = None + + +class InspectApiMaintenanceBookingItem(InspectApiBaseModel): + id: str = Field(alias="_id") + vid: str | None = Field(default=None, alias="_vid") + + +class InspectApiSuperProfileItem(InspectApiBaseModel): + id: str = Field(alias="_id") + vid: str | None = Field(default=None, alias="_vid") + + +class InspectApiTagInfoItem(InspectApiBaseModel): + id: str = Field(alias="_id") + vid: str | None = Field(default=None, alias="_vid") + + +class InspectApiCollectorInspect(InspectApiBaseModel): + nodeStatus: InspectApiCollection = Field(default_factory=InspectApiCollection) + paths: InspectApiCollection = Field(default_factory=InspectApiCollection) + + @property + def node_status_items(self) -> list[InspectApiNodeStatusItem]: + return [InspectApiNodeStatusItem.model_validate(item) for item in self.nodeStatus.items] + + @property + def path_items(self) -> list[InspectApiPathItem]: + return [InspectApiPathItem.model_validate(item) for item in self.paths.items] + + +class InspectApiCollector(InspectApiBaseModel): + inspect: InspectApiCollectorInspect = Field(default_factory=InspectApiCollectorInspect) + externalEdgesByDeviceKey: InspectApiCollection = Field(default_factory=InspectApiCollection) + maintenanceBookings: InspectApiCollection = Field(default_factory=InspectApiCollection) + security: dict[str, Any] = Field(default_factory=dict) + superProfiles: InspectApiCollection = Field(default_factory=InspectApiCollection) + tagInfo: InspectApiCollection = Field(default_factory=InspectApiCollection) + + @property + def external_edges_by_device_key_items(self) -> list[InspectApiExternalEdgesByDeviceKeyItem]: + return [ + InspectApiExternalEdgesByDeviceKeyItem.model_validate(item) for item in self.externalEdgesByDeviceKey.items + ] + + @property + def maintenance_booking_items(self) -> list[InspectApiMaintenanceBookingItem]: + return [InspectApiMaintenanceBookingItem.model_validate(item) for item in self.maintenanceBookings.items] + + @property + def super_profile_items(self) -> list[InspectApiSuperProfileItem]: + return [InspectApiSuperProfileItem.model_validate(item) for item in self.superProfiles.items] + + @property + def tag_info_items(self) -> list[InspectApiTagInfoItem]: + return [InspectApiTagInfoItem.model_validate(item) for item in self.tagInfo.items] + + +class InspectApiCollectorStatus(InspectApiBaseModel): + collector: InspectApiCollector = Field(default_factory=InspectApiCollector) + + +class InspectApiCollectorResponseData(InspectApiBaseModel): + status: InspectApiCollectorStatus + + +class InspectApiCollectorResponse(InspectApiBaseModel): + data: InspectApiCollectorResponseData + header: InspectApiRestV2Header + + +__all__ = [ + "InspectApiCollector", + "InspectApiCollectorInspect", + "InspectApiCollectorResponse", + "InspectApiCollectorResponseData", + "InspectApiCollectorStatus", + "InspectApiDoubleVertexInfo", + "InspectApiExternalEdgeLiveStatus", + "InspectApiExternalEdgeSide", + "InspectApiExternalEdgeStatus", + "InspectApiExternalEdgesByDeviceKeyItem", + "InspectApiGenericServiceFields", + "InspectApiMaintenanceBookingItem", + "InspectApiModuleStatus", + "InspectApiNodeMeta", + "InspectApiNodeStatusItem", + "InspectApiPathDescriptionItem", + "InspectApiPathItem", + "InspectApiPathSegment", + "InspectApiPathServiceFields", + "InspectApiPathStructure", + "InspectPortStatus", + "InspectApiSingleVertexInfo", + "InspectApiSuperProfileItem", + "InspectApiTagInfoItem", + "InspectApiVertexInfoFields", +] diff --git a/src/videoipath_automation_tool/apps/inspect/model/common.py b/src/videoipath_automation_tool/apps/inspect/model/common.py new file mode 100644 index 0000000..14ccf11 --- /dev/null +++ b/src/videoipath_automation_tool/apps/inspect/model/common.py @@ -0,0 +1,298 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from collections.abc import Callable +from enum import IntEnum +from typing import TYPE_CHECKING, Any, Literal, TypeVar + +from pydantic import BaseModel, ConfigDict, Field, field_validator + +if TYPE_CHECKING: + from videoipath_automation_tool.apps.inspect.snapshot import InspectSnapshot + +# Sentinel returned by :meth:`InspectSnapshot.get_staged_value` / :meth:`InspectEditableModel._staged` +# when no pending edit exists for a field. +_STAGED_MISSING: Any = object() + +_T = TypeVar("_T") + + +class InspectSeverity(IntEnum): + """VideoIPath status/alarm severity. Int-valued so ``== N``, ``int(x)``, and ordering still + work; ``str(x)`` / ``.label`` give the human label. Unknown wire codes are kept as raw ints + by the model validators (never crash). + + Scale derived from VideoIPath 2026.2.0 (built-in correlation templates + live alarms + sync + info); higher is worse. + """ + + NONE = 0 + OK = 1 + NOTICE = 2 + WARNING = 3 + MINOR = 4 + MAJOR = 5 + CRITICAL = 6 + + def __str__(self) -> str: + return self.label + + @property + def label(self) -> str: + return _SEVERITY_LABELS[self] + + +def map_severity(raw: Any) -> InspectSeverity | int | str | None: + """Known int → :class:`InspectSeverity`; ``None`` / str / unknown int pass through unchanged.""" + if isinstance(raw, InspectSeverity): + return raw + if isinstance(raw, int) and not isinstance(raw, bool) and raw in _SEVERITY_LABELS: + return InspectSeverity(raw) + return raw + + +# The icon types selectable in the VideoIPath UI (mirrors the topology app's ``IconType``; live data +# may contain further values, so read/write surfaces use the permissive ``InspectIconType | str``). +InspectIconType = Literal[ + "default", + "none", + "device", + "camera", + "monitor", + "encoder", + "decoder", + "audioMixer", + "videoMixer", + "processingDevice", + "transportStreamProcessor", + "mediaDevice", + "server", + "gateway", + "ipSwitchRouter", + "vlanCloud", + "videoAudioRouterMatrix", + "encoderDecoder", +] + +# Device icon size selectable in the UI (mirrors the topology app's ``IconSize``). +InspectIconSize = Literal["auto", "large", "medium", "small"] + +# SDP polling strategy: "always" (Continuous), "once" (Fetch and Confirm), "video" (Continuous +# Video, Confirm Others). Mirrors the topology app's ``SdpStrategy``. +InspectSdpStrategy = Literal["always", "once", "video"] + +# Vertex direction as reported in ``vertexInfo.vertexType`` (a "double" vertexInfo is the +# bidirectional case and is surfaced as "BiDirectional"). +InspectVertexType = Literal["BiDirectional", "In", "Internal", "Out", "Undecided"] + +# Vertex kind from the vertex edit form's ``typeFields.type``. "ip", "codec" and "router" are +# verified against a live 2025.4.9 server; "generic" is inferred from the nGraph element types. +# Read surfaces use the permissive ``InspectVertexKind | str`` for unknown future kinds. +InspectVertexKind = Literal["generic", "ip", "codec", "router"] + +# Edge redundancy mode (mirrors the topology app's ``RedundancyMode``). +InspectRedundancyMode = Literal["Any", "OnlyMain", "OnlySpare"] + +# Conflict priority for edges (``conflictPri``) and vertex control (``controlProps.configPriority``). +# The UI labels are off/high/normal/low; the edge form carries the priority as an int on the wire +# (verified 2025.4.9), so read/write surfaces convert with the mappings below. +InspectConfigPriority = Literal["off", "high", "normal", "low"] +CONFLICT_PRIORITY_TO_INT: dict[str, int] = {"off": 0, "high": 1, "normal": 2, "low": 3} +CONFLICT_PRIORITY_BY_INT: dict[int, str] = {value: name for name, value in CONFLICT_PRIORITY_TO_INT.items()} + +# SIPS mode on vertices (mirrors the topology app's ``SipsMode``). +InspectSipsMode = Literal["NONE", "SIPSAuto", "SIPSDuplicate", "SIPSMerge", "SIPSSplit"] + +# Vertex control level (mirrors the topology app's ``Control``). +InspectControl = Literal["full", "off", "semi"] + +# Codec format on codec vertices (mirrors the topology app's ``CodecFormat``). +InspectCodecFormat = Literal["Video", "Audio", "ASI", "Ancillary"] + +# Map coordinate type on nGraph map elements (mirrors topology ``cType``). +InspectMapCType = Literal["Topology", "Geo"] + + +def format_repr(obj: object, /, **fields: Any) -> str: + """Concise ``ClassName(k=v, ...)`` repr. Callable values are evaluated defensively + (skipped on error); None values are omitted. Never raises.""" + parts: list[str] = [] + for key, value in fields.items(): + if callable(value): + try: + value = value() + except Exception: + continue + if value is None: + continue + parts.append(f"{key}={value!r}") + return f"{type(obj).__name__}({', '.join(parts)})" + + +class InspectApiBaseModel(BaseModel): + model_config = ConfigDict(populate_by_name=True, validate_assignment=True, extra="allow") + + +class InspectInternalModel(BaseModel): + model_config = ConfigDict(validate_assignment=True, arbitrary_types_allowed=True) + + +class InspectFrozenModel(InspectInternalModel): + model_config = ConfigDict(frozen=True, slots=True, validate_assignment=True, arbitrary_types_allowed=True) + + +class InspectEditableModel(InspectInternalModel, ABC): + """Mutable domain view: identity fields are set at construction; editable attributes are + exposed as property setters that stage pending edits on the snapshot (read-your-writes). + + Subclasses must provide ``snapshot``, ``id``, and :attr:`_edit_kind`. Staging helpers + (:meth:`_stage` / :meth:`_staged`) are shared here. + """ + + model_config = ConfigDict(validate_assignment=True, arbitrary_types_allowed=True) + + snapshot: InspectSnapshot + + @property + @abstractmethod + def _edit_kind(self) -> Literal["device", "vertex", "edge", "module"]: + """Snapshot staging namespace for this entity (``device`` / ``vertex`` / ``edge`` / ``module``).""" + + def _stage(self, field: str, value: Any) -> None: + self.snapshot.stage_edit(self._edit_kind, self.id, field, value) + + def _staged(self, field: str) -> Any: + return self.snapshot.get_staged_value(self._edit_kind, self.id, field) + + def _staged_or( + self, + field: str, + fallback: Callable[[], _T], + *, + adapt: Callable[[Any], _T] | None = None, + ) -> _T: + """Return the staged value for ``field``, else ``fallback()``. + + When a staged value exists and ``adapt`` is given, ``adapt(staged)`` is returned (e.g. + ``list`` / ``dict`` for defensive copies). + """ + staged = self._staged(field) + if staged is not _STAGED_MISSING: + return adapt(staged) if adapt is not None else staged + return fallback() + + +class InspectApiDescriptor(InspectApiBaseModel): + desc: str = "" + label: str = "" + + +class InspectApiCollection(InspectApiBaseModel): + items: list[dict[str, Any]] = Field(default_factory=list, alias="_items") + + +class InspectApiStatusSummary(InspectApiBaseModel): + sa: InspectSeverity | int | str | None = None + severity: InspectSeverity | int | str | None = None + + @field_validator("sa", "severity", mode="before") + @classmethod + def _map_severity_fields(cls, value: Any) -> Any: + return map_severity(value) + + +class InspectApiStatusContext(InspectApiBaseModel): + devicePid: str | None = None + modulePid: str | None = None + portPid: str | None = None + + +class InspectApiEndpointStatus(InspectApiBaseModel): + context: InspectApiStatusContext | None = None + label: str | None = None + pid: str | None = None + status: InspectApiStatusSummary | None = None + + +class InspectServiceStatus(InspectApiBaseModel): + config: InspectApiStatusSummary | None = None + total: InspectApiStatusSummary | None = None + + +class InspectApiRestV2Header(InspectApiBaseModel): + auth: bool + caption: str + code: str + errorCodes: list[Any] = Field(default_factory=list) + errorDetails: list[Any] = Field(default_factory=list) + id: str + msg: list[str] = Field(default_factory=list) + ok: bool + user: str + + +class InspectApiPostRequestHeader(InspectApiBaseModel): + id: int = 0 + + +class InspectApiSimpleActionResult(InspectApiBaseModel): + msg: list[str] = Field(default_factory=list) + ok: bool + + +class InspectApiSimpleActionResponse(InspectApiBaseModel): + data: InspectApiSimpleActionResult + header: InspectApiRestV2Header + + +class InspectApiActionValidationErrorResponse(InspectApiBaseModel): + header: InspectApiRestV2Header + + +# --- Internal --- + +_SEVERITY_LABELS: dict[InspectSeverity, str] = { + InspectSeverity.NONE: "None", + InspectSeverity.OK: "OK", + InspectSeverity.NOTICE: "Notice", + InspectSeverity.WARNING: "Warning", + InspectSeverity.MINOR: "Minor", + InspectSeverity.MAJOR: "Major", + InspectSeverity.CRITICAL: "Critical", +} + + +__all__ = [ + "InspectApiActionValidationErrorResponse", + "InspectApiBaseModel", + "InspectEditableModel", + "InspectFrozenModel", + "InspectInternalModel", + "InspectApiCollection", + "InspectApiDescriptor", + "InspectApiEndpointStatus", + "InspectApiPostRequestHeader", + "InspectApiRestV2Header", + "InspectServiceStatus", + "InspectApiSimpleActionResponse", + "InspectApiSimpleActionResult", + "InspectApiStatusContext", + "InspectApiStatusSummary", + "CONFLICT_PRIORITY_BY_INT", + "CONFLICT_PRIORITY_TO_INT", + "InspectCodecFormat", + "InspectConfigPriority", + "InspectControl", + "InspectIconSize", + "InspectIconType", + "InspectMapCType", + "InspectRedundancyMode", + "InspectSdpStrategy", + "InspectSeverity", + "InspectSipsMode", + "InspectVertexKind", + "InspectVertexType", + "format_repr", + "map_severity", + "_STAGED_MISSING", +] diff --git a/src/videoipath_automation_tool/apps/inspect/model/ngraph.py b/src/videoipath_automation_tool/apps/inspect/model/ngraph.py new file mode 100644 index 0000000..f1d816d --- /dev/null +++ b/src/videoipath_automation_tool/apps/inspect/model/ngraph.py @@ -0,0 +1,157 @@ +from __future__ import annotations + +from typing import Any, Literal + +from pydantic import Field + +from videoipath_automation_tool.apps.inspect.model.common import ( + InspectApiBaseModel, + InspectApiDescriptor, + InspectCodecFormat, + InspectConfigPriority, + InspectControl, + InspectIconSize, + InspectIconType, + InspectMapCType, + InspectRedundancyMode, + InspectSdpStrategy, + InspectSipsMode, + InspectVertexType, +) + + +InspectApiNGraphElementType = Literal[ + "baseDevice", + "codecVertex", + "genericVertex", + "ipVertex", + "nGraphResourceTransform", + "unidirectionalEdge", +] + + +class InspectApiGpid(InspectApiBaseModel): + component: int | None = None + pointId: list[str] = Field(default_factory=list) + + +class InspectApiMapElement(InspectApiBaseModel): + cType: InspectMapCType = "Topology" + id: str = "" + name: str = "" + visible: bool = True + x: float = 0.0 + y: float = 0.0 + + +class InspectApiNGraphElement(InspectApiBaseModel): + id: str = Field(alias="_id") + rev: str | None = Field(default=None, alias="_rev") + vid: str | None = Field(default=None, alias="_vid") + descriptor: InspectApiDescriptor = Field(default_factory=InspectApiDescriptor) + fDescriptor: InspectApiDescriptor = Field(default_factory=InspectApiDescriptor) + tags: list[str] = Field(default_factory=list) + type: InspectApiNGraphElementType + + +class InspectApiBaseDevice(InspectApiNGraphElement): + type: Literal["baseDevice"] = "baseDevice" + iconSize: InspectIconSize | str = "medium" + iconType: InspectIconType | str = "default" + isVirtual: bool = False + maps: list[InspectApiMapElement] = Field(default_factory=list) + sdpStrategy: InspectSdpStrategy | str = "always" + siteId: str | None = None + + +class InspectApiVertex(InspectApiNGraphElement): + deviceId: str + gpid: InspectApiGpid | None = None + configPriority: InspectConfigPriority | str | int | None = None + control: InspectControl | str | int | None = None + custom: dict[str, Any] = Field(default_factory=dict) + extraAlertFilters: list[Any] = Field(default_factory=list) + imgUrl: str | None = None + isVirtual: bool = False + maps: list[InspectApiMapElement] = Field(default_factory=list) + sipsMode: InspectSipsMode | str | None = None + useAsEndpoint: bool | None = None + vertexType: InspectVertexType | str | None = None + + +class InspectApiIpVertex(InspectApiVertex): + type: Literal["ipVertex"] = "ipVertex" + ipAddress: str | None = None + ipNetmask: str | None = None + public: bool | None = None + supportsCpipeCfg: bool | None = None + supportsIgmpCfg: bool | None = None + supportsMacForwardingCfg: bool | None = None + supportsNsoCfg: bool | None = None + supportsOpenflowCfg: bool | None = None + supportsStaticIgmpCfg: bool | None = None + supportsVlanCfg: bool | None = None + supportsVplsCfg: bool | None = None + vlanId: str | None = None + vrfId: str | None = None + + +class InspectApiCodecVertex(InspectApiVertex): + type: Literal["codecVertex"] = "codecVertex" + codecFormat: InspectCodecFormat | str | None = None + codecType: str | None = None + + +class InspectApiGenericVertex(InspectApiVertex): + type: Literal["genericVertex"] = "genericVertex" + + +class InspectApiWeightFactorBandwidth(InspectApiBaseModel): + weight: int = 0 + + +class InspectApiWeightFactorService(InspectApiBaseModel): + max: int = 100 + weight: int = 0 + + +class InspectApiWeightFactors(InspectApiBaseModel): + bandwidth: InspectApiWeightFactorBandwidth = Field(default_factory=InspectApiWeightFactorBandwidth) + service: InspectApiWeightFactorService = Field(default_factory=InspectApiWeightFactorService) + + +class InspectApiUnidirectionalEdge(InspectApiNGraphElement): + type: Literal["unidirectionalEdge"] = "unidirectionalEdge" + active: bool = True + bandwidth: float | int = -1.0 + capacity: int = 65535 + conflictPri: int | str = 0 + excludeFormats: list[str] = Field(default_factory=list) + fromId: str + includeFormats: list[str] = Field(default_factory=list) + redundancyMode: InspectRedundancyMode | str = "Any" + toId: str + weight: int = 0 + weightFactors: InspectApiWeightFactors = Field(default_factory=InspectApiWeightFactors) + + +class InspectApiNGraphResourceTransform(InspectApiNGraphElement): + type: Literal["nGraphResourceTransform"] = "nGraphResourceTransform" + + +__all__ = [ + "InspectApiBaseDevice", + "InspectApiCodecVertex", + "InspectApiGenericVertex", + "InspectApiGpid", + "InspectApiIpVertex", + "InspectApiMapElement", + "InspectApiNGraphElement", + "InspectApiNGraphElementType", + "InspectApiNGraphResourceTransform", + "InspectApiUnidirectionalEdge", + "InspectApiVertex", + "InspectApiWeightFactorBandwidth", + "InspectApiWeightFactorService", + "InspectApiWeightFactors", +] diff --git a/src/videoipath_automation_tool/apps/inspect/model/tags.py b/src/videoipath_automation_tool/apps/inspect/model/tags.py new file mode 100644 index 0000000..fc9b565 --- /dev/null +++ b/src/videoipath_automation_tool/apps/inspect/model/tags.py @@ -0,0 +1,38 @@ +"""Tag assign / unassign action envelopes (``/rest/v2/actions/status/tags/*``). + +Used for module (and potentially other resource) tag bindings that are not written via +``updateTopology``. +""" + +from __future__ import annotations + +from pydantic import Field + +from videoipath_automation_tool.apps.inspect.model.common import ( + InspectApiBaseModel, + InspectApiPostRequestHeader, +) + + +def module_resource_id(module_pid: str) -> str: + """Collector resource id for a module pid (``device57.dev.0`` → ``device:device57.dev.0``).""" + if module_pid.startswith("device:"): + return module_pid + return f"device:{module_pid}" + + +class InspectApiAssignTagData(InspectApiBaseModel): + tagId: str + elementIds: list[str] = Field(default_factory=list) + + +class InspectApiAssignTagRequest(InspectApiBaseModel): + header: InspectApiPostRequestHeader = Field(default_factory=InspectApiPostRequestHeader) + data: InspectApiAssignTagData + + +__all__ = [ + "InspectApiAssignTagData", + "InspectApiAssignTagRequest", + "module_resource_id", +] diff --git a/src/videoipath_automation_tool/apps/inspect/model/update_topology.py b/src/videoipath_automation_tool/apps/inspect/model/update_topology.py new file mode 100644 index 0000000..f1aa9c9 --- /dev/null +++ b/src/videoipath_automation_tool/apps/inspect/model/update_topology.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +from typing import Any + +from pydantic import Field + +from videoipath_automation_tool.apps.inspect.model.actions import ( + InspectApiEdgeForm, + InspectApiLookupInspectDeviceFields, + InspectApiVertexEditForm, +) +from videoipath_automation_tool.apps.inspect.model.common import ( + InspectApiBaseModel, + InspectApiPostRequestHeader, + InspectApiRestV2Header, +) +from videoipath_automation_tool.apps.inspect.model.ngraph import ( + InspectApiNGraphResourceTransform, +) + + +# The verified per-kind write shapes (2025.4.9): +# - replaceDevices takes the device *edit form* (lookupInspectDevice.fields); the raw baseDevice +# element is rejected (coordinates + localAssignedTags are mandatory). +# - replaceVertices takes the vertex *edit form* (lookupInspectVertexById.fields); update-only. +# - replaceEdges takes the raw persisted edge form (lookupInspectEdgesByIds). +# dict fallbacks keep the models permissive for hand-built payloads. +InspectApiReplaceDevice = InspectApiLookupInspectDeviceFields | dict[str, Any] +InspectApiReplaceVertex = InspectApiVertexEditForm | dict[str, Any] +InspectApiReplaceEdge = InspectApiEdgeForm | dict[str, Any] +InspectApiReplaceResourceTransform = InspectApiNGraphResourceTransform | dict[str, Any] + + +class InspectApiUpdateTopologyData(InspectApiBaseModel): + replaceDevices: dict[str, InspectApiReplaceDevice] = Field(default_factory=dict) + replaceVertices: dict[str, InspectApiReplaceVertex] = Field(default_factory=dict) + replaceEdges: dict[str, InspectApiReplaceEdge] = Field(default_factory=dict) + replaceResourceTransforms: dict[str, InspectApiReplaceResourceTransform] = Field(default_factory=dict) + addExternalEdges: list[InspectApiEdgeForm | dict[str, Any]] = Field(default_factory=list) + remove: list[str] = Field(default_factory=list) + force: bool = False + + +class InspectApiUpdateTopologyRequest(InspectApiBaseModel): + header: InspectApiPostRequestHeader = Field(default_factory=InspectApiPostRequestHeader) + data: InspectApiUpdateTopologyData = Field(default_factory=InspectApiUpdateTopologyData) + + +class InspectApiUpdateTopologyResult(InspectApiBaseModel): + msg: list[str] = Field(default_factory=list) + ok: bool + + +class InspectApiUpdateTopologyValidationDetail(InspectApiBaseModel): + isCancel: bool | None = None + isProduct: bool | None = None + resolvable: bool | None = None + rev: str | None = None + status: int | str | None = None + type: str | None = None + + +class InspectApiUpdateTopologyValidation(InspectApiBaseModel): + createIds: list[str] = Field(default_factory=list) + details: dict[str, InspectApiUpdateTopologyValidationDetail] = Field(default_factory=dict) + result: InspectApiUpdateTopologyResult + + +class InspectApiUpdateTopologyResponseData(InspectApiBaseModel): + items: list[dict[str, Any]] = Field(default_factory=list) + res: InspectApiUpdateTopologyResult + validation: InspectApiUpdateTopologyValidation + + +class InspectApiUpdateTopologyResponse(InspectApiBaseModel): + data: InspectApiUpdateTopologyResponseData + header: InspectApiRestV2Header + + @property + def committed(self) -> bool: + return self.header.ok and self.data.res.ok and self.data.validation.result.ok + + +__all__ = [ + "InspectApiReplaceDevice", + "InspectApiReplaceEdge", + "InspectApiReplaceResourceTransform", + "InspectApiReplaceVertex", + "InspectApiUpdateTopologyData", + "InspectApiUpdateTopologyRequest", + "InspectApiUpdateTopologyResponse", + "InspectApiUpdateTopologyResponseData", + "InspectApiUpdateTopologyResult", + "InspectApiUpdateTopologyValidation", + "InspectApiUpdateTopologyValidationDetail", +] diff --git a/src/videoipath_automation_tool/apps/inspect/model/virtual.py b/src/videoipath_automation_tool/apps/inspect/model/virtual.py new file mode 100644 index 0000000..b38ac80 --- /dev/null +++ b/src/videoipath_automation_tool/apps/inspect/model/virtual.py @@ -0,0 +1,173 @@ +"""Wire models for Inspect virtual devices and port templates (verified 2025.4.9). + +The VideoIPath UI creates topology virtual devices via network actions +(``updateVirtualInstances``, ``updateVirtualTemplates``, ``addVirtualTopology``). +After create, placement / metadata / edges / removal use the same collector +``updateTopology`` path as physical devices. Status reads use +``status/network/virtualDevices`` and ``status/network/virtualTemplates``. +""" + +from __future__ import annotations + +from typing import Any + +from pydantic import Field + +from videoipath_automation_tool.apps.inspect.model.common import ( + InspectApiBaseModel, + InspectApiPostRequestHeader, + InspectApiRestV2Header, + InspectApiSimpleActionResult, + InspectCodecFormat, + InspectConfigPriority, + InspectControl, + InspectSipsMode, + InspectVertexType, +) +from videoipath_automation_tool.apps.inspect.model.ngraph import InspectApiNGraphElementType + + +class InspectApiVirtualPortFromTemplate(InspectApiBaseModel): + """One port instantiation from a port template (wire: ``vertices[]`` entry).""" + + templateId: str + count: int = 1 + + +class InspectApiVirtualModule(InspectApiBaseModel): + """A module on a virtual device definition (wire shape shared by reads and writes).""" + + moduleNumber: int | None = None + vertices: list[InspectApiVirtualPortFromTemplate] = Field(default_factory=list) + + +class InspectApiVirtualDeviceFields(InspectApiBaseModel): + """``lookupInspectDevice.fields.virtualDeviceFields`` (verified 2025.4.9).""" + + dynamic: list[InspectApiVirtualModule] = Field(default_factory=list) + manual: list[InspectApiVirtualModule] = Field(default_factory=list) + + +class InspectApiVirtualDeviceInstance(InspectApiBaseModel): + """One entry from ``status/network/virtualDevices`` (create/update body omits ``_id``).""" + + id: str | None = Field(default=None, alias="_id") + vid: str | None = Field(default=None, alias="_vid") + modules: list[InspectApiVirtualModule] = Field(default_factory=list) + + +class InspectApiVirtualDeviceWriteBody(InspectApiBaseModel): + """Body for one virtual device in ``updateVirtualInstances`` add/update.""" + + modules: list[InspectApiVirtualModule] = Field(default_factory=list) + + +class InspectApiUpdateVirtualInstancesData(InspectApiBaseModel): + add: list[InspectApiVirtualDeviceWriteBody] = Field(default_factory=list) + update: dict[str, InspectApiVirtualDeviceWriteBody] = Field(default_factory=dict) + remove: list[str] = Field(default_factory=list) + force: bool = False + + +class InspectApiUpdateVirtualInstancesRequest(InspectApiBaseModel): + header: InspectApiPostRequestHeader = Field(default_factory=InspectApiPostRequestHeader) + data: InspectApiUpdateVirtualInstancesData + + +class InspectApiUpdateVirtualInstancesValidation(InspectApiBaseModel): + createIds: list[Any] = Field(default_factory=list) + details: dict[str, Any] = Field(default_factory=dict) + result: InspectApiSimpleActionResult + + +class InspectApiUpdateVirtualInstancesResponseData(InspectApiBaseModel): + addedDeviceLabels: dict[str, str] = Field(default_factory=dict) + res: InspectApiSimpleActionResult + validation: InspectApiUpdateVirtualInstancesValidation + + +class InspectApiUpdateVirtualInstancesResponse(InspectApiBaseModel): + data: InspectApiUpdateVirtualInstancesResponseData + header: InspectApiRestV2Header + + +class InspectApiVirtualTemplateVertex(InspectApiBaseModel): + """Vertex config embedded in a port template (lossless; ``extra="allow"``).""" + + type: InspectApiNGraphElementType | str | None = None + vertexType: InspectVertexType | str | None = None + codecFormat: InspectCodecFormat | str | None = None + isVirtual: bool | None = None + active: bool | None = None + control: InspectControl | str | None = None + configPriority: InspectConfigPriority | str | None = None + useAsEndpoint: bool | None = None + deviceId: str | None = None + descriptor: dict[str, Any] | None = None + fDescriptor: dict[str, Any] | None = None + custom: dict[str, Any] = Field(default_factory=dict) + tags: list[str] = Field(default_factory=list) + maps: list[Any] = Field(default_factory=list) + sipsMode: InspectSipsMode | str | None = None + imgUrl: str | None = None + extraAlertFilters: list[Any] = Field(default_factory=list) + gpid: dict[str, Any] | None = None + + +class InspectApiVirtualTemplateItem(InspectApiBaseModel): + """One entry from ``status/network/virtualTemplates``.""" + + id: str = Field(alias="_id") + vid: str | None = Field(default=None, alias="_vid") + label: str + vertex: InspectApiVirtualTemplateVertex + + +class InspectApiVirtualTemplateWriteBody(InspectApiBaseModel): + """Body for one port template in ``updateVirtualTemplates.add``.""" + + label: str + vertex: dict[str, Any] + + +class InspectApiUpdateVirtualTemplatesData(InspectApiBaseModel): + add: dict[str, InspectApiVirtualTemplateWriteBody] = Field(default_factory=dict) + remove: list[str] = Field(default_factory=list) + force: bool = False + + +class InspectApiUpdateVirtualTemplatesRequest(InspectApiBaseModel): + header: InspectApiPostRequestHeader = Field(default_factory=InspectApiPostRequestHeader) + data: InspectApiUpdateVirtualTemplatesData + + +class InspectApiAddVirtualTopologyData(InspectApiBaseModel): + deviceId: str + moduleId: int + countByVertexTemplate: dict[str, int] = Field(default_factory=dict) + + +class InspectApiAddVirtualTopologyRequest(InspectApiBaseModel): + header: InspectApiPostRequestHeader = Field(default_factory=InspectApiPostRequestHeader) + data: InspectApiAddVirtualTopologyData + + +__all__ = [ + "InspectApiAddVirtualTopologyData", + "InspectApiAddVirtualTopologyRequest", + "InspectApiUpdateVirtualInstancesData", + "InspectApiUpdateVirtualInstancesRequest", + "InspectApiUpdateVirtualInstancesResponse", + "InspectApiUpdateVirtualInstancesResponseData", + "InspectApiUpdateVirtualInstancesValidation", + "InspectApiUpdateVirtualTemplatesData", + "InspectApiUpdateVirtualTemplatesRequest", + "InspectApiVirtualDeviceFields", + "InspectApiVirtualDeviceInstance", + "InspectApiVirtualDeviceWriteBody", + "InspectApiVirtualModule", + "InspectApiVirtualPortFromTemplate", + "InspectApiVirtualTemplateItem", + "InspectApiVirtualTemplateVertex", + "InspectApiVirtualTemplateWriteBody", +] diff --git a/src/videoipath_automation_tool/apps/inspect/snapshot.py b/src/videoipath_automation_tool/apps/inspect/snapshot.py new file mode 100644 index 0000000..bf773e1 --- /dev/null +++ b/src/videoipath_automation_tool/apps/inspect/snapshot.py @@ -0,0 +1,1156 @@ +"""InspectSnapshot: skeleton-first, lazily-hydrated, accreting read state. + +A snapshot is built from two scoped skeleton reads (all devices without module/port detail, all +external-edge pairs). Detail is hydrated on demand — the first access to a device's ports fetches +that one device's full nodeStatus sub-tree and merges it into the same internal indexes; services +load once as a section. The snapshot is never a single point in time: each device and section +carries its own fetch timestamp. ``refresh()`` builds a *new* snapshot; state is never reused +across snapshots. + +After a successful commit the transaction calls the post-commit hooks here to update only the +touched entities via targeted scoped re-reads instead of a full reload. +""" + +from __future__ import annotations + +import logging +import threading +from concurrent.futures import ThreadPoolExecutor +from datetime import datetime, timezone +from enum import Enum +from typing import TYPE_CHECKING, Any, Iterator, Optional + +from pydantic import Field + +from videoipath_automation_tool.apps.inspect.model.alarms import InspectApiAlarmItem +from videoipath_automation_tool.apps.inspect.model.collector import ( + InspectApiCollectorResponse, + InspectApiExternalEdgeLiveStatus, + InspectApiExternalEdgesByDeviceKeyItem, + InspectApiExternalEdgeStatus, + InspectApiModuleStatus, + InspectApiNodeStatusItem, + InspectApiPathItem, + InspectApiSingleVertexInfo, + InspectPortStatus, +) +from videoipath_automation_tool.apps.inspect.model.common import ( + InspectFrozenModel, + InspectInternalModel, + InspectSeverity, + _STAGED_MISSING, + format_repr, +) + +if TYPE_CHECKING: + from videoipath_automation_tool.apps.inspect.domain.alarm import InspectAlarm + from videoipath_automation_tool.apps.inspect.domain.device import InspectDevice + from videoipath_automation_tool.apps.inspect.domain.edge import InspectEdge + from videoipath_automation_tool.apps.inspect.domain.module import InspectModule + from videoipath_automation_tool.apps.inspect.domain.port import InspectPort + from videoipath_automation_tool.apps.inspect.domain.service import InspectService + from videoipath_automation_tool.apps.inspect.domain.vertex import InspectVertex + from videoipath_automation_tool.apps.inspect.api import InspectAPI + from videoipath_automation_tool.apps.inspect.model.actions import ( + InspectApiEdgeForm, + InspectApiLookupVertexResponseData, + ) + + +class HydrationLevel(str, Enum): + SKELETON = "skeleton" + FULL = "full" + + +class InspectSnapshot: + def __init__( + self, + fetcher: Optional["InspectAPI"] = None, + device_items: Optional[list[InspectApiNodeStatusItem]] = None, + edge_items: Optional[list[InspectApiExternalEdgesByDeviceKeyItem]] = None, + *, + device_level: HydrationLevel = HydrationLevel.SKELETON, + path_items: Optional[list[InspectApiPathItem]] = None, + alarm_items: Optional[list[InspectApiAlarmItem]] = None, + ) -> None: + self._fetcher = fetcher + self._lock = threading.RLock() + self._created_at = _now() + + # Core indexes + self._devices_by_id: dict[str, _DeviceRecord] = {} + self._devices_by_label: dict[str, list[str]] = {} + self._edge_pairs: dict[str, InspectApiExternalEdgesByDeviceKeyItem] = {} + self._edges_by_device_id: dict[str, list[_IndexedEdge]] = {} + self._edge_by_port_key: dict[tuple[str, str], _IndexedEdge] = {} + + # Per-device port + module indexes (populated on hydration) + self._ports_by_device_id: dict[str, list[_IndexedPort]] = {} + self._port_by_key: dict[tuple[str, str], _IndexedPort] = {} + self._ports_by_pid: dict[str, list[_IndexedPort]] = {} + self._modules_by_device_id: dict[str, dict[str, InspectApiModuleStatus]] = {} + + # Vertex edit-form details, fetched lazily per vertex and invalidated when the + # owning device's ports are rebuilt after a refresh/commit. + self._vertex_details: dict[str, "InspectApiLookupVertexResponseData"] = {} + + # Edge edit-form details, fetched lazily per edge and invalidated when the + # owning edge pair is dropped/re-indexed after a refresh/commit. + self._edge_details: dict[str, "InspectApiEdgeForm"] = {} + + # Section: services / paths + self._paths_by_booking_id: dict[str, InspectApiPathItem] = {} + self._services_by_device_id: dict[str, list[str]] = {} + self._section_loaded: dict[str, bool] = {"paths": False, "alarms": False} + self._section_fetched_at: dict[str, datetime] = {} + + # Section: current alarms (status/alarms/current), indexed by resource key + self._alarms: list[InspectApiAlarmItem] = [] + self._alarms_by_device_id: dict[str, list[InspectApiAlarmItem]] = {} + self._alarms_by_resource_key: dict[str, list[InspectApiAlarmItem]] = {} + + # Domain-object caches + self._device_cache: dict[str, "InspectDevice"] = {} + self._module_cache: dict[tuple[str, str], "InspectModule"] = {} + self._edge_cache: dict[str, "InspectEdge"] = {} + self._service_cache: dict[str, "InspectService"] = {} + + # Entities whose post-write re-fetch failed; re-fetched lazily on next access. + self._stale_devices: set[str] = set() + self._stale_pairs: set[str] = set() + + # Pending domain-object edits (wire-field intents) staged by setters before update()/commit. + # Keyed by (kind, entity_id) where kind is "device" / "vertex" / "edge". + self._pending_edits: dict[tuple[str, str], dict[str, Any]] = {} + + for node in device_items or []: + self._index_device(node, device_level) + for pair in edge_items or []: + self._index_edge_pair(pair) + if path_items is not None: + self._index_paths(path_items) + self._section_loaded["paths"] = True + self._section_fetched_at["paths"] = self._created_at + if alarm_items is not None: + self._index_alarms(alarm_items) + self._section_loaded["alarms"] = True + self._section_fetched_at["alarms"] = self._created_at + + def __repr__(self) -> str: + return format_repr( + self, + devices=len(self._devices_by_id), + edge_pairs=len(self._edge_pairs), + ) + + __str__ = __repr__ + + # --- Construction --- + + @classmethod + def from_full_response( + cls, response: InspectApiCollectorResponse, fetcher: Optional["InspectAPI"] = None + ) -> "InspectSnapshot": + """Build a fully-hydrated snapshot from one full collector aggregate (eager / fallback mode).""" + collector = response.data.status.collector + return cls( + fetcher=fetcher, + device_items=collector.inspect.node_status_items, + edge_items=collector.external_edges_by_device_key_items, + device_level=HydrationLevel.FULL, + path_items=collector.inspect.path_items, + ) + + # Backwards-compatible alias for the original draft API. + from_response = from_full_response + + # --- Freshness / introspection --- + + @property + def created_at(self) -> datetime: + return self._created_at + + def fetched_at(self, device_id: str) -> datetime | None: + record = self._devices_by_id.get(device_id) + return record.fetched_at if record else None + + def section_fetched_at(self, section: str = "paths") -> datetime | None: + return self._section_fetched_at.get(section) + + def is_device_hydrated(self, device_id: str) -> bool: + record = self._devices_by_id.get(device_id) + return record is not None and record.level is HydrationLevel.FULL + + # --- Device reads --- + + def get_device(self, device_id: str) -> Optional["InspectDevice"]: + self._reconcile_stale_device(device_id) + if device_id not in self._devices_by_id: + return None + return self._wrap_device(device_id) + + # Backwards-compatible alias. + get_device_by_id = get_device + + def find_device_by_label(self, label: str) -> Optional["InspectDevice"]: + ids = self._devices_by_label.get(label, []) + return self._wrap_device(ids[0]) if ids else None + + def find_devices_by_label(self, label: str) -> list["InspectDevice"]: + return [self._wrap_device(device_id) for device_id in self._devices_by_label.get(label, [])] + + # Backwards-compatible alias. + find_devices_by_name = find_devices_by_label + + @property + def devices(self) -> list["InspectDevice"]: + return [self._wrap_device(device_id) for device_id in self._devices_by_id] + + def get_devices(self, detail: bool = False) -> list["InspectDevice"]: + if detail: + self.preload() + return self.devices + + def get_device_record(self, device_id: str) -> Optional[_DeviceRecord]: + """Internal: return the (possibly hydrated) record for a device; used by domain objects.""" + self._reconcile_stale_device(device_id) + return self._devices_by_id.get(device_id) + + # --- Module + port reads (trigger hydration) --- + + def get_modules_for_device(self, device_id: str) -> list["InspectModule"]: + self._ensure_device_detail(device_id) + return [self._wrap_module(device_id, module_id) for module_id in self._modules_by_device_id.get(device_id, {})] + + def get_module(self, device_id: str, module_id: str) -> Optional["InspectModule"]: + self._ensure_device_detail(device_id) + if module_id not in self._modules_by_device_id.get(device_id, {}): + return None + return self._wrap_module(device_id, module_id) + + def get_module_status(self, device_id: str, module_id: str) -> Optional[InspectApiModuleStatus]: + """The raw module status (for domain objects to resolve live); None if the module is gone.""" + return self._modules_by_device_id.get(device_id, {}).get(module_id) + + def get_ports_for_device(self, device_id: str) -> list["InspectPort"]: + self._ensure_device_detail(device_id) + return [self._wrap_port(indexed) for indexed in self._ports_by_device_id.get(device_id, [])] + + def get_ports_for_module(self, device_id: str, module_id: str) -> list["InspectPort"]: + self._ensure_device_detail(device_id) + return [ + self._wrap_port(indexed) + for indexed in self._ports_by_device_id.get(device_id, []) + if indexed.module_id == module_id + ] + + def get_port(self, device_id: str, port_id: str) -> Optional["InspectPort"]: + self._ensure_device_detail(device_id) + indexed = self._port_by_key.get((device_id, port_id)) + return self._wrap_port(indexed) if indexed else None + + def find_port_by_id(self, port_id: str) -> Optional["InspectPort"]: + indexed = self._ports_by_pid.get(port_id) + return self._wrap_port(indexed[0]) if indexed else None + + # --- Vertex detail reads (trigger lookup) --- + + def get_vertex_details(self, vertex_id: str) -> Optional["InspectApiLookupVertexResponseData"]: + return self.get_vertex_details_many([vertex_id]).get(vertex_id) + + def get_vertex_details_many(self, vertex_ids: list[str]) -> dict[str, "InspectApiLookupVertexResponseData"]: + """Batched, cached vertex edit-form lookup (``lookupInspectVertexByIds``). Only uncached ids + are fetched, in a single call; without a fetcher only cached entries are returned.""" + missing = [vertex_id for vertex_id in vertex_ids if vertex_id not in self._vertex_details] + if missing and self._fetcher is not None: + response = self._fetcher.lookup_vertices(missing) + with self._lock: + self._vertex_details.update(response.data) + return { + vertex_id: detail for vertex_id in vertex_ids if (detail := self._vertex_details.get(vertex_id)) is not None + } + + def get_vertex( + self, + vertex_id: str, + vertex_info: Optional["InspectApiSingleVertexInfo"] = None, + *, + port_factory_label: str | None = None, + ) -> Optional["InspectVertex"]: + """Typed vertex view for ``vertex_id`` (triggers a cached ``lookupInspectVertexById``); the + concrete subclass is chosen from the edit form's ``typeFields.type``. ``vertex_info`` (the + owning port's offline ``vertexInfo`` side) supplies the direction/status flags: when it is + given a base vertex is still returned even if the edit form is unavailable; without it, an + unknown vertex returns None. ``port_factory_label`` (when built via a port) is exposed as + :attr:`InspectVertex.factory_label`.""" + lookup = self.get_vertex_details(vertex_id) + if lookup is None and vertex_info is None: + return None + type_fields = lookup.fields.typeFields if lookup is not None else None + kind = type_fields.type if type_fields is not None else None + from videoipath_automation_tool.apps.inspect.domain.vertex import build_vertex + + return build_vertex(self, vertex_id, kind, vertex_info, port_factory_label=port_factory_label) + + # --- Edge reads (no hydration) --- + + @property + def edges(self) -> list["InspectEdge"]: + self._reconcile_stale_pairs() + seen: set[str] = set() + result: list["InspectEdge"] = [] + for indexed_edges in self._edges_by_device_id.values(): + for indexed in indexed_edges: + if indexed.edge_id in seen: + continue + seen.add(indexed.edge_id) + result.append(self._wrap_edge(indexed)) + return result + + def get_edges(self) -> list["InspectEdge"]: + return self.edges + + def get_edges_for_device(self, device_id: str) -> list["InspectEdge"]: + self._reconcile_stale_pairs() + seen: set[str] = set() + result: list["InspectEdge"] = [] + for indexed in self._edges_by_device_id.get(device_id, []): + if indexed.edge_id in seen: + continue + seen.add(indexed.edge_id) + result.append(self._wrap_edge(indexed)) + return result + + def get_edges_for_port(self, device_id: str, port_id: str) -> list["InspectEdge"]: + """All edges incident on a port. The read view (``externalEdgesByDeviceKey``) keys edge + endpoints by *port* (not vertex), so edges are grouped at the port level.""" + self._reconcile_stale_pairs() + seen: set[str] = set() + result: list["InspectEdge"] = [] + for indexed in self._edges_by_device_id.get(device_id, []): + on_port = (indexed.from_device_id == device_id and indexed.from_port_id == port_id) or ( + indexed.to_device_id == device_id and indexed.to_port_id == port_id + ) + if not on_port or indexed.edge_id in seen: + continue + seen.add(indexed.edge_id) + result.append(self._wrap_edge(indexed)) + return result + + def get_edge_for_port(self, device_id: str, port_id: str) -> Optional["InspectEdge"]: + self._reconcile_stale_pairs() + indexed = self._edge_by_port_key.get((device_id, port_id)) + return self._wrap_edge(indexed) if indexed else None + + def get_edge_details(self, edge_id: str) -> Optional["InspectApiEdgeForm"]: + return self.get_edge_details_many([edge_id]).get(edge_id) + + def get_edge_details_many(self, edge_ids: list[str]) -> dict[str, "InspectApiEdgeForm"]: + """Batched, cached edge edit-form lookup (``lookupInspectEdgesByIds``). Only uncached ids are + fetched, in a single call; without a fetcher only cached entries are returned.""" + missing = [edge_id for edge_id in edge_ids if edge_id not in self._edge_details] + if missing and self._fetcher is not None: + response = self._fetcher.lookup_edges(missing) + with self._lock: + for edge_id, item in response.data.items(): + self._edge_details[edge_id] = item.edge + return {edge_id: detail for edge_id in edge_ids if (detail := self._edge_details.get(edge_id)) is not None} + + def get_linked_devices(self, device_id: str) -> list["InspectDevice"]: + self._reconcile_stale_pairs() + linked: set[str] = set() + for indexed in self._edges_by_device_id.get(device_id, []): + for candidate in (indexed.from_device_id, indexed.to_device_id): + if candidate and candidate != device_id: + linked.add(candidate) + return [d for lid in sorted(linked) if (d := self.get_device(lid)) is not None] + + # --- Service reads (section, trigger section load) --- + + @property + def services(self) -> list["InspectService"]: + self._ensure_section_paths() + return [self._wrap_service(item) for item in self._paths_by_booking_id.values()] + + def get_services(self) -> list["InspectService"]: + return self.services + + def get_service_by_booking_id(self, booking_id: str) -> Optional["InspectService"]: + self._ensure_section_paths() + item = self._paths_by_booking_id.get(booking_id) + return self._wrap_service(item) if item else None + + def get_services_for_device(self, device_id: str) -> list["InspectService"]: + self._ensure_section_paths() + result: list["InspectService"] = [] + for booking_id in self._services_by_device_id.get(device_id, []): + item = self._paths_by_booking_id.get(booking_id) + if item is not None: + result.append(self._wrap_service(item)) + return result + + # --- Alarm reads (section, trigger section load) --- + + def get_alarms_for_device(self, device_id: str) -> list["InspectAlarm"]: + self._ensure_section_alarms() + return _sorted_alarms(self._alarms_by_device_id.get(device_id, [])) + + def get_alarms_for_resource(self, resource_key: str) -> list["InspectAlarm"]: + """Alarms whose joined ``pointId`` equals ``resource_key`` (module/port pid, edge id, …).""" + self._ensure_section_alarms() + return _sorted_alarms(self._alarms_by_resource_key.get(resource_key, [])) + + def get_alarms_for_module(self, device_id: str, module_id: str) -> list["InspectAlarm"]: + """Alarms whose joined ``pointId`` equals the module pid (device_id reserved for callers).""" + _ = device_id + return self.get_alarms_for_resource(module_id) + + def get_alarms_for_port(self, port_id: str | None, *, device_id: str | None = None) -> list["InspectAlarm"]: + _ = device_id + if not port_id: + return [] + return self.get_alarms_for_resource(port_id) + + def get_alarms_for_edge(self, edge_id: str, *, pair_id: str | None = None) -> list["InspectAlarm"]: + self._ensure_section_alarms() + items = list(self._alarms_by_resource_key.get(edge_id, [])) + if pair_id and pair_id != edge_id: + items.extend(self._alarms_by_resource_key.get(pair_id, [])) + return _sorted_alarms(items) + + def get_alarms_for_service(self, booking_id: str) -> list["InspectAlarm"]: + return self.get_alarms_for_resource(booking_id) + + # --- Bulk preload --- + + def preload(self, devices: Optional[list[str]] = None) -> None: + """Hydrate multiple devices in parallel to avoid N+1 when detail is needed for many. + + Best-effort: a failed per-device fetch is marked stale and logged so the rest of the + preload still completes (mirrors :meth:`_try_refresh_device`). + """ + target = devices if devices is not None else list(self._devices_by_id) + pending = [d for d in target if not self.is_device_hydrated(d)] + if not pending or self._fetcher is None: + for device_id in pending: + self._try_ensure_device_detail(device_id) + return + with ThreadPoolExecutor(max_workers=min(_PRELOAD_WORKERS, len(pending))) as pool: + list(pool.map(self._try_ensure_device_detail, pending)) + + # --- Pending domain edits (setters → update()) --- + + def stage_edit(self, kind: str, entity_id: str, field: str, value: Any) -> None: + """Record a pending wire-field intent for ``entity_id`` (``kind``: device/vertex/edge/module).""" + with self._lock: + self._pending_edits.setdefault((kind, entity_id), {})[field] = value + + def get_staged_edits(self, kind: str, entity_id: str) -> dict[str, Any]: + """Return a copy of the pending intents for ``entity_id``, or an empty dict.""" + with self._lock: + return dict(self._pending_edits.get((kind, entity_id), {})) + + def get_staged_value(self, kind: str, entity_id: str, field: str) -> Any: + """Return the staged value for ``field``, or ``_STAGED_MISSING`` if none.""" + with self._lock: + edits = self._pending_edits.get((kind, entity_id)) + if edits is None or field not in edits: + return _STAGED_MISSING + return edits[field] + + def iter_staged_edits(self, kind: str | None = None) -> list[tuple[str, str, dict[str, Any]]]: + """All pending edits as ``(kind, entity_id, intents)``. Optionally filter by ``kind``.""" + with self._lock: + return [ + (k, eid, dict(intents)) + for (k, eid), intents in self._pending_edits.items() + if kind is None or k == kind + ] + + def clear_staged( + self, + *, + kind: str | None = None, + entity_id: str | None = None, + entity_ids: Optional[list[str]] = None, + ) -> None: + """Clear pending edits. With ``entity_id``/``entity_ids``, clear those keys (optionally + scoped by ``kind``); with only ``kind``, clear every entity of that kind; with neither, + clear all.""" + with self._lock: + if entity_id is not None: + ids = [entity_id] + elif entity_ids is not None: + ids = list(entity_ids) + else: + ids = None + if ids is None and kind is None: + self._pending_edits.clear() + return + for key in list(self._pending_edits): + k, eid = key + if kind is not None and k != kind: + continue + if ids is not None and eid not in ids: + continue + self._pending_edits.pop(key, None) + + # --- Refresh --- + + def refresh(self) -> "InspectSnapshot": + """Return a *new* snapshot from a fresh skeleton read (never mutates this one).""" + if self._fetcher is None: + raise RuntimeError("This snapshot has no fetcher and cannot be refreshed; build a new snapshot instead.") + return InspectSnapshot( + fetcher=self._fetcher, + device_items=self._fetcher.get_device_skeleton(), + edge_items=self._fetcher.get_edge_skeleton(), + ) + + # --- Post-commit hooks --- + + def apply_post_commit( + self, + removed_ids: Optional[list[str]] = None, + device_ids: Optional[list[str]] = None, + pair_ids: Optional[list[str]] = None, + mark_paths_stale: bool = True, + ) -> None: + """Targeted refresh after a successful commit: drop removed entities locally, re-fetch the + affected devices and edge pairs, and mark the services section stale. + + Never raises: a failed re-fetch marks the entity stale (re-fetched lazily on next access) + and logs, so a post-commit hook cannot lose the caller's already-successful commit result. + """ + self._apply_removals(removed_ids or []) + if self._fetcher is not None: + for device_id in device_ids or []: + if device_id in self._devices_by_id: + self._try_refresh_device(device_id) + for pair_id in pair_ids or []: + self._try_refresh_edge_pair(pair_id) + if mark_paths_stale: + self._mark_paths_stale() + self._mark_alarms_stale() + + def apply_network_refresh(self, device_ids: list[str]) -> None: + """Targeted refresh after a network action (addDevices / syncDevices): upsert the named + devices (new or restructured) and reconcile the edge pairs touching them, then mark the + services section stale. Never raises (same contract as :meth:`apply_post_commit`). + + Unlike a commit, a network action does not report the exact touched entities and can create + pairs to previously-unconnected devices, so edges are reconciled from one cheap edge-skeleton + read scoped to pairs touching an affected device (per-device detail stays targeted).""" + if self._fetcher is None or not device_ids: + return + affected = set(device_ids) + for device_id in device_ids: + self._try_refresh_device(device_id) + self._reconcile_pairs_for_devices(affected) + self._mark_paths_stale() + self._mark_alarms_stale() + + def upsert_devices_from_skeleton(self, device_ids: list[str]) -> None: + """Insert or refresh named devices from one device-skeleton read. + + Used after creating virtual devices: per-device detail fetches often miss brand-new + ``virtual.N`` nodes (detail-less / dash-vs-dot id form), while the skeleton indexes them + under the public ``deviceId`` (``virtual.N``) that ``addedDeviceLabels`` returns. + Never raises (same contract as :meth:`apply_network_refresh`). + """ + if self._fetcher is None or not device_ids: + return + wanted = set(device_ids) + try: + nodes = self._fetcher.get_device_skeleton() + except Exception as exc: + for device_id in device_ids: + self._stale_devices.add(device_id) + _logger.warning( + "Inspect snapshot: skeleton upsert for %s failed: %s", + sorted(wanted), + exc, + ) + return + with self._lock: + for node in nodes: + device_id = node.deviceId or node.id + if device_id not in wanted: + continue + self._index_device(node, HydrationLevel.SKELETON) + self._stale_devices.discard(device_id) + + # --- Internal: hydration --- + + def _ensure_device_detail(self, device_id: str) -> None: + self._reconcile_stale_device(device_id) + record = self._devices_by_id.get(device_id) + if record is None or record.level is HydrationLevel.FULL or self._fetcher is None: + return + # The collector keys nodeStatus by the item's own id (dash form for virtual devices, + # e.g. 'virtual-2'), which differs from the public device id ('virtual.2'). Use it here. + detail = self._fetcher.get_device_detail(record.node.id or device_id) + if detail is None: + # No further detail to load (e.g. virtual devices expose no modules); mark hydrated + # so we honour the at-most-one-fetch contract instead of re-fetching on every access. + with self._lock: + current = self._devices_by_id.get(device_id) + if current is not None and current.level is not HydrationLevel.FULL: + current.level = HydrationLevel.FULL + return + with self._lock: + current = self._devices_by_id.get(device_id) + if current is None or current.level is HydrationLevel.FULL: + return + self._upsert_device(device_id, detail) + + def _refresh_device(self, device_id: str) -> None: + """Re-fetch one device's full detail and upsert it (adds it if newly present). May raise.""" + if self._fetcher is None: + return + record = self._devices_by_id.get(device_id) + detail = self._fetcher.get_device_detail(record.node.id if record else device_id) + if detail is None: + # Keep any existing record untouched (e.g. detail-less virtual devices); just clear stale. + self._stale_devices.discard(device_id) + return + with self._lock: + self._upsert_device(device_id, detail) + + def _refresh_edge_pair(self, pair_id: str) -> None: + """Re-fetch and re-index one external-edge device pair. May raise.""" + if self._fetcher is None: + return + pair = self._fetcher.get_edge_pair(pair_id) + with self._lock: + self._drop_edge_pair(pair_id) + if pair is not None: + self._index_edge_pair(pair) + self._stale_pairs.discard(pair_id) + + # --- Internal: resilient refresh + lazy-stale self-heal --- + + def _try_ensure_device_detail(self, device_id: str) -> None: + """Hydrate a device; on failure mark it stale (lazy self-heal on next access) and log.""" + try: + self._ensure_device_detail(device_id) + except Exception as exc: + self._stale_devices.add(device_id) + _logger.warning("Inspect snapshot: preload of device '%s' failed: %s", device_id, exc) + + def _try_refresh_device(self, device_id: str) -> None: + """Re-fetch a device; on failure mark it stale (lazy self-heal on next access) and log.""" + try: + self._refresh_device(device_id) + except Exception as exc: + self._stale_devices.add(device_id) + _logger.warning("Inspect snapshot: post-write re-fetch of device '%s' failed: %s", device_id, exc) + + def _try_refresh_edge_pair(self, pair_id: str) -> None: + """Re-fetch an edge pair; on failure mark it stale (lazy self-heal on next access) and log.""" + try: + self._refresh_edge_pair(pair_id) + except Exception as exc: + self._stale_pairs.add(pair_id) + _logger.warning("Inspect snapshot: post-write re-fetch of edge pair '%s' failed: %s", pair_id, exc) + + def _reconcile_stale_device(self, device_id: str) -> None: + if device_id not in self._stale_devices: + return + try: + self._refresh_device(device_id) + self._stale_devices.discard(device_id) + except Exception as exc: + _logger.warning("Inspect snapshot: lazy re-fetch of stale device '%s' failed: %s", device_id, exc) + + def _reconcile_stale_pairs(self) -> None: + for pair_id in list(self._stale_pairs): + try: + self._refresh_edge_pair(pair_id) + except Exception as exc: + _logger.warning("Inspect snapshot: lazy re-fetch of stale edge pair '%s' failed: %s", pair_id, exc) + + def _reconcile_pairs_for_devices(self, device_ids: set[str]) -> None: + """Reconcile every edge pair touching an affected device from one fresh edge-skeleton read.""" + if self._fetcher is None or not device_ids: + return + affected_pairs = {indexed.pair_id for d in device_ids for indexed in self._edges_by_device_id.get(d, [])} + try: + pairs = self._fetcher.get_edge_skeleton() + except Exception as exc: + self._stale_pairs.update(affected_pairs) + _logger.warning("Inspect snapshot: edge reconcile after network action failed: %s", exc) + return + with self._lock: + for pair_id in affected_pairs: + self._drop_edge_pair(pair_id) + for pair in pairs: + if self._pair_touches(pair, device_ids): + self._drop_edge_pair(pair.id) + self._index_edge_pair(pair) + + def _pair_touches(self, pair: InspectApiExternalEdgesByDeviceKeyItem, device_ids: set[str]) -> bool: + primary = self._resolve_device_id(pair.primary.devicePid) + secondary = self._resolve_device_id(pair.secondary.devicePid) + return primary in device_ids or secondary in device_ids + + def _mark_paths_stale(self) -> None: + with self._lock: + self._section_loaded["paths"] = False + self._paths_by_booking_id.clear() + self._services_by_device_id.clear() + + def _mark_alarms_stale(self) -> None: + with self._lock: + self._section_loaded["alarms"] = False + self._alarms.clear() + self._alarms_by_device_id.clear() + self._alarms_by_resource_key.clear() + + def _upsert_device(self, device_id: str, detail: InspectApiNodeStatusItem) -> None: + """Insert or replace a device record (FULL), keeping the label index and caches consistent.""" + old = self._devices_by_id.get(device_id) + old_label = old.label if old is not None else None + record = _DeviceRecord(device_id=device_id, node=detail, level=HydrationLevel.FULL) + new_label = record.label + if old_label and old_label != new_label: + remaining = [d for d in self._devices_by_label.get(old_label, []) if d != device_id] + if remaining: + self._devices_by_label[old_label] = remaining + else: + self._devices_by_label.pop(old_label, None) + self._devices_by_id[device_id] = record + if new_label: + ids = self._devices_by_label.setdefault(new_label, []) + if device_id not in ids: + ids.append(device_id) + self._device_cache.pop(device_id, None) + self._rebuild_device_ports(device_id, detail) + self._stale_devices.discard(device_id) + + def _ensure_section_paths(self) -> None: + if self._section_loaded.get("paths") or self._fetcher is None: + return + items = self._fetcher.get_paths_section() + with self._lock: + if self._section_loaded.get("paths"): + return + self._index_paths(items) + self._section_loaded["paths"] = True + self._section_fetched_at["paths"] = _now() + self._service_cache.clear() + + def _ensure_section_alarms(self) -> None: + if self._section_loaded.get("alarms") or self._fetcher is None: + return + items = self._fetcher.get_alarms_section() + with self._lock: + if self._section_loaded.get("alarms"): + return + self._index_alarms(items) + self._section_loaded["alarms"] = True + self._section_fetched_at["alarms"] = _now() + + # --- Internal: indexing --- + + def _index_device(self, node: InspectApiNodeStatusItem, level: HydrationLevel) -> None: + device_id = node.deviceId or node.id + if not device_id: + return + record = _DeviceRecord(device_id=device_id, node=node, level=level) + self._devices_by_id[device_id] = record + label = record.label + if label: + ids = self._devices_by_label.setdefault(label, []) + if device_id not in ids: + ids.append(device_id) + if level is HydrationLevel.FULL: + self._rebuild_device_ports(device_id, node) + + def _rebuild_device_ports(self, device_id: str, node: InspectApiNodeStatusItem) -> None: + # Drop existing port index entries for this device + old = self._ports_by_device_id.pop(device_id, []) + for indexed in old: + for vertex_id in _vertex_ids_from_status(indexed.port): + self._vertex_details.pop(vertex_id, None) + port_id = _port_id_from_status(indexed.port) + if port_id is not None: + self._port_by_key.pop((device_id, port_id), None) + remaining = [p for p in self._ports_by_pid.get(port_id, []) if p.device_id != device_id] + if remaining: + self._ports_by_pid[port_id] = remaining + else: + self._ports_by_pid.pop(port_id, None) + # Drop the device's module index + wrappers + for module_id in self._modules_by_device_id.pop(device_id, {}): + self._module_cache.pop((device_id, module_id), None) + # Rebuild + entries: list[_IndexedPort] = [] + modules: dict[str, InspectApiModuleStatus] = {} + for module in _iter_modules(node.modules): + module_id = module.pid or module.id + if module_id is not None: + modules[module_id] = module + for port in _iter_ports(module.ports): + indexed = _IndexedPort(device_id=device_id, module_id=module_id, port=port) + entries.append(indexed) + port_id = _port_id_from_status(port) + if port_id is None: + continue + self._port_by_key[(device_id, port_id)] = indexed + self._ports_by_pid.setdefault(port_id, []).append(indexed) + self._ports_by_device_id[device_id] = entries + self._modules_by_device_id[device_id] = modules + + def _resolve_device_id(self, pid: str | None) -> str | None: + """Map an edge ``devicePid`` to the canonical device id. + + For physical devices the pid equals the device id. For virtual devices the collector reports + the pid in dash-encoded form (``virtual-2``) while the device id is dot form (``virtual.2``); + reconcile the two so edges index under the same key the device is stored under. + """ + if not pid or pid in self._devices_by_id: + return pid + dotted = pid.replace("-", ".") + return dotted if dotted in self._devices_by_id else pid + + def _index_edge_pair(self, pair_item: InspectApiExternalEdgesByDeviceKeyItem) -> None: + self._edge_pairs[pair_item.id] = pair_item + primary_device_id = self._resolve_device_id(pair_item.primary.devicePid) + secondary_device_id = self._resolve_device_id(pair_item.secondary.devicePid) + for side, device_id in ( + (pair_item.primary, primary_device_id), + (pair_item.secondary, secondary_device_id), + ): + if not device_id: + continue + for edge in side.data.values(): + from_device_id = ( + self._resolve_device_id( + _device_id_from_context(edge.fromStatus.context if edge.fromStatus else None) + ) + or primary_device_id + ) + from_port_id = _port_id_from_endpoint(edge.fromStatus) + to_device_id = ( + self._resolve_device_id(_device_id_from_context(edge.toStatus.context if edge.toStatus else None)) + or secondary_device_id + ) + to_port_id = _port_id_from_endpoint(edge.toStatus) + indexed = _IndexedEdge( + edge_id=edge.id, + pair_id=pair_item.id, + edge=edge, + pair_status=pair_item.status, + primary_device_id=primary_device_id, + secondary_device_id=secondary_device_id, + from_device_id=from_device_id, + from_port_id=from_port_id, + to_device_id=to_device_id, + to_port_id=to_port_id, + ) + self._edges_by_device_id.setdefault(device_id, []).append(indexed) + for endpoint_device_id, port_id in ( + (from_device_id, from_port_id), + (to_device_id, to_port_id), + ): + if endpoint_device_id and port_id: + self._edge_by_port_key[(endpoint_device_id, port_id)] = indexed + + def _drop_edge_pair(self, pair_id: str) -> None: + self._edge_pairs.pop(pair_id, None) + for edges in self._edges_by_device_id.values(): + for edge in edges: + if edge.pair_id == pair_id: + self._edge_details.pop(edge.edge_id, None) + for device_id, edges in list(self._edges_by_device_id.items()): + kept = [e for e in edges if e.pair_id != pair_id] + if kept: + self._edges_by_device_id[device_id] = kept + else: + self._edges_by_device_id.pop(device_id, None) + for key, indexed in list(self._edge_by_port_key.items()): + if indexed.pair_id == pair_id: + self._edge_by_port_key.pop(key, None) + for edge_id, edge in list(self._edge_cache.items()): + if edge.pair_id == pair_id: + self._edge_cache.pop(edge_id, None) + + def _index_paths(self, path_items: list[InspectApiPathItem]) -> None: + for item in path_items: + booking_id = item.serviceFields.bid + self._paths_by_booking_id[booking_id] = item + device_ids: set[str] = set() + for segment in item.path: + structure = segment.structure + if structure and structure.deviceId: + device_ids.add(structure.deviceId) + for device_id in device_ids: + ids = self._services_by_device_id.setdefault(device_id, []) + if booking_id not in ids: + ids.append(booking_id) + + def _index_alarms(self, alarm_items: list[InspectApiAlarmItem]) -> None: + self._alarms = list(alarm_items) + self._alarms_by_device_id.clear() + self._alarms_by_resource_key.clear() + for item in alarm_items: + point_id = list(item.id.pointId) if item.id is not None else [] + if not point_id: + continue + device_id = point_id[0] + self._alarms_by_device_id.setdefault(device_id, []).append(item) + resource_key = ".".join(point_id) + self._alarms_by_resource_key.setdefault(resource_key, []).append(item) + # Edge pair / directed edge keys appear as a single pointId element containing "::". + for part in point_id: + if "::" in part: + self._alarms_by_resource_key.setdefault(part, []).append(item) + + def _apply_removals(self, removed_ids: list[str]) -> None: + if not removed_ids: + return + with self._lock: + for removed in removed_ids: + # Device removal + record = self._devices_by_id.pop(removed, None) + if record is not None: + label = record.label + if label and label in self._devices_by_label: + self._devices_by_label[label] = [d for d in self._devices_by_label[label] if d != removed] + if not self._devices_by_label[label]: + self._devices_by_label.pop(label, None) + self._device_cache.pop(removed, None) + for indexed in self._ports_by_device_id.pop(removed, []): + for vertex_id in _vertex_ids_from_status(indexed.port): + self._vertex_details.pop(vertex_id, None) + for module_id in self._modules_by_device_id.pop(removed, {}): + self._module_cache.pop((removed, module_id), None) + self._edges_by_device_id.pop(removed, None) + # Edge removal by edge id or pair id + if "::" in removed: + self._drop_edge_id(removed) + + def _drop_edge_id(self, edge_or_pair_id: str) -> None: + for device_id, edges in list(self._edges_by_device_id.items()): + kept = [e for e in edges if e.edge_id != edge_or_pair_id and e.pair_id != edge_or_pair_id] + if kept != edges: + if kept: + self._edges_by_device_id[device_id] = kept + else: + self._edges_by_device_id.pop(device_id, None) + for key, indexed in list(self._edge_by_port_key.items()): + if indexed.edge_id == edge_or_pair_id or indexed.pair_id == edge_or_pair_id: + self._edge_by_port_key.pop(key, None) + self._edge_cache.pop(edge_or_pair_id, None) + self._edge_details.pop(edge_or_pair_id, None) + + # --- Internal: domain wrappers (cached) --- + + def _wrap_device(self, device_id: str) -> "InspectDevice": + from videoipath_automation_tool.apps.inspect.domain.device import InspectDevice + + cached = self._device_cache.get(device_id) + if cached is not None: + return cached + device = InspectDevice(snapshot=self, id=device_id) + self._device_cache[device_id] = device + return device + + def _wrap_module(self, device_id: str, module_id: str) -> "InspectModule": + from videoipath_automation_tool.apps.inspect.domain.module import InspectModule + + cached = self._module_cache.get((device_id, module_id)) + if cached is not None: + return cached + module = InspectModule(snapshot=self, device_id=device_id, module_id=module_id) + self._module_cache[(device_id, module_id)] = module + return module + + def _wrap_port(self, indexed: _IndexedPort) -> "InspectPort": + from videoipath_automation_tool.apps.inspect.domain.port import InspectPort + + return InspectPort(snapshot=self, indexed=indexed) + + def _wrap_edge(self, indexed: _IndexedEdge) -> "InspectEdge": + from videoipath_automation_tool.apps.inspect.domain.edge import InspectEdge + + cached = self._edge_cache.get(indexed.edge_id) + if cached is not None: + return cached + edge = InspectEdge(snapshot=self, indexed=indexed) + self._edge_cache[indexed.edge_id] = edge + return edge + + def _wrap_service(self, path_item: InspectApiPathItem) -> "InspectService": + from videoipath_automation_tool.apps.inspect.domain.service import InspectService + + booking_id = path_item.serviceFields.bid + cached = self._service_cache.get(booking_id) + if cached is not None: + return cached + service = InspectService(snapshot=self, path_item=path_item) + self._service_cache[booking_id] = service + return service + + +# --- Internal --- + +_PRELOAD_WORKERS = 8 + +_logger = logging.getLogger("videoipath_automation_tool_inspect_snapshot") + + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +def _severity_rank(value: Any) -> int: + """Sort key: higher severity first; unknown / missing treated as lowest.""" + if isinstance(value, InspectSeverity): + return int(value) + if isinstance(value, int) and not isinstance(value, bool): + return value + return -1 + + +def _sorted_alarms(items: list[InspectApiAlarmItem]) -> list["InspectAlarm"]: + from videoipath_automation_tool.apps.inspect.domain.alarm import InspectAlarm + + ordered = sorted( + items, + key=lambda item: _severity_rank(item.info.severity if item.info is not None else None), + reverse=True, + ) + return [InspectAlarm(item=item) for item in ordered] + + +class _DeviceRecord(InspectInternalModel): + device_id: str + node: InspectApiNodeStatusItem + level: HydrationLevel + fetched_at: datetime = Field(default_factory=_now) + + @property + def label(self) -> str | None: + return self.node.effective_label + + @property + def pid(self) -> str | None: + return self.node.pid or self.node.deviceId + + def __repr__(self) -> str: + return format_repr(self, device_id=self.device_id, level=self.level) + + __str__ = __repr__ + + +class _IndexedPort(InspectFrozenModel): + device_id: str + module_id: str | None + port: InspectPortStatus + + def __repr__(self) -> str: + return format_repr( + self, + device_id=self.device_id, + module_id=self.module_id, + port_id=_port_id_from_status(self.port), + ) + + __str__ = __repr__ + + +class _IndexedEdge(InspectFrozenModel): + edge_id: str + pair_id: str + edge: InspectApiExternalEdgeStatus + pair_status: InspectApiExternalEdgeLiveStatus | None + primary_device_id: str | None + secondary_device_id: str | None + from_device_id: str | None + from_port_id: str | None + to_device_id: str | None + to_port_id: str | None + + def __repr__(self) -> str: + return format_repr( + self, + edge_id=self.edge_id, + from_device_id=self.from_device_id, + to_device_id=self.to_device_id, + ) + + __str__ = __repr__ + + +# --- Module-level helpers (kept stable for the domain layer) --- + + +def _device_id_from_context(context: Any) -> str | None: + if context is None: + return None + device_pid = getattr(context, "devicePid", None) + if device_pid: + return device_pid + if isinstance(context, dict): + value = context.get("devicePid") + return value if isinstance(value, str) else None + return None + + +def _port_id_from_endpoint(endpoint: Any) -> str | None: + if endpoint is None: + return None + pid = getattr(endpoint, "pid", None) + if isinstance(pid, str) and pid: + return pid + context = getattr(endpoint, "context", None) + if context is not None: + if isinstance(context, dict): + port_pid = context.get("portPid") + else: + port_pid = getattr(context, "portPid", None) + if isinstance(port_pid, str) and port_pid: + return port_pid + return None + + +def _port_id_from_status(port: InspectPortStatus) -> str | None: + port_id = port.pid or port.id + return port_id if port_id else None + + +def _vertex_ids_from_status(port: InspectPortStatus) -> tuple[str, ...]: + """All vertex ids carried by a port's ``vertexInfo`` (one for single, out+in for double).""" + info = port.parsed_vertex_info + if info is None: + return () + if isinstance(info, InspectApiSingleVertexInfo): + return (info.id,) if info.id else () + return tuple(side.id for side in (info.out, info.in_) if side is not None and side.id) + + +def _iter_modules( + modules: dict[str, InspectApiModuleStatus] | list[InspectApiModuleStatus] | None, +) -> Iterator[InspectApiModuleStatus]: + if not modules: + return + if isinstance(modules, dict): + yield from modules.values() + return + yield from modules + + +def _iter_ports( + ports: dict[str, InspectPortStatus] | list[InspectPortStatus] | None, +) -> Iterator[InspectPortStatus]: + if not ports: + return + if isinstance(ports, dict): + yield from ports.values() + return + yield from ports + + +__all__ = ["InspectSnapshot", "HydrationLevel", "_STAGED_MISSING"] diff --git a/src/videoipath_automation_tool/apps/inspect/transaction.py b/src/videoipath_automation_tool/apps/inspect/transaction.py new file mode 100644 index 0000000..74314b5 --- /dev/null +++ b/src/videoipath_automation_tool/apps/inspect/transaction.py @@ -0,0 +1,1089 @@ +"""Transaction for Inspect topology writes. + +A transaction stages topology changes, captures a per-entity baseline via the lookup endpoints +(the lookup forms *are* the ``updateTopology`` write shapes), and applies them +atomically on ``commit()``. Commit runs a client-side compare-and-commit conflict check against +freshly re-fetched baselines, then a single ``updateTopology`` POST, then a three-flag success +evaluation. On success it drives a targeted snapshot refresh instead of +a full reload. + +Caller mutations are field-level *intents* recorded against the staged baseline and applied at +commit-build time, so ``rebase()`` can re-fetch baselines and re-apply the same intents. + +Verified server facts encoded here (2025.4.9): +- ``replaceDevices`` / ``replaceVertices`` take the lookup *edit form*; ``replaceVertices`` is + update-only (vertices cannot be created via ``updateTopology``). +- ``descriptor`` is *mandatory* in the device edit form, so it is always round-tripped from the + baseline; ``descriptor.label`` is only changed when the caller sets a label explicitly (the + Inspect UI has the same behaviour — the persisted descriptor is not distinguishable from the + effective one on the collector surface). +- ``replaceEdges`` takes the raw persisted edge form; there is no ``_rev`` anywhere (last-writer-wins). +- Apply is reject-before-apply (all-or-nothing), so a detected conflict aborts the whole commit. +""" + +from __future__ import annotations + +import copy +import logging +from typing import TYPE_CHECKING, Any, Optional, Sequence + +from pydantic import Field + +from videoipath_automation_tool.apps.inspect.errors import ( + InspectCommitConflictError, + InspectCommitError, + InspectConflict, + InspectEntityNotFoundError, + InspectError, +) +from videoipath_automation_tool.apps.inspect.model.actions import ( + InspectApiEdgeForm, + InspectApiLookupInspectDeviceFields, + InspectApiVertexControlProps, + InspectApiVertexEditForm, +) +from videoipath_automation_tool.apps.inspect.model.common import ( + CONFLICT_PRIORITY_TO_INT, + InspectApiSimpleActionResponse, + InspectCodecFormat, + InspectConfigPriority, + InspectControl, + InspectFrozenModel, + InspectIconSize, + InspectIconType, + InspectInternalModel, + InspectRedundancyMode, + InspectSdpStrategy, + InspectSipsMode, + format_repr, +) +from videoipath_automation_tool.apps.inspect.model.tags import module_resource_id +from videoipath_automation_tool.apps.inspect.model.update_topology import ( + InspectApiUpdateTopologyData, + InspectApiUpdateTopologyResponse, +) + +if TYPE_CHECKING: + from videoipath_automation_tool.apps.inspect.api import InspectAPI + from videoipath_automation_tool.apps.inspect.domain.device import InspectDevice + from videoipath_automation_tool.apps.inspect.domain.edge import InspectEdge + from videoipath_automation_tool.apps.inspect.domain.module import InspectModule + from videoipath_automation_tool.apps.inspect.domain.vertex import InspectVertex + from videoipath_automation_tool.apps.inspect.snapshot import InspectSnapshot + + Editable = InspectDevice | InspectVertex | InspectEdge | InspectModule +else: + Editable = Any + + +class CommitResult(InspectFrozenModel): + """Outcome of a successful commit; a failed commit raises ``InspectCommitError``. + + ``response`` is ``None`` when the commit only applied module tag assign/unassign ops (no + ``updateTopology`` call). + """ + + applied_ids: list[str] + created_ids: list[str] + response: InspectApiUpdateTopologyResponse | None = None + + @property + def ok(self) -> bool: + return True + + @property + def validation(self) -> Any: + return self.response.data.validation if self.response is not None else None + + def __repr__(self) -> str: + return format_repr( + self, + applied=len(self.applied_ids), + created=len(self.created_ids), + ) + + __str__ = __repr__ + + +class InspectTransaction: + """Single-use, atomic batch of Inspect topology changes. + + Stage changes with ``update`` (domain-object setter flush), ``place_device`` / ``update_*`` / + ``connect`` / ``disconnect`` / ``remove``, then call ``commit()``. The transaction cannot be + reused after commit or discard; use it as a context manager to guarantee cleanup (exit without + commit discards and logs a warning). + """ + + def __init__( + self, + api: "InspectAPI", + snapshot: Optional["InspectSnapshot"] = None, + logger: Optional[logging.Logger] = None, + ) -> None: + self._api = api + self._snapshot = snapshot + self._logger = logger or logging.getLogger("videoipath_automation_tool_inspect_txn") + self._entries: dict[tuple[str, str], _Staged] = {} + self._committed = False + self._discarded = False + + # --- Context manager --- + + def __enter__(self) -> "InspectTransaction": + return self + + def __exit__(self, exc_type: Any, exc: Any, tb: Any) -> None: + if exc_type is not None: + self.discard() + return + if not self._committed and not self._discarded: + if self._entries: + self._logger.warning( + "Inspect transaction exited with %d staged change(s) and no commit(); discarding.", + len(self._entries), + ) + self.discard() + + # --- Introspection --- + + @property + def staged(self) -> list[tuple[str, str]]: + return list(self._entries) + + def __len__(self) -> int: + return len(self._entries) + + def __repr__(self) -> str: + return format_repr( + self, + staged=len(self._entries), + committed=self._committed or None, + discarded=self._discarded or None, + ) + + __str__ = __repr__ + + # --- Staging: domain objects --- + + def update(self, obj: Editable | Sequence[Editable]) -> "InspectTransaction": + """Flush pending domain-object setter edits into this transaction. + + Accepts an :class:`InspectDevice`, :class:`InspectVertex`, :class:`InspectEdge`, + :class:`InspectModule`, or a sequence of them. For a device, also cascades every dirty + vertex/edge/module whose id belongs to that device (unit of work). Clears the snapshot's + pending edits after staging. Returns ``self`` for chaining. + + Prefer this over keyword ``update_device`` / ``update_vertex`` / … when edits were made via + domain-object setters. Requires a snapshot-bound transaction (from ``app.inspect.transaction()``). + """ + if self._snapshot is None: + raise RuntimeError( + "Inspect snapshot is not available; load the topology (e.g. access app.inspect.devices) " + "before updating domain objects." + ) + objects = list(obj) if isinstance(obj, Sequence) and not _is_single_editable(obj) else [obj] # type: ignore[list-item] + if not objects: + raise ValueError("Nothing to update.") + + flushed_keys: list[tuple[str, str]] = [] + for item in objects: + flushed_keys.extend(_stage_editable(self, self._snapshot, item)) + + for kind, entity_id in flushed_keys: + self._snapshot.clear_staged(kind=kind, entity_id=entity_id) + return self + + # --- Staging: devices --- + + def place_device(self, device_id: str, x: float, y: float) -> "InspectTransaction": + """Move a device to grid coordinates (``replaceDevices``).""" + entry = self._stage_device(device_id) + entry.intents["coordinates"] = {"x": x, "y": y} + return self + + def update_device( + self, + device_id: str, + *, + label: Optional[str] = None, + description: Optional[str] = None, + icon_type: Optional[InspectIconType | str] = None, + icon_size: Optional[InspectIconSize | str] = None, + sdp_strategy: Optional[InspectSdpStrategy | str] = None, + site_id: Optional[str] = None, + tags: Optional[list[str]] = None, + local_assigned_tags: Optional[list[str]] = None, + coordinates: Optional[dict[str, float]] = None, + intents: Optional[dict[str, Any]] = None, + ) -> "InspectTransaction": + """Edit a device's edit-dialog fields (``replaceDevices``). + + Covers the Inspect UI "Edit Device" dialog: ``label`` / ``description`` (the persisted + ``descriptor``), ``tags``, ``site_id``, ``icon_size``, ``icon_type`` and ``sdp_strategy``, + plus ``coordinates`` for placement. ``descriptor`` is always round-tripped from the baseline + (it is mandatory server-side); ``label`` / ``description`` are applied to it only when set here. + """ + entry = self._stage_device(device_id) + if intents: + entry.intents.update(intents) + if label is not None: + entry.intents["descriptor.label"] = label + if description is not None: + entry.intents["descriptor.desc"] = description + if icon_type is not None: + entry.intents["iconType"] = icon_type + if icon_size is not None: + entry.intents["iconSize"] = icon_size + if sdp_strategy is not None: + entry.intents["sdpStrategy"] = sdp_strategy + if site_id is not None: + entry.intents["siteId"] = site_id + if tags is not None: + entry.intents["tags"] = list(tags) + if local_assigned_tags is not None: + entry.intents["localAssignedTags"] = list(local_assigned_tags) + if coordinates is not None: + entry.intents["coordinates"] = dict(coordinates) + return self + + # --- Staging: vertices --- + + def update_vertex( + self, + vertex_id: str, + *, + use_as_endpoint: Optional[bool] = None, + label: Optional[str] = None, + tags: Optional[list[str]] = None, + form_tags: Optional[list[str]] = None, + description: Optional[str] = None, + active: Optional[bool] = None, + sips_mode: Optional[InspectSipsMode | str] = None, + control: Optional[InspectControl | str] = None, + control_props: Optional[Any] = None, + extra_alert_filters: Optional[list[Any]] = None, + custom: Optional[dict[str, Any]] = None, + queueable: Optional[bool] = None, + destination_monitor_leader: Optional[bool] = None, + park_port: Optional[int] = None, + # IP-vertex ``typeFields`` (only meaningful on IP vertices): + ip_address: Optional[str] = None, + ip_netmask: Optional[str] = None, + public: Optional[bool] = None, + vlan_id: Optional[str] = None, + vrf_id: Optional[str] = None, + supports_cpipe: Optional[bool] = None, + supports_igmp: Optional[bool] = None, + supports_mac_forwarding: Optional[bool] = None, + supports_nso: Optional[bool] = None, + supports_openflow: Optional[bool] = None, + supports_static_igmp: Optional[bool] = None, + supports_vlan: Optional[bool] = None, + supports_vpls: Optional[bool] = None, + # Codec-vertex ``typeFields.specific`` / ``typeFields.generic``: + sdp_support: Optional[bool] = None, + is_igmp_source: Optional[bool] = None, + specific_type: Optional[str] = None, + codec_format: Optional[InspectCodecFormat | str] = None, + multiplicity: Optional[int] = None, + codec_public: Optional[bool] = None, + extra_formats: Optional[list[Any]] = None, + bidir_partner_id: Optional[str] = None, + partner_config: Optional[Any] = None, + service_id: Optional[Any] = None, + main_src_info: Optional[dict[str, Any]] = None, + main_dst_info: Optional[dict[str, Any]] = None, + spare_src_info: Optional[dict[str, Any]] = None, + spare_dst_info: Optional[dict[str, Any]] = None, + main_destination_port: Optional[int] = None, + spare_destination_port: Optional[int] = None, + # Raw wire-field intents (used by domain-object flush / update()): + intents: Optional[dict[str, Any]] = None, + ) -> "InspectTransaction": + """Edit a vertex/port (``replaceVertices``; update-only — vertices cannot be created here). + + Covers the "Edit vertex" / bulk-edit dialogs: base fields, IP-vertex ``typeFields``, and + codec ``typeFields.generic`` / ``typeFields.specific``. ``tags`` assigns catalog tags + (``localAssignedTags``); ``form_tags`` sets the form's distinct ``tags`` list. + """ + entry = self._stage_vertex(vertex_id) + if intents: + entry.intents.update(intents) + if use_as_endpoint is not None: + entry.intents["useAsEndpoint"] = use_as_endpoint + if label is not None: + entry.intents["label"] = label + if tags is not None: + # Port tag assignment is the vertex's localAssignedTags (verified 2025.4.9); the + # separate ``fields.tags`` list does not register as an assigned tag. + entry.intents["localAssignedTags"] = list(tags) + if form_tags is not None: + entry.intents["tags"] = list(form_tags) + if description is not None: + entry.intents["desc"] = description + if active is not None: + entry.intents["active"] = active + if sips_mode is not None: + entry.intents["sipsMode"] = sips_mode + if control is not None: + # Best-effort: verified 2025.4.9 form has controlProps, not control; extra="allow" + # preserves a top-level control field if the server accepts it. + entry.intents["control"] = control + if control_props is not None: + if isinstance(control_props, dict): + entry.intents["controlProps"] = InspectApiVertexControlProps.model_validate(control_props) + else: + entry.intents["controlProps"] = control_props + if extra_alert_filters is not None: + entry.intents["extraAlertFilters"] = list(extra_alert_filters) + if custom is not None: + entry.intents["custom"] = dict(custom) + if queueable is not None: + entry.intents["queueable"] = queueable + if destination_monitor_leader is not None: + entry.intents["destinationMonitorLeader"] = destination_monitor_leader + + for value, wire_field in ( + (park_port, "parkPort"), + (ip_address, "ipAddress"), + (ip_netmask, "ipNetmask"), + (public, "public"), + (vlan_id, "vlanId"), + (vrf_id, "vrfId"), + (supports_cpipe, "supportsCpipeCfg"), + (supports_igmp, "supportsIgmpCfg"), + (supports_mac_forwarding, "supportsMacForwardingCfg"), + (supports_nso, "supportsNsoCfg"), + (supports_openflow, "supportsOpenflowCfg"), + (supports_static_igmp, "supportsStaticIgmpCfg"), + (supports_vlan, "supportsVlanCfg"), + (supports_vpls, "supportsVplsCfg"), + ): + if value is not None: + entry.intents[f"typeFields.{wire_field}"] = value + + for value, wire_path in ( + (sdp_support, "typeFields.specific.sdpSupport"), + (is_igmp_source, "typeFields.specific.isIgmpSource"), + (specific_type, "typeFields.specific.type"), + (codec_format, "typeFields.generic.codecFormat"), + (multiplicity, "typeFields.generic.multiplicity"), + (codec_public, "typeFields.generic.public"), + (extra_formats, "typeFields.generic.extraFormats"), + (bidir_partner_id, "typeFields.generic.bidirPartnerId"), + (partner_config, "typeFields.generic.partnerConfig"), + (service_id, "typeFields.generic.serviceId"), + (main_src_info, "typeFields.generic.mainSrcInfo"), + (main_dst_info, "typeFields.generic.mainDstInfo"), + (spare_src_info, "typeFields.generic.spareSrcInfo"), + (spare_dst_info, "typeFields.generic.spareDstInfo"), + (main_destination_port, "typeFields.generic.mainDstInfo.port"), + (spare_destination_port, "typeFields.generic.spareDstInfo.port"), + ): + if value is not None: + entry.intents[wire_path] = list(value) if wire_path.endswith("extraFormats") else value + return self + + # --- Staging: edges --- + + def update_edge( + self, + edge_id: str, + *, + label: Optional[str] = None, + description: Optional[str] = None, + weight: Optional[int] = None, + capacity: Optional[int] = None, + bandwidth: Optional[float] = None, + redundancy_mode: Optional[InspectRedundancyMode | str] = None, + conflict_priority: Optional[InspectConfigPriority | int | str] = None, + include_formats: Optional[list[str]] = None, + exclude_formats: Optional[list[str]] = None, + bandwidth_weight_factor: Optional[int] = None, + weight_per_service: Optional[int] = None, + active: Optional[bool] = None, + tags: Optional[list[str]] = None, + also_opposite: bool = False, + intents: Optional[dict[str, Any]] = None, + ) -> "InspectTransaction": + """Edit an existing edge's "Edit Edge" dialog fields (``replaceEdges``). + + With ``also_opposite`` the same changes are staged on the opposite directed edge (the reverse + ``.out`` <-> ``.in`` edge, as the Inspect UI's "apply changes to opposite directed edge" + option does); raises ``InspectEntityNotFoundError`` if that opposite edge does not exist. + """ + fields = dict( + label=label, + description=description, + weight=weight, + capacity=capacity, + bandwidth=bandwidth, + redundancy_mode=redundancy_mode, + conflict_priority=conflict_priority, + include_formats=include_formats, + exclude_formats=exclude_formats, + bandwidth_weight_factor=bandwidth_weight_factor, + weight_per_service=weight_per_service, + active=active, + tags=tags, + intents=intents, + ) + self._apply_edge_update(edge_id, fields) + if also_opposite: + opposite_id = _opposite_edge_id(edge_id) + if self._lookup_edge_form(opposite_id) is None: + raise InspectEntityNotFoundError(opposite_id, kind="edge") + self._apply_edge_update(opposite_id, fields) + return self + + def _apply_edge_update(self, edge_id: str, fields: dict[str, Any]) -> None: + entry = self._stage_edge(edge_id) + if fields.get("intents"): + entry.intents.update(fields["intents"]) + if fields["label"] is not None: + entry.intents["descriptor.label"] = fields["label"] + if fields["description"] is not None: + entry.intents["descriptor.desc"] = fields["description"] + if fields["weight"] is not None: + entry.intents["weight"] = fields["weight"] + if fields["capacity"] is not None: + entry.intents["capacity"] = fields["capacity"] + if fields["bandwidth"] is not None: + entry.intents["bandwidth"] = fields["bandwidth"] + if fields["redundancy_mode"] is not None: + entry.intents["redundancyMode"] = fields["redundancy_mode"] + if fields["conflict_priority"] is not None: + entry.intents["conflictPri"] = _conflict_priority_to_wire(fields["conflict_priority"]) + if fields["include_formats"] is not None: + entry.intents["includeFormats"] = list(fields["include_formats"]) + if fields["exclude_formats"] is not None: + entry.intents["excludeFormats"] = list(fields["exclude_formats"]) + if fields["active"] is not None: + entry.intents["active"] = fields["active"] + if fields["tags"] is not None: + entry.intents["tags"] = list(fields["tags"]) + if fields["bandwidth_weight_factor"] is not None or fields["weight_per_service"] is not None: + entry.intents["weightFactors"] = _merged_weight_factors( + entry.baseline_form.weightFactors, + fields["bandwidth_weight_factor"], + fields["weight_per_service"], + ) + + def connect( + self, + from_vertex: str, + to_vertex: str, + *, + bidirectional: bool = True, + overwrite: bool = False, + **edge_fields: Any, + ) -> "InspectTransaction": + """Create an edge from an out-vertex to an in-vertex (``replaceEdges``). + + With ``bidirectional`` (default) the reverse edge (``to``'s out -> ``from``'s in) is staged + too. Set ``overwrite=True`` to replace an edge that already exists. ``edge_fields`` override + the persisted-edge defaults (e.g. ``weight``, ``capacity``, ``bandwidth``, ``redundancyMode``). + """ + self._stage_new_edge(from_vertex, to_vertex, overwrite=overwrite, edge_fields=edge_fields) + if bidirectional: + self._stage_new_edge( + _reverse_vertex(to_vertex), _reverse_vertex(from_vertex), overwrite=overwrite, edge_fields=edge_fields + ) + return self + + def disconnect(self, from_vertex: str, to_vertex: str, *, bidirectional: bool = True) -> "InspectTransaction": + """Remove the edge from ``from_vertex`` to ``to_vertex`` (and its reverse if bidirectional).""" + self.remove(_edge_key(from_vertex, to_vertex)) + if bidirectional: + self.remove(_edge_key(_reverse_vertex(to_vertex), _reverse_vertex(from_vertex))) + return self + + # --- Staging: removals --- + + def remove(self, entity_id: str) -> "InspectTransaction": + """Remove any entity by id (device / vertex / edge key). Last-writer-wins (no conflict check).""" + self._ensure_open() + kind = _entity_kind(entity_id) + self._entries[(kind, entity_id)] = _Staged(kind=kind, entity_id=entity_id, remove=True) + return self + + def remove_device(self, device_id: str) -> "InspectTransaction": + """Remove a device (its baseDevice element) from the topology graph.""" + return self.remove(device_id) + + # --- Staging: modules (tag assign/unassign; not updateTopology) --- + + def update_module( + self, + module_id: str, + *, + tags: Optional[list[str]] = None, + intents: Optional[dict[str, Any]] = None, + ) -> "InspectTransaction": + """Edit a module's locally assigned tags (``assignTag`` / ``unassignTag`` on commit). + + Requires a bound snapshot so the current local tag set can be read for the diff. Tag ops + are separate RPCs from ``updateTopology`` and are not atomic with topology changes. + """ + entry = self._stage_module(module_id) + if intents: + entry.intents.update(intents) + if tags is not None: + entry.intents["tags"] = list(tags) + return self + + # --- Commit lifecycle --- + + def commit(self, check_conflicts: bool = True) -> CommitResult: + """Validate, send, and (on success) refresh. Raises on conflict or server rejection. + + Topology entries go through ``updateTopology``. Module tag intents are applied + afterward via ``assignTag`` / ``unassignTag`` (not one atomic server transaction). + + Raises: + InspectCommitConflictError: a staged entity changed on the server since staging. + InspectCommitError: the server rejected the topology commit (validation or apply gate). + InspectError: a module tag assign/unassign action failed. + """ + self._ensure_open() + if not self._entries: + raise ValueError("Nothing staged; commit aborted.") + + topology_entries = [e for e in self._entries.values() if e.kind != _MODULE] + module_entries = [e for e in self._entries.values() if e.kind == _MODULE] + + if check_conflicts and topology_entries: + self._check_conflicts() + + response: InspectApiUpdateTopologyResponse | None = None + created_ids: list[str] = [] + if topology_entries: + delta = self._build_delta() + response = self._api.update_topology(delta) + if not response.committed: + raise InspectCommitError(response) + created_ids = list(response.data.validation.createIds) + + if module_entries: + self._commit_module_tags(module_entries) + + self._committed = True + applied_ids = [e.entity_id for e in self._entries.values()] + result = CommitResult( + applied_ids=applied_ids, + created_ids=created_ids, + response=response, + ) + self._refresh_snapshot() + self._logger.debug("Inspect commit applied %d change(s): %s", len(applied_ids), applied_ids) + return result + + def rebase(self) -> "InspectTransaction": + """Re-fetch baselines for all staged entities, keeping the recorded intents. + + Use after ``InspectCommitConflictError`` to move the staged changes onto current server + state; then ``commit()`` again. Intents that themselves target a concurrently-changed field + will overwrite that change (last-writer-wins for the intent). Module tag baselines are + refreshed from the bound snapshot when present. + """ + self._ensure_open() + for entry in self._entries.values(): + if entry.remove or entry.is_new or entry.baseline_form is None: + continue + if entry.kind == _MODULE: + if self._snapshot is not None: + tags = self._module_local_tags(entry.entity_id) + entry.baseline_form = list(tags) + entry.baseline_dump = {"tags": list(tags)} + continue + fresh = self._fetch_baseline(entry.kind, entry.entity_id) + entry.baseline_form = fresh + entry.baseline_dump = fresh.model_dump(mode="json") + return self + + def discard(self) -> None: + """Drop all staged changes; the transaction can no longer be committed.""" + self._entries.clear() + self._discarded = True + + # --- Internal: staging helpers --- + + def _ensure_open(self) -> None: + if self._committed: + raise RuntimeError("This transaction was already committed; open a new one.") + if self._discarded: + raise RuntimeError("This transaction was discarded; open a new one.") + + def _stage_device(self, device_id: str) -> _Staged: + return self._stage(_DEVICE, device_id) + + def _stage_vertex(self, vertex_id: str) -> _Staged: + return self._stage(_VERTEX, vertex_id) + + def _stage_edge(self, edge_id: str) -> _Staged: + return self._stage(_EDGE, edge_id) + + def _stage_module(self, module_id: str) -> _Staged: + """Stage a module tag edit; baseline is the current local tag list from the snapshot.""" + self._ensure_open() + key = (_MODULE, module_id) + existing = self._entries.get(key) + if existing is not None and not existing.remove: + return existing + if self._snapshot is None: + raise RuntimeError( + "Inspect snapshot is required to stage module tag edits (load the topology before updating modules)." + ) + device_id = _device_of(module_id) + if self._snapshot.get_module(device_id, module_id) is None: + raise InspectEntityNotFoundError(module_id, kind="module") + baseline_tags = self._module_local_tags(module_id) + entry = _Staged( + kind=_MODULE, + entity_id=module_id, + baseline_form=list(baseline_tags), + baseline_dump={"tags": list(baseline_tags)}, + ) + self._entries[key] = entry + return entry + + def _stage(self, kind: str, entity_id: str) -> _Staged: + self._ensure_open() + key = (kind, entity_id) + existing = self._entries.get(key) + if existing is not None and not existing.remove: + return existing + baseline = self._fetch_baseline(kind, entity_id) + entry = _Staged( + kind=kind, + entity_id=entity_id, + baseline_form=baseline, + baseline_dump=baseline.model_dump(mode="json"), + ) + self._entries[key] = entry + return entry + + def _stage_new_edge( + self, from_vertex: str, to_vertex: str, *, overwrite: bool, edge_fields: dict[str, Any] + ) -> None: + self._ensure_open() + edge_id = _edge_key(from_vertex, to_vertex) + existing = self._lookup_edge_form(edge_id) + if existing is not None and not overwrite: + raise ValueError(f"Edge '{edge_id}' already exists; pass overwrite=True to replace it.") + form = ( + existing.model_copy(deep=True) + if existing is not None + else InspectApiEdgeForm(fromId=from_vertex, toId=to_vertex) + ) + form.fromId = from_vertex + form.toId = to_vertex + entry = _Staged( + kind=_EDGE, + entity_id=edge_id, + baseline_form=form, + baseline_dump=None if existing is None else existing.model_dump(mode="json"), + intents=dict(edge_fields), + is_new=existing is None, + ) + self._entries[(_EDGE, edge_id)] = entry + + # --- Internal: baselines --- + + def _fetch_baseline(self, kind: str, entity_id: str) -> Any: + if kind == _DEVICE: + return self._lookup_device_form(entity_id, required=True) + if kind == _VERTEX: + return self._lookup_vertex_form(entity_id, required=True) + form = self._lookup_edge_form(entity_id) + if form is None: + raise InspectEntityNotFoundError(entity_id, kind="edge") + return form + + def _lookup_device_form(self, device_id: str, required: bool) -> Optional[InspectApiLookupInspectDeviceFields]: + try: + response = self._api.lookup_inspect_device(device_id) + except Exception as exc: # connector-level miss + if required: + raise InspectEntityNotFoundError(device_id, kind="device") from exc + return None + return response.data.fields + + def _lookup_vertex_form(self, vertex_id: str, required: bool) -> Optional[InspectApiVertexEditForm]: + response = self._api.lookup_vertices([vertex_id]) + item = response.data.get(vertex_id) + if item is None: + if required: + raise InspectEntityNotFoundError(vertex_id, kind="vertex") + return None + return item.fields + + def _lookup_edge_form(self, edge_id: str) -> Optional[InspectApiEdgeForm]: + response = self._api.lookup_edges([edge_id]) + item = response.data.get(edge_id) + return item.edge if item is not None else None + + # --- Internal: conflict check (compare-and-commit) --- + + def _check_conflicts(self) -> None: + current = self._refetch_baselines() + conflicts: list[InspectConflict] = [] + for entry in self._entries.values(): + if entry.kind == _MODULE or entry.remove or entry.is_new or entry.baseline_dump is None: + continue + key = (entry.kind, entry.entity_id) + server_form = current.get(key) + if server_form is None: + conflicts.append(InspectConflict(entry.entity_id, entry.kind, {"__exists__": (True, False)})) + continue + server_dump = server_form.model_dump(mode="json") + if server_dump != entry.baseline_dump: + diffs = _field_diffs(entry.baseline_dump, server_dump) + conflicts.append(InspectConflict(entry.entity_id, entry.kind, diffs)) + if conflicts: + raise InspectCommitConflictError(conflicts) + + def _refetch_baselines(self) -> dict[tuple[str, str], Any]: + """Batched re-fetch of every conflict-checkable staged entity's current server form.""" + vertex_ids = [e.entity_id for e in self._entries.values() if e.kind == _VERTEX and _checkable(e)] + edge_ids = [e.entity_id for e in self._entries.values() if e.kind == _EDGE and _checkable(e)] + device_ids = [e.entity_id for e in self._entries.values() if e.kind == _DEVICE and _checkable(e)] + + current: dict[tuple[str, str], Any] = {} + if vertex_ids: + data = self._api.lookup_vertices(vertex_ids).data + for vid in vertex_ids: + item = data.get(vid) + if item is not None: + current[(_VERTEX, vid)] = item.fields + if edge_ids: + data = self._api.lookup_edges(edge_ids).data + for eid in edge_ids: + item = data.get(eid) + if item is not None: + current[(_EDGE, eid)] = item.edge + for did in device_ids: + form = self._lookup_device_form(did, required=False) + if form is not None: + current[(_DEVICE, did)] = form + return current + + # --- Internal: payload build --- + + def _build_delta(self) -> InspectApiUpdateTopologyData: + delta = InspectApiUpdateTopologyData() + for entry in self._entries.values(): + if entry.kind == _MODULE: + continue + if entry.remove: + delta.remove.append(entry.entity_id) + continue + form = entry.baseline_form.model_copy(deep=True) + _apply_intents(form, entry.intents) + if entry.kind == _DEVICE: + delta.replaceDevices[entry.entity_id] = form + elif entry.kind == _VERTEX: + delta.replaceVertices[entry.entity_id] = form + else: + delta.replaceEdges[entry.entity_id] = form + return delta + + def _commit_module_tags(self, entries: list[_Staged]) -> None: + """Diff desired vs current local tags and call assignTag / unassignTag (batched by tag).""" + to_assign: dict[str, list[str]] = {} + to_unassign: dict[str, list[str]] = {} + for entry in entries: + desired = entry.intents.get("tags") + if desired is None: + continue + desired_set = set(desired) + current_set = set(self._module_local_tags(entry.entity_id)) + element_id = module_resource_id(entry.entity_id) + for tag_id in desired_set - current_set: + to_assign.setdefault(tag_id, []).append(element_id) + for tag_id in current_set - desired_set: + to_unassign.setdefault(tag_id, []).append(element_id) + + for tag_id, element_ids in to_assign.items(): + _raise_if_tag_action_failed("assignTag", tag_id, self._api.assign_tag(tag_id, element_ids)) + for tag_id, element_ids in to_unassign.items(): + _raise_if_tag_action_failed("unassignTag", tag_id, self._api.unassign_tag(tag_id, element_ids)) + + def _module_local_tags(self, module_id: str) -> list[str]: + """Current local (or effective) tags for ``module_id`` from the bound snapshot.""" + assert self._snapshot is not None + device_id = _device_of(module_id) + status = self._snapshot.get_module_status(device_id, module_id) + if status is None: + return [] + local = status.local_assigned_tags + return list(local) if local else list(status.assigned_tags) + + # --- Internal: post-commit targeted refresh --- + + def _refresh_snapshot(self) -> None: + if self._snapshot is None: + return + removed_ids: list[str] = [] + device_ids: set[str] = set() + pair_ids: set[str] = set() + for entry in self._entries.values(): + if entry.remove: + removed_ids.append(entry.entity_id) + if entry.kind == _EDGE: + pair_ids.update(_pair_ids_for_edge(entry.entity_id)) + continue + if entry.kind == _DEVICE: + device_ids.add(entry.entity_id) + elif entry.kind in (_VERTEX, _MODULE): + device_ids.add(_device_of(entry.entity_id)) + elif entry.kind == _EDGE: + pair_ids.update(_pair_ids_for_edge(entry.entity_id)) + self._snapshot.apply_post_commit( + removed_ids=removed_ids, + device_ids=list(device_ids), + pair_ids=list(pair_ids), + mark_paths_stale=True, + ) + + +# --- Internal --- + +# Staged-entry kinds. +_DEVICE = "device" +_VERTEX = "vertex" +_EDGE = "edge" +_MODULE = "module" + + +class _Staged(InspectInternalModel): + kind: str + entity_id: str + # The write-shape baseline (edit/edge form) as fetched at stage time; None for a raw remove. + baseline_form: Any | None = None + # JSON dump of the baseline at stage time, used for the compare-and-commit conflict check. + baseline_dump: dict[str, Any] | None = None + # Field-level intents (wire field names; dotted for one level of nesting, e.g. "descriptor.label"). + intents: dict[str, Any] = Field(default_factory=dict) + remove: bool = False + is_new: bool = False + + +def _device_of(entity_id: str) -> str: + """Owning device id of a vertex/port id (``device12.1.Ethernet1.out`` -> ``device12``, + ``virtual.2.0.1`` -> ``virtual.2``).""" + if entity_id.startswith("virtual."): + parts = entity_id.split(".") + if len(parts) >= 2: + return f"{parts[0]}.{parts[1]}" + return entity_id.split(".", 1)[0] + + +def _is_single_editable(obj: Any) -> bool: + from videoipath_automation_tool.apps.inspect.domain.device import InspectDevice + from videoipath_automation_tool.apps.inspect.domain.edge import InspectEdge + from videoipath_automation_tool.apps.inspect.domain.module import InspectModule + from videoipath_automation_tool.apps.inspect.domain.vertex import InspectVertex + + return isinstance(obj, (InspectDevice, InspectVertex, InspectEdge, InspectModule)) + + +def _stage_editable( + tx: InspectTransaction, + snapshot: "InspectSnapshot", + obj: Editable, +) -> list[tuple[str, str]]: + """Stage pending edits for ``obj`` (and cascade children for a device). Returns flushed keys.""" + from videoipath_automation_tool.apps.inspect.domain.device import InspectDevice + from videoipath_automation_tool.apps.inspect.domain.edge import InspectEdge + from videoipath_automation_tool.apps.inspect.domain.module import InspectModule + from videoipath_automation_tool.apps.inspect.domain.vertex import InspectVertex + + flushed: list[tuple[str, str]] = [] + + if isinstance(obj, InspectDevice): + device_edits = snapshot.get_staged_edits("device", obj.id) + if device_edits: + tx.update_device(obj.id, intents=device_edits) + flushed.append(("device", obj.id)) + for kind, entity_id, intents in snapshot.iter_staged_edits(): + if kind == "vertex" and _device_of(entity_id) == obj.id and intents: + tx.update_vertex(entity_id, intents=intents) + flushed.append((kind, entity_id)) + elif kind == "module" and _device_of(entity_id) == obj.id and intents: + tx.update_module(entity_id, intents=intents) + flushed.append((kind, entity_id)) + elif kind == "edge" and intents: + from_id, _, to_id = entity_id.partition("::") + if _device_of(from_id) == obj.id or _device_of(to_id) == obj.id: + tx.update_edge(entity_id, intents=intents) + flushed.append((kind, entity_id)) + return flushed + + if isinstance(obj, InspectVertex): + edits = snapshot.get_staged_edits("vertex", obj.id) + if edits: + tx.update_vertex(obj.id, intents=edits) + flushed.append(("vertex", obj.id)) + return flushed + + if isinstance(obj, InspectEdge): + edits = snapshot.get_staged_edits("edge", obj.id) + if edits: + tx.update_edge(obj.id, intents=edits) + flushed.append(("edge", obj.id)) + return flushed + + if isinstance(obj, InspectModule): + edits = snapshot.get_staged_edits("module", obj.id) + if edits: + tx.update_module(obj.id, intents=edits) + flushed.append(("module", obj.id)) + return flushed + + raise TypeError(f"Unsupported update target: {type(obj)!r}") + + +def _raise_if_tag_action_failed(action: str, tag_id: str, response: InspectApiSimpleActionResponse) -> None: + if response.header.ok and response.data.ok: + return + messages = list(response.data.msg) + list(response.header.msg) + detail = "; ".join(m for m in messages if m) or "tag action rejected by the server" + raise InspectError(f"Inspect {action} failed for tag '{tag_id}': {detail}") + + +def _entity_kind(entity_id: str) -> str: + """Classify an entity id as device, vertex, or edge for staging.""" + if "::" in entity_id: + return _EDGE + if "." not in entity_id: + return _DEVICE + # ``virtual.N`` is a device id (dotted); ``virtual.N.module.vertex`` is a vertex. + if entity_id.startswith("virtual."): + parts = entity_id.split(".") + if len(parts) == 2 and parts[1].isdigit(): + return _DEVICE + return _VERTEX + + +def _reverse_vertex(vertex_id: str) -> str: + """Flip the trailing direction of an IP vertex id (``.out`` <-> ``.in``).""" + if vertex_id.endswith(".out"): + return vertex_id[: -len(".out")] + ".in" + if vertex_id.endswith(".in"): + return vertex_id[: -len(".in")] + ".out" + return vertex_id + + +def _edge_key(from_id: str, to_id: str) -> str: + return f"{from_id}::{to_id}" + + +def _opposite_edge_id(edge_id: str) -> str: + """The opposite directed edge of ``fromId::toId`` — ``reverse(toId)::reverse(fromId)`` with the + trailing ``.out`` <-> ``.in`` direction flipped on each vertex.""" + from_id, to_id = edge_id.split("::", 1) + return _edge_key(_reverse_vertex(to_id), _reverse_vertex(from_id)) + + +def _conflict_priority_to_wire(value: InspectConfigPriority | int | str) -> int | str: + """Map a friendly conflict-priority name (off/high/normal/low) to the on-wire int; pass ints and + unknown values through unchanged.""" + if isinstance(value, str): + return CONFLICT_PRIORITY_TO_INT.get(value, value) + return value + + +def _merged_weight_factors( + baseline: Any, bandwidth_weight_factor: Optional[int], weight_per_service: Optional[int] +) -> dict[str, Any]: + """Merge the requested weight-factor changes onto a deep copy of the baseline ``weightFactors`` + (a nested dict), preserving untouched sub-values (e.g. ``service.max``).""" + merged: dict[str, Any] = copy.deepcopy(baseline) if isinstance(baseline, dict) else {} + merged.setdefault("bandwidth", {}) + merged.setdefault("service", {}) + if bandwidth_weight_factor is not None: + merged["bandwidth"]["weight"] = bandwidth_weight_factor + if weight_per_service is not None: + merged["service"]["weight"] = weight_per_service + return merged + + +def _apply_intents(form: Any, intents: dict[str, Any]) -> None: + """Apply wire-field intents onto a baseline form. Supports arbitrary dotted paths and deep-merges + when the terminal parent is a ``dict`` (e.g. codec ``mainDstInfo.port``, edge ``weightFactors``).""" + for key, value in intents.items(): + if "." not in key: + setattr(form, key, value) + continue + parts = key.split(".") + target: Any = form + for index, part in enumerate(parts[:-1]): + next_target = _get_path_child(target, part) + if next_target is None: + # Intermediate containers are dicts (codec generic/specific, weightFactors, …). + next_target = {} + _set_path_child(target, part, next_target) + # Re-fetch in case the parent model replaced the assigned value. + next_target = _get_path_child(target, part) + if next_target is None: + raise ValueError(f"Cannot create intermediate path '{'.'.join(parts[: index + 1])}' on form.") + target = next_target + leaf = parts[-1] + if isinstance(target, dict): + existing = target.get(leaf) + if isinstance(existing, dict) and isinstance(value, dict): + merged = copy.deepcopy(existing) + merged.update(value) + target[leaf] = merged + else: + target[leaf] = value + else: + existing = getattr(target, leaf, None) + if isinstance(existing, dict) and isinstance(value, dict): + merged = copy.deepcopy(existing) + merged.update(value) + setattr(target, leaf, merged) + else: + setattr(target, leaf, value) + + +def _get_path_child(obj: Any, name: str) -> Any: + if isinstance(obj, dict): + return obj.get(name) + return getattr(obj, name, None) + + +def _set_path_child(obj: Any, name: str, value: Any) -> None: + if isinstance(obj, dict): + obj[name] = value + else: + setattr(obj, name, value) + + +def _checkable(entry: _Staged) -> bool: + return not entry.remove and not entry.is_new and entry.baseline_dump is not None + + +def _field_diffs(baseline: dict[str, Any], current: dict[str, Any]) -> dict[str, tuple[object, object]]: + diffs: dict[str, tuple[object, object]] = {} + for key in set(baseline) | set(current): + before = baseline.get(key) + after = current.get(key) + if before != after: + diffs[key] = (before, after) + return diffs + + +def _pair_ids_for_edge(edge_id: str) -> tuple[str, ...]: + """Both possible collector pair-key orderings for an edge (one is a no-op on refresh).""" + if "::" not in edge_id: + return () + from_id, to_id = edge_id.split("::", 1) + dev_a, dev_b = _device_of(from_id), _device_of(to_id) + if dev_a == dev_b: + return (f"{dev_a}::{dev_b}",) + return (f"{dev_a}::{dev_b}", f"{dev_b}::{dev_a}") + + +__all__ = ["InspectTransaction", "CommitResult"] diff --git a/src/videoipath_automation_tool/apps/inventory/model/driver_schema/2026.2.0.json b/src/videoipath_automation_tool/apps/inventory/model/driver_schema/2026.2.0.json new file mode 100644 index 0000000..039087d --- /dev/null +++ b/src/videoipath_automation_tool/apps/inventory/model/driver_schema/2026.2.0.json @@ -0,0 +1,21469 @@ +{ + "data": { + "status": { + "system": { + "drivers": { + "_items": [ + { + "_id": "com.dante.DDM-0.1.0", + "_vid": "com.dante.DDM-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "DDM" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom setting fields for DDM driver", + "label": "DanteDomainManager" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.dante.DDM.api_token": { + "_schema": { + "default": "", + "descriptor": { + "desc": "Needs to be provided", + "label": "API Token" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "Driver for Dante Domain Manager", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "gateway", + "ipAddress": null, + "label": "Dante Domain Manager", + "modules": [], + "name": "DDM", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.dante", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "MatrixControlLike", + "CoreLike", + "DeviceLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": false, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.NMOS-0.1.0", + "_vid": "com.nevion.NMOS-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "NMOS" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom setting fields for NMOS Nodes", + "label": "NMOS" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.nevion.NMOS.always_enable_rtp": { + "_schema": { + "default": false, + "descriptor": { + "desc": "The \"rtp_enabled\" field in \"transport_params\" will always be set to true", + "label": "Always enable RTP" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.NMOS.disable_rx_sdp": { + "_schema": { + "default": false, + "descriptor": { + "desc": "Configure this unit's receivers with regular transport parameters only", + "label": "Disable Rx SDP" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.NMOS.disable_rx_sdp_with_null": { + "_schema": { + "default": true, + "descriptor": { + "desc": "Configures how RX SDPs are disabled. If unchecked, an empty string is used", + "label": "Disable Rx SDP with null" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.NMOS.enable_bulk_config": { + "_schema": { + "default": false, + "descriptor": { + "desc": "Configure this unit using bulk API", + "label": "Enable bulk config" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.NMOS.enable_experimental_alarm": { + "_schema": { + "default": false, + "descriptor": { + "desc": "Enables experimental alarms over websockets using IS-07 on certain Vizrt devices. Disables alarms completely if disabled", + "label": "Enable experimental alarms using IS-07" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.NMOS.experimental_alarm_port": { + "_schema": { + "default": 0, + "descriptor": { + "desc": "HTTP port for location of experimental IS-07 alarm websocket. If empty or 0 it uses Port field instead", + "label": "Experimental alarm port" + }, + "isNullable": true, + "options": [], + "ranges": [ + [ + 1, + 65535, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + }, + "com.nevion.NMOS.is05_api_version": { + "_schema": { + "default": false, + "descriptor": { + "desc": "Configure IS05 API version to use max", + "label": "Enable Max IS05 API version" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.NMOS.port": { + "_schema": { + "default": 80, + "descriptor": { + "desc": "The HTTP port used to reach the Node directly", + "label": "Port" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 1, + 65535, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "A driver for NMOS capable devices tailored for single devices", + "deviceType": "nmos", + "discoveredBy": null, + "exists": "No", + "iconType": "none", + "ipAddress": null, + "label": "NMOS (Single-device)", + "modules": [], + "name": "NMOS", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [ + { + "name": "NMOS", + "swBuildTime": null, + "swVersion": null + } + ], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "MatrixControlLike", + "DeviceLike", + "PortLike", + "CoreLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": false, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.NMOS_multidevice-0.1.0", + "_vid": "com.nevion.NMOS_multidevice-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "NMOS_multidevice" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom setting fields for NMOS Multidevice Nodes", + "label": "NMOS Multidevice" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.nevion.NMOS_multidevice.always_enable_rtp": { + "_schema": { + "default": false, + "descriptor": { + "desc": "The \"rtp_enabled\" field in \"transport_params\" will always be set to true", + "label": "Always enable RTP" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.NMOS_multidevice.disable_rx_sdp": { + "_schema": { + "default": false, + "descriptor": { + "desc": "Configure this unit's receivers with regular transport parameters only", + "label": "Disable Rx SDP" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.NMOS_multidevice.disable_rx_sdp_with_null": { + "_schema": { + "default": true, + "descriptor": { + "desc": "Configures how RX SDPs are disabled. If unchecked, an empty string is used", + "label": "Disable Rx SDP with null" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.NMOS_multidevice.enable_bulk_config": { + "_schema": { + "default": false, + "descriptor": { + "desc": "Configure this unit using bulk API", + "label": "Enable bulk config" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.NMOS_multidevice.enable_experimental_alarm": { + "_schema": { + "default": false, + "descriptor": { + "desc": "Enables experimental alarms over websockets using IS-07 on certain Vizrt devices. Disables alarms completely if disabled", + "label": "Enable experimental alarms using IS-07" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.NMOS_multidevice.experimental_alarm_port": { + "_schema": { + "default": 0, + "descriptor": { + "desc": "HTTP port for location of experimental IS-07 alarm websocket. If empty or 0 it uses Port field instead", + "label": "Experimental alarm port" + }, + "isNullable": true, + "options": [], + "ranges": [ + [ + 1, + 65535, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + }, + "com.nevion.NMOS_multidevice.indices_in_ids": { + "_schema": { + "default": true, + "descriptor": { + "desc": "Enable if device reports static streams to get sortable ids", + "label": "Use indices in IDs" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.NMOS_multidevice.is05_api_version": { + "_schema": { + "default": false, + "descriptor": { + "desc": "Configure IS05 API version to use max", + "label": "Enable Max IS05 API version" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.NMOS_multidevice.port": { + "_schema": { + "default": 80, + "descriptor": { + "desc": "The HTTP port used to reach the Node directly", + "label": "Port" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 1, + 65535, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "A driver for NMOS capable devices tailored for single devices", + "deviceType": "nmos", + "discoveredBy": null, + "exists": "No", + "iconType": "none", + "ipAddress": null, + "label": "NMOS (Multi-device)", + "modules": [], + "name": "NMOS_multidevice", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [ + { + "name": "NMOS", + "swBuildTime": null, + "swVersion": null + } + ], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "MatrixControlLike", + "DeviceLike", + "PortLike", + "CoreLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": false, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.abb_dpa_upscale_st-0.1.0", + "_vid": "com.nevion.abb_dpa_upscale_st-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "abb_dpa_upscale_st" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "Driver for ABB DPA UPScale ST UPS system", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "device", + "ipAddress": null, + "label": "ABB DPA UPScale ST UPS", + "modules": [ + "System" + ], + "name": "abb_dpa_upscale_st", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [ + { + "name": "DPA UPScale ST 40", + "swBuildTime": null, + "swVersion": null + }, + { + "name": "DPA UPScale ST 80", + "swBuildTime": null, + "swVersion": null + } + ], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0", + "1.3.6.1.2.1.33.1.1.1.0", + "1.3.6.1.2.1.33.1.1.2.0" + ], + "snmpSysObjectIdValue": "1.3.6.1.2.1.33", + "status": "", + "supportedApis": [ + "ParameterControlLike", + "CoreLike", + "DeviceLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.adva_fsp150-0.1.0", + "_vid": "com.nevion.adva_fsp150-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "adva_fsp150" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "Driver for ADVA FSP 150", + "deviceType": "switch", + "discoveredBy": null, + "exists": "No", + "iconType": "ipSwitchRouter", + "ipAddress": null, + "label": "ADVA FSP 150", + "modules": [], + "name": "adva_fsp150", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": "1.3.6.1.4.1.2544.1.12.1.1.11", + "status": "", + "supportedApis": [ + "ParameterControlLike", + "DeviceLike", + "TestLike", + "CoreLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.adva_fsp150_xg400_series-0.1.0", + "_vid": "com.nevion.adva_fsp150_xg400_series-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "adva_fsp150_xg400_series" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "Driver for ADVA FSP 150-XG400 Series", + "deviceType": "switch", + "discoveredBy": null, + "exists": "No", + "iconType": "ipSwitchRouter", + "ipAddress": null, + "label": "ADVA FSP 150-XG400", + "modules": [], + "name": "adva_fsp150_xg400_series", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": "1.3.6.1.4.1.2544.1.20.2.2", + "status": "", + "supportedApis": [ + "ParameterControlLike", + "DeviceLike", + "TestLike", + "CoreLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.agama_analyzer-0.1.0", + "_vid": "com.nevion.agama_analyzer-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "agama_analyzer" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "Driver for Agama Analyzer devices", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "default", + "ipAddress": null, + "label": "Sky Agama Analyzer", + "modules": [], + "name": "agama_analyzer", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "CoreLike", + "DeviceLike", + "PortLike" + ], + "supportsExternal": true, + "supportsParameterApi": false, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.altum_xavic_decoder-0.1.0", + "_vid": "com.nevion.altum_xavic_decoder-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "altum_xavic_decoder" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "Driver for Altum XVE Decoder", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "decoder", + "ipAddress": null, + "label": "SAPEC Altum II Decoder", + "modules": [], + "name": "altum_xavic_decoder", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [ + { + "name": "AHE 3", + "swBuildTime": null, + "swVersion": null + } + ], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0", + "1.3.6.1.4.1.21664.101.1.1.5.0" + ], + "snmpSysObjectIdValue": "1.3.6.1.4.1.21664.101", + "status": "", + "supportedApis": [ + "ParameterControlLike", + "DeviceLike", + "DynamicLike", + "PortLike", + "CoreLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.altum_xavic_encoder-0.1.0", + "_vid": "com.nevion.altum_xavic_encoder-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "altum_xavic_encoder" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "Driver for Altum XVE Encoder", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "encoder", + "ipAddress": null, + "label": "SAPEC Altum II Encoder", + "modules": [], + "name": "altum_xavic_encoder", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [ + { + "name": "AHE 3", + "swBuildTime": null, + "swVersion": null + } + ], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0", + "1.3.6.1.4.1.21664.101.1.1.5.0" + ], + "snmpSysObjectIdValue": "1.3.6.1.4.1.21664.101", + "status": "", + "supportedApis": [ + "ParameterControlLike", + "DeviceLike", + "DynamicLike", + "PortLike", + "CoreLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.amagi_cloudport-0.1.0", + "_vid": "com.nevion.amagi_cloudport-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "amagi_cloudport" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom setting fields for Amagi Cloudport", + "label": "Amagi Cloudport" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.nevion.amagi_cloudport.port": { + "_schema": { + "default": 4999, + "descriptor": { + "desc": "Port", + "label": "Port" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 0, + 65535, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "Driver for Amagi Cloudport", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "none", + "ipAddress": null, + "label": "Amagi Cloudport", + "modules": [], + "name": "amagi_cloudport", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "CoreLike", + "DeviceLike", + "PortLike" + ], + "supportsExternal": true, + "supportsParameterApi": false, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.amethyst3-0.1.0", + "_vid": "com.nevion.amethyst3-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "amethyst3" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "Redundancy Switch", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "ipSwitchRouter", + "ipAddress": null, + "label": "Thomson AMETHYST III", + "modules": [], + "name": "amethyst3", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [ + { + "name": "AMETHYST III", + "swBuildTime": null, + "swVersion": null + } + ], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": "1.3.6.1.4.1.4947.2.13.11", + "status": "", + "supportedApis": [ + "ParameterControlLike", + "CoreLike", + "DeviceLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.anubis-0.1.0", + "_vid": "com.nevion.anubis-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "anubis" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "none", + "ipAddress": null, + "label": "Merging Anubis", + "modules": [], + "name": "anubis", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "CoreLike", + "DeviceLike", + "PortLike" + ], + "supportsExternal": true, + "supportsParameterApi": false, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.appeartv_x_platform-0.2.0", + "_vid": "com.nevion.appeartv_x_platform-0.2.0", + "attachments": [ + { + "description": "Default", + "name": "appeartv_x_platform" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom setting fields for AppearTV X-Platform devices", + "label": "AppearTV X-Platform" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.nevion.appeartv_x_platform.coder_ip_mapping": { + "_schema": { + "default": "", + "descriptor": { + "desc": "Coder module - IP module association map", + "label": "Coder-IP mapping" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "com.nevion.appeartv_x_platform.lan_wan_mapping": { + "_schema": { + "default": "", + "descriptor": { + "desc": "LAN/WAN module association map", + "label": "LAN-WAN mapping" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "Driver for AppearTV X-Platform devices with dynamic topology", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "none", + "ipAddress": null, + "label": "AppearTV X-Platform (Dynamic)", + "modules": [], + "name": "appeartv_x_platform", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "ParameterControlLike", + "DeviceLike", + "PortLike", + "CoreLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.2.0" + }, + { + "_id": "com.nevion.appeartv_x_platform_static-0.1.0", + "_vid": "com.nevion.appeartv_x_platform_static-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "appeartv_x_platform_static" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom setting fields for AppearTV X-Platform devices", + "label": "AppearTV X-Platform (Static)" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.nevion.appeartv_x_platform_static.implicit_interface_selection": { + "_schema": { + "default": false, + "descriptor": { + "desc": "Select vlan subinterfaces based on vlan in port configuration.", + "label": "Implicit Interface Selection" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "Driver for AppearTV X-Platform devices with static topology", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "none", + "ipAddress": null, + "label": "AppearTV X-Platform (Static)", + "modules": [], + "name": "appeartv_x_platform_static", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "ParameterControlLike", + "DeviceLike", + "PortLike", + "CoreLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.archwave_unet-0.1.0", + "_vid": "com.nevion.archwave_unet-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "archwave_unet" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom settings field for ArchwaveUnet drivers", + "label": "ArchwaveUnet" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.nevion.archwave_unet.channel_mode": { + "_schema": { + "default": "Stereo", + "descriptor": { + "desc": "In Stereo mode the driver will only report one stream consumer (output) to the topology. The driver will automatically configure the second stream consumer based on the received SDP to the former consumer stream\nIn Dual Mono mode both stream consumers will be reported to the topology and handled as individual streams", + "label": "Stream consumer channel mode" + }, + "encoding": "UTF-8", + "gui": { + "tags": [], + "widget": "Dropdown" + }, + "isNullable": false, + "lengthRanges": [], + "options": [ + { + "descriptor": { + "desc": "", + "label": "Dual Mono" + }, + "value": "Dual Mono" + }, + { + "descriptor": { + "desc": "", + "label": "Stereo" + }, + "value": "Stereo" + } + ], + "status": "Current", + "type": "string" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "Driver for Archwave AudioLan/uNet modules", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "gateway", + "ipAddress": null, + "label": "Archwave AudioLan/uNet", + "modules": [], + "name": "archwave_unet", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "CoreLike", + "DeviceLike", + "PortLike" + ], + "supportsExternal": true, + "supportsParameterApi": false, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.arista-0.1.0", + "_vid": "com.nevion.arista-0.1.0", + "attachments": [ + { + "description": "Global ACL rules for Arista, used unless specific is specified.", + "name": "arista_static_acl_global" + }, + { + "description": "Specific ACL rules for Arista, overrides global if defined.", + "name": "arista_static_acl_specific" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom setting fields for Arista", + "label": "Arista" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.nevion.arista.enable_cache": { + "_schema": { + "default": true, + "descriptor": { + "desc": "", + "label": "Enable config related cache" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.arista.multicast_route_ignore": { + "_schema": { + "default": "", + "descriptor": { + "desc": "", + "label": "Multicast routes ignore list, comma separated" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "com.nevion.arista.use_multi_vrf": { + "_schema": { + "default": false, + "descriptor": { + "desc": "", + "label": "Enable multi-VRF functionality" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.arista.use_tls": { + "_schema": { + "default": true, + "descriptor": { + "desc": "", + "label": "Use TLS (no certificate checks)" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.arista.use_twice_nat": { + "_schema": { + "default": false, + "descriptor": { + "desc": "", + "label": "Enable twice NAT functionality" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "Driver for Arista Switch series", + "deviceType": "switch", + "discoveredBy": null, + "exists": "No", + "iconType": "ipSwitchRouter", + "ipAddress": null, + "label": "Arista Switch Series", + "modules": [], + "name": "arista", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0", + "1.3.6.1.2.1.1.1.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "CoreLike", + "DeviceLike", + "StatusLike", + "PortLike" + ], + "supportsExternal": true, + "supportsParameterApi": false, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.ateme_cm4101-0.1.0", + "_vid": "com.nevion.ateme_cm4101-0.1.0", + "attachments": [ + { + "description": "Encoder configuration", + "name": "ateme_cm4101" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "A driver for the Ateme CM4101 encoder", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "encoder", + "ipAddress": null, + "label": "Ateme CM4101", + "modules": [ + "System", + "Encoder 1-N" + ], + "name": "ateme_cm4101", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [ + { + "name": "Ateme CM4101", + "swBuildTime": null, + "swVersion": null + } + ], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.1.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "ParameterControlLike", + "DeviceLike", + "DynamicLike", + "PortLike", + "CoreLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.ateme_cm5000-0.1.0", + "_vid": "com.nevion.ateme_cm5000-0.1.0", + "attachments": [ + { + "description": "Encoder configuration", + "name": "ateme_cm5000.enc" + }, + { + "description": "Ethernet configuration", + "name": "ateme_cm5000.ip" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "A driver for the Ateme Kyrion CM5000 encoder", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "encoder", + "ipAddress": null, + "label": "Ateme Kyrion CM5000", + "modules": [ + "System", + "Encoder 1-N" + ], + "name": "ateme_cm5000", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [ + { + "name": "Ateme Kyrion CM5000", + "swBuildTime": null, + "swVersion": null + } + ], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.4.1.27338.4.2.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "ParameterControlLike", + "DeviceLike", + "DynamicLike", + "PortLike", + "CoreLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.ateme_dr5000-0.1.0", + "_vid": "com.nevion.ateme_dr5000-0.1.0", + "attachments": [ + { + "description": "Decoder configuration", + "name": "ateme_dr5000.dec" + }, + { + "description": "Ethernet configuration", + "name": "ateme_dr5000.ip" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "A driver for the Ateme DR5000 decoder", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "decoder", + "ipAddress": null, + "label": "Ateme DR5000", + "modules": [ + "System", + "Decoder 1-N" + ], + "name": "ateme_dr5000", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [ + { + "name": "DR5000", + "swBuildTime": null, + "swVersion": null + } + ], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.4.1.27338.5.2.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "ParameterControlLike", + "DeviceLike", + "DynamicLike", + "NetworkServiceLike", + "PortLike", + "CoreLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.ateme_dr8400-0.1.0", + "_vid": "com.nevion.ateme_dr8400-0.1.0", + "attachments": [ + { + "description": "Decoder configuration", + "name": "ateme_dr8400" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "A driver for the Ateme DR8400 decoder", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "decoder", + "ipAddress": null, + "label": "Ateme DR8400", + "modules": [ + "System", + "Decoder 1-N" + ], + "name": "ateme_dr8400", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [ + { + "name": "Ateme DR8400", + "swBuildTime": null, + "swVersion": null + } + ], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.1.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "ParameterControlLike", + "DeviceLike", + "DynamicLike", + "NetworkServiceLike", + "PortLike", + "CoreLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.avnpxh12-0.1.0", + "_vid": "com.nevion.avnpxh12-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "avnpxh12" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom setting fields for avnpxh12", + "label": "avnpxh12" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.nevion.emberplus.keepalives": { + "_schema": { + "default": true, + "descriptor": { + "desc": "If selected, keep-alives will be used to determine reachability", + "label": "Send keep-alives" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.emberplus.port": { + "_schema": { + "default": 9000, + "descriptor": { + "desc": "Port", + "label": "Port" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 0, + 65535, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + }, + "com.nevion.emberplus.queue": { + "_schema": { + "default": true, + "descriptor": { + "desc": "", + "label": "Request queueing" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.emberplus.suppress_illegal": { + "_schema": { + "default": false, + "descriptor": { + "desc": "", + "label": "Suppress illegal update warnings" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.emberplus.trace": { + "_schema": { + "default": false, + "descriptor": { + "desc": "", + "label": "Tracing (logging intensive)" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "none", + "ipAddress": null, + "label": "Sonifex AVNPXH12", + "modules": [], + "name": "avnpxh12", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "ParameterControlLike", + "CoreLike", + "DeviceLike", + "PortLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.aws_media-0.1.0", + "_vid": "com.nevion.aws_media-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "aws_media" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom setting fields for aws_media", + "label": "aws_media" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.nevion.aws_media.n_flows": { + "_schema": { + "default": 10, + "descriptor": { + "desc": "Number of MediaConnect flows", + "label": "Max #Flows" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 0, + 1000, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + }, + "com.nevion.aws_media.n_outputs_per_fow": { + "_schema": { + "default": 2, + "descriptor": { + "desc": "Number of outputs per MediaConnect flow", + "label": "Max #Outputs/Flow" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 0, + 50, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "Generic driver for aws services", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "vlanCloud", + "ipAddress": null, + "label": "AWS Media", + "modules": [], + "name": "aws_media", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "MatrixControlLike", + "CoreLike", + "DeviceLike", + "PortLike" + ], + "supportsExternal": true, + "supportsParameterApi": false, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.blade_runner-0.1.0", + "_vid": "com.nevion.blade_runner-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "blade_runner" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom driver settings for Arkona Blade Runner", + "label": "Arkona Blade Runner" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.nevion.blade_runner.matrix": { + "_schema": { + "default": "", + "descriptor": { + "desc": "Example: {\n \"audioShuffler\": {\n \"0\": {\n \"label\": \"Shuffler 1\",\n \"maxOutputChannels\": 16,\n \"rtpInputs\": {\n \"0\": [\n 1,\n 2\n ]\n }\n }\n }\n}", + "label": "AudioShuffler Matrix JSON" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "com.nevion.blade_runner.rtp_receivers": { + "_schema": { + "default": "", + "descriptor": { + "desc": "Example:{\n \"rtpInput\": {\n \"0\": {\n \"label\": \"Input 1\",\n \"maxChannel\": 16,\n \"channelLabel\": [\n \"ex1\",\n \"ex2\"\n ]\n }\n }\n}", + "label": "RTP Receivers JSON" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "Software-defined IP-Routing, Processing & Multi-Viewing Platform", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "none", + "ipAddress": null, + "label": "Arkona Blade Runner", + "modules": [], + "name": "blade_runner", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "DeviceLike", + "PortLike", + "CoreLike", + "StatusLike", + "MatrixControlLike", + "ParameterControlLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.cisco_7600_series-0.1.0", + "_vid": "com.nevion.cisco_7600_series-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "cisco_7600_series" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "Driver for Cisco 7600 series routers", + "deviceType": "switch", + "discoveredBy": null, + "exists": "No", + "iconType": "none", + "ipAddress": null, + "label": "Cisco 7600 Series", + "modules": [], + "name": "cisco_7600_series", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "ParameterControlLike", + "CoreLike", + "DeviceLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.cisco_asr-0.1.0", + "_vid": "com.nevion.cisco_asr-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "cisco_asr" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "Driver for Cisco ASR (Aggregation Services Routers) 9904/9901/920/1002 Routers", + "deviceType": "switch", + "discoveredBy": null, + "exists": "No", + "iconType": "none", + "ipAddress": null, + "label": "Cisco ASR", + "modules": [], + "name": "cisco_asr", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "ParameterControlLike", + "CoreLike", + "DeviceLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.cisco_catalyst_3850-0.1.0", + "_vid": "com.nevion.cisco_catalyst_3850-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "cisco_catalyst_3850" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom setting fields for Catalyst 3850", + "label": "Catalyst 3850" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.nevion.api.sample_flows_interval": { + "_schema": { + "default": 0, + "descriptor": { + "desc": "Interval at which to poll flow stats. 0 to disable.", + "label": "Flow stats interval [s]" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 0, + 0, + 1 + ], + [ + 2, + 3600, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "Driver for Cisco Catalyst 3850 devices", + "deviceType": "switch", + "discoveredBy": null, + "exists": "No", + "iconType": "ipSwitchRouter", + "ipAddress": null, + "label": "Cisco Catalyst 3850", + "modules": [], + "name": "cisco_catalyst_3850", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "netconf": 830, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "ParameterControlLike", + "DeviceLike", + "PortLike", + "CoreLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.cisco_me-0.1.0", + "_vid": "com.nevion.cisco_me-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "cisco_me" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "Driver for Cisco ME3800X/ME3600X Ethernet Switches", + "deviceType": "switch", + "discoveredBy": null, + "exists": "No", + "iconType": "none", + "ipAddress": null, + "label": "Cisco ME", + "modules": [], + "name": "cisco_me", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "ParameterControlLike", + "CoreLike", + "DeviceLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.cisco_ncs540-0.1.0", + "_vid": "com.nevion.cisco_ncs540-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "cisco_ncs540" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "Driver for a Cisco NCS540 SNMP device", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "none", + "ipAddress": null, + "label": "Cisco NCS540 SNMP driver", + "modules": [], + "name": "cisco_ncs540", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "ParameterControlLike", + "CoreLike", + "DeviceLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.cisco_nexus-0.1.0", + "_vid": "com.nevion.cisco_nexus-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "cisco_nexus" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom setting fields for Nexus", + "label": "Nexus" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.nevion.nexus.controlled_vrfs": { + "_schema": { + "default": "", + "descriptor": { + "desc": "Comma-separated lists of VRFs to control. Empty list = all VRFs.", + "label": "Controlled VRFs" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "com.nevion.nexus.full_vrf_control": { + "_schema": { + "default": false, + "descriptor": { + "desc": "True = configure RPF for all/specified VRFs. False = only configure RPF for known source IP adresses.", + "label": "Full VRF Control" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.nexus.layer2_netmask_mode": { + "_schema": { + "default": false, + "descriptor": { + "desc": "Use /31 mroute source address netmask for layer 2 mroutes, i.e. when source address and next-hop are identical.", + "label": "Use /31 mroute netmask for layer 2" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.nexus.periodic_netconf_restart": { + "_schema": { + "default": 0, + "descriptor": { + "desc": "Interval in seconds for periodic netconf connection restart. If 0, no restart is performed.", + "label": "Restart netconf every (s)" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 0, + 2147483647, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "Driver for Cisco Nexus devices", + "deviceType": "switch", + "discoveredBy": null, + "exists": "No", + "iconType": "ipSwitchRouter", + "ipAddress": null, + "label": "Cisco Nexus (NX-OS)", + "modules": [], + "name": "cisco_nexus", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "ParameterControlLike", + "DeviceLike", + "DynamicLike", + "PortLike", + "CoreLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.cisco_nexus_nbm-0.1.0", + "_vid": "com.nevion.cisco_nexus_nbm-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "cisco_nexus_nbm" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom setting fields for Cisco Nexus NBM", + "label": "Cisco Nexus NBM" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.nevion.api.sample_flows_interval": { + "_schema": { + "default": 0, + "descriptor": { + "desc": "Interval at which to poll flow stats. 0 to disable.", + "label": "Flow stats interval [s]" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 0, + 0, + 1 + ], + [ + 2, + 3600, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + }, + "com.nevion.cisco_nexus_nbm.use_nat": { + "_schema": { + "default": false, + "descriptor": { + "desc": "", + "label": "Enable NAT functionality" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "Driver for Cisco Nexus devices with non-blocking multicast (NBM) process", + "deviceType": "switch", + "discoveredBy": null, + "exists": "No", + "iconType": "none", + "ipAddress": null, + "label": "Cisco Nexus (NBM)", + "modules": [], + "name": "cisco_nexus_nbm", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "ParameterControlLike", + "DeviceLike", + "PortLike", + "CoreLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.comprimato-0.1.0", + "_vid": "com.nevion.comprimato-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "comprimato" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "A driver for Comprimato", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "none", + "ipAddress": null, + "label": "Comprimato Driver", + "modules": [], + "name": "comprimato", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "CoreLike", + "DeviceLike", + "StatusLike", + "PortLike" + ], + "supportsExternal": true, + "supportsParameterApi": false, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.cp330-0.1.0", + "_vid": "com.nevion.cp330-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "cp330" + } + ], + "configurableDevice": true, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "Driver for the Nevion CP330 T2-Bridge", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "transportStreamProcessor", + "ipAddress": null, + "label": "Nevion CP330", + "modules": [], + "name": "cp330", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [ + { + "name": "CP330", + "swBuildTime": null, + "swVersion": null + } + ], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": "1.3.6.1.4.1.22909.3.28", + "status": "", + "supportedApis": [ + "ParameterControlLike", + "DeviceLike", + "DynamicLike", + "MaintenanceLike", + "CoreLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": true, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.cp4400-0.1.0", + "_vid": "com.nevion.cp4400-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "cp4400" + } + ], + "configurableDevice": true, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom setting fields for Platform4000", + "label": "Platform4000" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.nevion.null.reuse_ts_element": { + "_schema": { + "default": false, + "descriptor": { + "desc": "", + "label": "Enable to activate logic to join existing TS input element for ASI outputs when setting up multicast with identical settings" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "Driver for the Nevion CP4400", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "transportStreamProcessor", + "ipAddress": null, + "label": "Nevion CP4400", + "modules": [], + "name": "cp4400", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [ + { + "name": "CP4400", + "swBuildTime": null, + "swVersion": null + } + ], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": "1.3.6.1.4.1.8768.10.38", + "status": "", + "supportedApis": [ + "ParameterControlLike", + "DeviceLike", + "DynamicLike", + "PortLike", + "CoreLike", + "StatusLike", + "MaintenanceLike", + "NetworkServiceLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [ + { + "descriptor": { + "desc": "Last bitrate measured.", + "label": "Ts Pid Bitrate" + }, + "id": "ts.pid.bit.rate", + "isAlarmTrigger": false, + "isCounter": false, + "unit": "" + }, + { + "descriptor": { + "desc": "Last average bitrate measured.", + "label": "Ts Pid Average Bitrate" + }, + "id": "ts.pid.average.bit.rate", + "isAlarmTrigger": false, + "isCounter": false, + "unit": "" + }, + { + "descriptor": { + "desc": "Continuity error counter.", + "label": "Ts Pid Continuity Error Counter" + }, + "id": "ts.pid.continuity.error.counter", + "isAlarmTrigger": false, + "isCounter": false, + "unit": "" + }, + { + "descriptor": { + "desc": "Last bitrate sampled for program.", + "label": "Ts Service Bitrate" + }, + "id": "ts.service.bit.rate", + "isAlarmTrigger": false, + "isCounter": false, + "unit": "" + }, + { + "descriptor": { + "desc": "Effective bitrate, i.e., bitrate without null packets.", + "label": "Ts Effective Bitrate" + }, + "id": "ts.effective.bit.rate", + "isAlarmTrigger": false, + "isCounter": false, + "unit": "" + }, + { + "descriptor": { + "desc": "Total bitrate.", + "label": "Ts Total Bitrate" + }, + "id": "ts.total.bit.rate", + "isAlarmTrigger": false, + "isCounter": false, + "unit": "" + }, + { + "descriptor": { + "desc": "Inter arrival time, i.e., the time between arrivals into the system.", + "label": "Tsoip Rx Sips Iat" + }, + "id": "tsoip.rx.sips.iat", + "isAlarmTrigger": false, + "isCounter": false, + "unit": "" + }, + { + "descriptor": { + "desc": "Packet delay factor.", + "label": "Tsoip Rx Sips Pdv" + }, + "id": "tsoip.rx.sips.pdv", + "isAlarmTrigger": false, + "isCounter": false, + "unit": "" + }, + { + "descriptor": { + "desc": "Current buffer.", + "label": "Tsoip Rx Buff Lat" + }, + "id": "tsoip.rx.buff.lat", + "isAlarmTrigger": false, + "isCounter": false, + "unit": "" + }, + { + "descriptor": { + "desc": "Current utilization fraction of buffer, with the signal currently received and configured parameters.", + "label": "Tsoip Rx Buff Util" + }, + "id": "tsoip.rx.buff.util", + "isAlarmTrigger": false, + "isCounter": false, + "unit": "" + }, + { + "descriptor": { + "desc": "SFP Channel internal bias current", + "label": "Sfp Internal Current" + }, + "id": "sfp.internal.current", + "isAlarmTrigger": false, + "isCounter": false, + "unit": "mA" + }, + { + "descriptor": { + "desc": "SFP Channel Internal Vcc", + "label": "Sfp Internal Voltage" + }, + "id": "sfp.internal.voltage", + "isAlarmTrigger": false, + "isCounter": false, + "unit": "V" + }, + { + "descriptor": { + "desc": "SFP Channel internal temperature", + "label": "Sfp Internal Temp" + }, + "id": "sfp.internal.temp", + "isAlarmTrigger": false, + "isCounter": false, + "unit": "C" + }, + { + "descriptor": { + "desc": "SFP Channel RX input power", + "label": "Sfp Rx Effect" + }, + "id": "sfp.rx.effect", + "isAlarmTrigger": false, + "isCounter": false, + "unit": "mW" + }, + { + "descriptor": { + "desc": "SFP Channel RX input power", + "label": "Sfp Rx Effect Log" + }, + "id": "sfp.rx.effect.log", + "isAlarmTrigger": false, + "isCounter": false, + "unit": "dBm" + }, + { + "descriptor": { + "desc": "SFP Channel TX output power", + "label": "Sfp Tx Effect" + }, + "id": "sfp.tx.effect", + "isAlarmTrigger": false, + "isCounter": false, + "unit": "mW" + }, + { + "descriptor": { + "desc": "SFP Channel TX output power", + "label": "Sfp Tx Effect Log" + }, + "id": "sfp.tx.effect.log", + "isAlarmTrigger": false, + "isCounter": false, + "unit": "dBm" + } + ], + "updateableDevice": false, + "updateableModule": { + "ASI-Input/Output-Board": { + "availableBanks": [], + "rebootOption": 0 + }, + "DVB-T/T2-Demodulator": { + "availableBanks": [], + "rebootOption": 0 + }, + "GNSS-clock-reference-board": { + "availableBanks": [], + "rebootOption": 0 + }, + "Main-Board": { + "availableBanks": [], + "rebootOption": 0 + } + }, + "version": "0.1.0" + }, + { + "_id": "com.nevion.cp505-0.1.0", + "_vid": "com.nevion.cp505-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "cp505" + } + ], + "configurableDevice": true, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "Driver for the Nevion CP505 ATSC Processor", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "transportStreamProcessor", + "ipAddress": null, + "label": "Nevion CP505", + "modules": [ + "System", + "Network", + "ASI Inputs", + "ASI Outputs", + "Switch Inputs", + "TS Out", + "IP Inputs" + ], + "name": "cp505", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [ + { + "name": "CP505", + "swBuildTime": null, + "swVersion": null + } + ], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": "1.3.6.1.4.1.22909.3.20", + "status": "", + "supportedApis": [ + "ParameterControlLike", + "DeviceLike", + "DynamicLike", + "MaintenanceLike", + "CoreLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": true, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.cp511-0.1.0", + "_vid": "com.nevion.cp511-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "cp511" + } + ], + "configurableDevice": true, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "Driver for the Nevion CP511 SFN Adapter", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "transportStreamProcessor", + "ipAddress": null, + "label": "Nevion CP511", + "modules": [], + "name": "cp511", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [ + { + "name": "CP511", + "swBuildTime": null, + "swVersion": null + } + ], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": "1.3.6.1.4.1.22909.3.15", + "status": "", + "supportedApis": [ + "ParameterControlLike", + "DeviceLike", + "DynamicLike", + "MaintenanceLike", + "CoreLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": true, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.cp515-0.1.0", + "_vid": "com.nevion.cp515-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "cp515" + } + ], + "configurableDevice": true, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "Driver for the Nevion CP515 SI Manager", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "transportStreamProcessor", + "ipAddress": null, + "label": "Nevion CP515", + "modules": [], + "name": "cp515", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [ + { + "name": "CP515", + "swBuildTime": null, + "swVersion": null + } + ], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": "1.3.6.1.4.1.22909.3.9", + "status": "", + "supportedApis": [ + "ParameterControlLike", + "DeviceLike", + "DynamicLike", + "MaintenanceLike", + "PortLike", + "CoreLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": true, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.cp524-0.1.0", + "_vid": "com.nevion.cp524-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "cp524" + } + ], + "configurableDevice": true, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "Driver for the Nevion CP524 TS Adapter", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "transportStreamProcessor", + "ipAddress": null, + "label": "Nevion CP524", + "modules": [ + "System", + "Network", + "Clock Regulator", + "ASI Inputs", + "ASI Outputs", + "Switches", + "TS Out", + "IP Inputs", + "IP Outputs" + ], + "name": "cp524", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [ + { + "name": "CP524", + "swBuildTime": null, + "swVersion": null + } + ], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": "1.3.6.1.4.1.22909.3.32", + "status": "", + "supportedApis": [ + "ParameterControlLike", + "DeviceLike", + "DynamicLike", + "MaintenanceLike", + "PortLike", + "CoreLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": true, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.cp525-0.1.0", + "_vid": "com.nevion.cp525-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "cp525" + } + ], + "configurableDevice": true, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "Driver for the Nevion CP525 cMUX Remultiplexer", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "transportStreamProcessor", + "ipAddress": null, + "label": "Nevion CP525", + "modules": [ + "System", + "Network", + "Clock Regulator", + "ASI Inputs", + "ASI Outputs", + "TS Out", + "IP Inputs" + ], + "name": "cp525", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [ + { + "name": "CP525", + "swBuildTime": null, + "swVersion": null + } + ], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": "1.3.6.1.4.1.22909.3.5", + "status": "", + "supportedApis": [ + "ParameterControlLike", + "DeviceLike", + "DynamicLike", + "MaintenanceLike", + "CoreLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": true, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.cp540-0.1.0", + "_vid": "com.nevion.cp540-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "cp540" + } + ], + "configurableDevice": true, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "Driver for the Nevion CP540 TS Monitoring Switch", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "transportStreamProcessor", + "ipAddress": null, + "label": "Nevion CP540", + "modules": [ + "System", + "Network", + "Clock Regulator", + "ASI Inputs" + ], + "name": "cp540", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [ + { + "name": "CP540", + "swBuildTime": null, + "swVersion": null + } + ], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": "1.3.6.1.4.1.22909.3.6", + "status": "", + "supportedApis": [ + "ParameterControlLike", + "DeviceLike", + "DynamicLike", + "MaintenanceLike", + "CoreLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": true, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.cp560-0.1.0", + "_vid": "com.nevion.cp560-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "cp560" + } + ], + "configurableDevice": true, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "Driver for the Nevion CP560 DVB-T2 Gateway", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "transportStreamProcessor", + "ipAddress": null, + "label": "Nevion CP560", + "modules": [ + "System", + "Network", + "Clock Regulator", + "ASI Inputs", + "ASI Outputs", + "Switch Inputs", + "T2 Outputs", + "IP Inputs" + ], + "name": "cp560", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [ + { + "name": "CP560", + "swBuildTime": null, + "swVersion": null + } + ], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": "1.3.6.1.4.1.22909.3.14", + "status": "", + "supportedApis": [ + "ParameterControlLike", + "DeviceLike", + "DynamicLike", + "MaintenanceLike", + "CoreLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": true, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.demo-tns-0.1.0", + "_vid": "com.nevion.demo-tns-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "demo-tns" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "A demo driver that fakes access towards a Nevion TNS device for monitoring", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "monitor", + "ipAddress": null, + "label": "Demo TNS", + "modules": [], + "name": "demo-tns", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [ + { + "name": "Dummy TNS4200", + "swBuildTime": null, + "swVersion": null + } + ], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "ParameterControlLike", + "DeviceLike", + "NetworkServiceLike", + "PortLike", + "CoreLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.device_up_driver-0.1.0", + "_vid": "com.nevion.device_up_driver-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "device_up_driver" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom setting fields for DeviceUpDriver family", + "label": "DeviceUpDriver family" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.nevion.device_up_driver.retries": { + "_schema": { + "default": 1, + "descriptor": { + "desc": "The number of times the device will check reachability.", + "label": "Number of retries" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 1, + 20, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + }, + "com.nevion.device_up_driver.timeout": { + "_schema": { + "default": 5, + "descriptor": { + "desc": "Timeout in seconds. Upon reaching the timeout, the cache is considered stale and will be invalidated.", + "label": "Timeout [s]" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 0, + 20, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "none", + "ipAddress": null, + "label": "Device Up Driver (Ping)", + "modules": [], + "name": "device_up_driver", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "CoreLike", + "DeviceLike" + ], + "supportsExternal": false, + "supportsParameterApi": false, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.dhd_series52-0.1.0", + "_vid": "com.nevion.dhd_series52-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "dhd_series52" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom setting fields for dhd_series52", + "label": "dhd_series52" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.nevion.emberplus.keepalives": { + "_schema": { + "default": true, + "descriptor": { + "desc": "If selected, keep-alives will be used to determine reachability", + "label": "Send keep-alives" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.emberplus.port": { + "_schema": { + "default": 9000, + "descriptor": { + "desc": "Port", + "label": "Port" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 0, + 65535, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + }, + "com.nevion.emberplus.queue": { + "_schema": { + "default": true, + "descriptor": { + "desc": "", + "label": "Request queueing" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.emberplus.suppress_illegal": { + "_schema": { + "default": false, + "descriptor": { + "desc": "", + "label": "Suppress illegal update warnings" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.emberplus.trace": { + "_schema": { + "default": false, + "descriptor": { + "desc": "", + "label": "Tracing (logging intensive)" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "Driver for DHD.audio", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "none", + "ipAddress": null, + "label": "DBD Series 52", + "modules": [], + "name": "dhd_series52", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "ParameterControlLike", + "DeviceLike", + "PortLike", + "CoreLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.dhd_xc3core-0.1.0", + "_vid": "com.nevion.dhd_xc3core-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "dhd_xc3core" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom setting fields for DHD Audio XC3 Core", + "label": "DHD Audio XC3 Core" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.nevion.dhd_xc3core.gpi_range": { + "_schema": { + "default": "", + "descriptor": { + "desc": "GPI as ranges or values, e.g., start-end,single,start-end", + "label": "GPI Range" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "com.nevion.dhd_xc3core.gpo_range": { + "_schema": { + "default": "", + "descriptor": { + "desc": "GPO as ranges or values, e.g., start-end,single,start-end", + "label": "GPO Range" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "com.nevion.dhd_xc3core.token": { + "_schema": { + "default": "", + "descriptor": { + "desc": "DHD Token", + "label": "Token" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "com.nevion.dhd_xc3core.user": { + "_schema": { + "default": "BCS", + "descriptor": { + "desc": "DHD User", + "label": "User" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "Driver for DHD Audio XC3 Core", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "none", + "ipAddress": null, + "label": "DHD Audio XC3 Core", + "modules": [], + "name": "dhd_xc3core", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [ + { + "name": "DHD Audio", + "swBuildTime": null, + "swVersion": null + } + ], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "TallyLike", + "GPIOLike", + "CoreLike", + "DeviceLike" + ], + "supportsExternal": true, + "supportsParameterApi": false, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.directout_prodigy_gpio-0.1.0", + "_vid": "com.nevion.directout_prodigy_gpio-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "directout_prodigy_gpio" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "Multiformat audio matrix", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "none", + "ipAddress": null, + "label": "PRODIGY-MX-GPIO", + "modules": [], + "name": "directout_prodigy_gpio", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "ParameterControlLike", + "GPIOLike", + "CoreLike", + "DeviceLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.dse892-0.1.0", + "_vid": "com.nevion.dse892-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "dse892" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "Simple Network Management Protocol (SNMP) Gateway", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "device", + "ipAddress": null, + "label": "Kohler DSE892", + "modules": [], + "name": "dse892", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [ + { + "name": "DSE892", + "swBuildTime": null, + "swVersion": null + } + ], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": "1.3.6.1.4.1.41385.1", + "status": "", + "supportedApis": [ + "ParameterControlLike", + "CoreLike", + "DeviceLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.dyvi-0.1.0", + "_vid": "com.nevion.dyvi-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "dyvi" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "none", + "ipAddress": null, + "label": "EVS Dyvi", + "modules": [], + "name": "dyvi", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "ParameterControlLike", + "CoreLike", + "DeviceLike", + "PortLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.electra-0.1.0", + "_vid": "com.nevion.electra-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "electra" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "Universal Multi-Service Encoder", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "gateway", + "ipAddress": null, + "label": "Electra", + "modules": [], + "name": "electra", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": "0.0", + "status": "", + "supportedApis": [ + "ParameterControlLike", + "CoreLike", + "DeviceLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.elemental_live-01.01.01", + "_vid": "com.nevion.elemental_live-01.01.01", + "attachments": [ + { + "description": "Default", + "name": "elemental_live" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "Broadcast and live streaming encoder", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "encoder", + "ipAddress": null, + "label": "Elemental Live", + "modules": [], + "name": "elemental_live", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [ + { + "name": "Live", + "swBuildTime": null, + "swVersion": null + } + ], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "ParameterControlLike", + "DeviceLike", + "PortLike", + "CoreLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "01.01.01" + }, + { + "_id": "com.nevion.embrionix_sfp-0.1.0", + "_vid": "com.nevion.embrionix_sfp-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "embrionix_sfp" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "Driver for Embrionix SFPs", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "gateway", + "ipAddress": null, + "label": "Embrionix SFP", + "modules": [], + "name": "embrionix_sfp", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "CoreLike", + "DeviceLike", + "StatusLike", + "PortLike" + ], + "supportsExternal": true, + "supportsParameterApi": false, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.emerge_enterprise-0.0.1", + "_vid": "com.nevion.emerge_enterprise-0.0.1", + "attachments": [ + { + "description": "Default", + "name": "emerge_enterprise" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "A driver for eMerge Enterprise devices (via an SNMP interface)", + "deviceType": "switch", + "discoveredBy": null, + "exists": "No", + "iconType": "none", + "ipAddress": null, + "label": "Nevion eMerge (SNMP monitoring)", + "modules": [], + "name": "emerge_enterprise", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [ + { + "name": "eMerge Enterprise", + "swBuildTime": null, + "swVersion": null + } + ], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "ParameterControlLike", + "CoreLike", + "DeviceLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.0.1" + }, + { + "_id": "com.nevion.emerge_openflow-0.0.1", + "_vid": "com.nevion.emerge_openflow-0.0.1", + "attachments": [ + { + "description": "Default", + "name": "emerge_openflow" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom setting fields for Openflow drivers", + "label": "Openflow" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.nevion.api.sample_flows_interval": { + "_schema": { + "default": 0, + "descriptor": { + "desc": "Interval at which to poll flow stats. 0 to disable.", + "label": "Flow stats interval [s]" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 0, + 0, + 1 + ], + [ + 2, + 3600, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + }, + "com.nevion.emerge_openflow.ipv4address": { + "_schema": { + "default": "", + "descriptor": { + "desc": "Required when using DPID as main address instead of IPv4 (cluster)", + "label": "IPv4 address" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "com.nevion.openflow_allow_groups": { + "_schema": { + "default": true, + "descriptor": { + "desc": "Allow use of group actions in flows", + "label": "Allow groups" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.openflow_flow_priority": { + "_schema": { + "default": 60000, + "descriptor": { + "desc": "Flow priority used by videoipath", + "label": "Flow Priority" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 2, + 65535, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + }, + "com.nevion.openflow_interface_shutdown_alarms": { + "_schema": { + "default": false, + "descriptor": { + "desc": "Allow service correlated alarms when admin shuts down an interface", + "label": "Interface shutdown alarms" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.openflow_max_buckets": { + "_schema": { + "default": 65535, + "descriptor": { + "desc": "Max number of buckets in an openflow group", + "label": "Max buckets" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 2, + 65535, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + }, + "com.nevion.openflow_max_groups": { + "_schema": { + "default": 65535, + "descriptor": { + "desc": "Max number of groups on the switch", + "label": "Max groups" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 1, + 65535, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + }, + "com.nevion.openflow_max_meters": { + "_schema": { + "default": 65535, + "descriptor": { + "desc": "Max number of meters on the switch", + "label": "Max meters" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 2, + 65535, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + }, + "com.nevion.openflow_table_id": { + "_schema": { + "default": 0, + "descriptor": { + "desc": "Table ID to use for videoipath flows", + "label": "Table ID" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 0, + 255, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "A driver for eMerge Openflow devices (via an Openflow controller)", + "deviceType": "switch", + "discoveredBy": null, + "exists": "No", + "iconType": "ipSwitchRouter", + "ipAddress": null, + "label": "Nevion eMerge (Openflow + SNMP)", + "modules": [], + "name": "emerge_openflow", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [ + { + "name": "eMerge Openflow", + "swBuildTime": null, + "swVersion": null + } + ], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [], + "snmpSysObjectIdValue": "1.3.6.1.4.1.27975.99.0", + "status": "", + "supportedApis": [ + "ParameterControlLike", + "DeviceLike", + "PortLike", + "CoreLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.0.1" + }, + { + "_id": "com.nevion.ericsson_avp2000-0.1.0", + "_vid": "com.nevion.ericsson_avp2000-0.1.0", + "attachments": [ + { + "description": "Map from vbi line number to value", + "name": "ericsson_avp_teletext" + }, + { + "description": "Info about which alarms to mask", + "name": "ericsson_avp_alarm_masking" + } + ], + "configurableDevice": true, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom setting fields for Ericsson devices", + "label": "Ericsson" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.nevion.ericsson.use_alarm_map": { + "_schema": { + "default": true, + "descriptor": { + "desc": "If enabled, only relevant alerts will be raised.", + "label": "Map alarms" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "Driver for the Ericsson AVP 2000 and 4000", + "deviceType": "switch", + "discoveredBy": null, + "exists": "No", + "iconType": "encoder", + "ipAddress": null, + "label": "Ericsson AVP", + "modules": [], + "name": "ericsson_avp2000", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [ + { + "name": "AVP", + "swBuildTime": null, + "swVersion": null + } + ], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0", + "1.3.6.1.4.1.1773.1.1.1.7.0" + ], + "snmpSysObjectIdValue": "1.3.6.1.4.1.1773", + "status": "", + "supportedApis": [ + "ParameterControlLike", + "DeviceLike", + "DynamicLike", + "MaintenanceLike", + "PortLike", + "CoreLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.ericsson_ce-0.1.0", + "_vid": "com.nevion.ericsson_ce-0.1.0", + "attachments": [ + { + "description": "Map from vbi line number to value", + "name": "ericsson_avp_teletext" + }, + { + "description": "Info about which alarms to mask", + "name": "ericsson_avp_alarm_masking" + } + ], + "configurableDevice": true, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom setting fields for Ericsson devices", + "label": "Ericsson" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.nevion.ericsson.use_alarm_map": { + "_schema": { + "default": true, + "descriptor": { + "desc": "If enabled, only relevant alerts will be raised.", + "label": "Map alarms" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "Driver for the Ericsson CE", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "encoder", + "ipAddress": null, + "label": "Ericsson CE", + "modules": [], + "name": "ericsson_ce", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [ + { + "name": "CE", + "swBuildTime": null, + "swVersion": null + } + ], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0", + "1.3.6.1.4.1.1773.1.1.1.7.0" + ], + "snmpSysObjectIdValue": "1.3.6.1.4.1.1773", + "status": "", + "supportedApis": [ + "ParameterControlLike", + "DeviceLike", + "DynamicLike", + "MaintenanceLike", + "PortLike", + "CoreLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.ericsson_rx8200-0.1.0", + "_vid": "com.nevion.ericsson_rx8200-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "ericsson_rx8200" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom setting fields for Ericsson devices", + "label": "Ericsson" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.nevion.ericsson.use_alarm_map": { + "_schema": { + "default": true, + "descriptor": { + "desc": "If enabled, only relevant alerts will be raised.", + "label": "Map alarms" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "Driver for the Ericsson RX8200 Advanced Modular Receiver", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "decoder", + "ipAddress": null, + "label": "Ericsson RX8200", + "modules": [ + "RX8000", + "IP Out", + "Audio 1", + "HD Output", + "Multi Standard Decoder", + "CA Lite", + "Control Interface" + ], + "name": "ericsson_rx8200", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [ + { + "name": "RX8200", + "swBuildTime": null, + "swVersion": null + } + ], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0", + "1.3.6.1.4.1.1773.1.1.1.7.0" + ], + "snmpSysObjectIdValue": "1.3.6.1.4.1.1773", + "status": "", + "supportedApis": [ + "ParameterControlLike", + "DeviceLike", + "DynamicLike", + "PortLike", + "CoreLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.evertz_500fc-0.1.0", + "_vid": "com.nevion.evertz_500fc-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "evertz_500fc" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "none", + "ipAddress": null, + "label": "Evertz 500FC", + "modules": [], + "name": "evertz_500fc", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": "1.3.6.1.4.1.6827", + "status": "", + "supportedApis": [ + "ParameterControlLike", + "DeviceLike", + "DynamicLike", + "CoreLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.evertz_570fc-0.1.0", + "_vid": "com.nevion.evertz_570fc-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "evertz_570fc" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "Driver for Evertz 570 Frame Controller", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "default", + "ipAddress": null, + "label": "Evertz 570 FC", + "modules": [], + "name": "evertz_570fc", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "CoreLike", + "DeviceLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": false, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.evertz_570itxe_hw_p60_udc-0.1.0", + "_vid": "com.nevion.evertz_570itxe_hw_p60_udc-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "evertz_570itxe_hw_p60_udc" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "Driver for Evertz 570ITXE-HW-P60 Multi-Channel J2K Encoder/Decoder", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "default", + "ipAddress": null, + "label": "Evertz 570ITXE-HW-P60", + "modules": [], + "name": "evertz_570itxe_hw_p60_udc", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "CoreLike", + "DeviceLike", + "StatusLike", + "PortLike" + ], + "supportsExternal": true, + "supportsParameterApi": false, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.evertz_570j2k_x19_12e-0.1.0", + "_vid": "com.nevion.evertz_570j2k_x19_12e-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "evertz_570j2k_x19_12e" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "Driver for Evertz 570J2K-HW-X19 Multi-Channel J2K Encoder/Decoder, app mode 12E", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "encoderDecoder", + "ipAddress": null, + "label": "Evertz 570J2K-HW-X19 (12E)", + "modules": [], + "name": "evertz_570j2k_x19_12e", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "CoreLike", + "DeviceLike", + "StatusLike", + "PortLike" + ], + "supportsExternal": true, + "supportsParameterApi": false, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.evertz_570j2k_x19_6e6d-0.1.0", + "_vid": "com.nevion.evertz_570j2k_x19_6e6d-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "evertz_570j2k_x19_6e6d" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "Driver for Evertz 570J2K-HW-X19 Multi-Channel J2K Encoder/Decoder, app mode 6E6D", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "encoderDecoder", + "ipAddress": null, + "label": "Evertz 570J2K-HW-X19 (6E6D)", + "modules": [], + "name": "evertz_570j2k_x19_6e6d", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "CoreLike", + "DeviceLike", + "StatusLike", + "PortLike" + ], + "supportsExternal": true, + "supportsParameterApi": false, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.evertz_570j2k_x19_u9d-0.1.0", + "_vid": "com.nevion.evertz_570j2k_x19_u9d-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "evertz_570j2k_x19_u9d" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "Driver for Evertz 570J2K-HW-X19 Multi-Channel J2K Encoder/Decoder, app mode U9D", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "encoderDecoder", + "ipAddress": null, + "label": "Evertz 570J2K-HW-X19 (U9D)", + "modules": [], + "name": "evertz_570j2k_x19_u9d", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "CoreLike", + "DeviceLike", + "StatusLike", + "PortLike" + ], + "supportsExternal": true, + "supportsParameterApi": false, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.evertz_570j2k_x19_u9e-0.1.0", + "_vid": "com.nevion.evertz_570j2k_x19_u9e-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "evertz_570j2k_x19_u9e" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "Driver for Evertz 570J2K-HW-X19 Multi-Channel J2K Encoder/Decoder, app mode U9E", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "encoderDecoder", + "ipAddress": null, + "label": "Evertz 570J2K-HW-X19 (U9E)", + "modules": [], + "name": "evertz_570j2k_x19_u9e", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "CoreLike", + "DeviceLike", + "StatusLike", + "PortLike" + ], + "supportsExternal": true, + "supportsParameterApi": false, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.evertz_5782dec-0.1.0", + "_vid": "com.nevion.evertz_5782dec-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "evertz_5782dec" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom setting fields for Evertz drivers", + "label": "Evertz" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.nevion.evertz.enable_frame_controller": { + "_schema": { + "default": false, + "descriptor": { + "desc": "Control card through Frame Controller", + "label": "Enable Frame Controller" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.evertz.frame_controller_slot": { + "_schema": { + "default": 1, + "descriptor": { + "desc": "Defines which slot will be used for communication", + "label": "Frame Controller Slot" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 1, + 15, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "Driver for Evertz 5782 decoder", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "decoder", + "ipAddress": null, + "label": "Evertz 5782 Decoder", + "modules": [], + "name": "evertz_5782dec", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "CoreLike", + "DeviceLike", + "StatusLike", + "PortLike" + ], + "supportsExternal": true, + "supportsParameterApi": false, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.evertz_5782enc-0.1.0", + "_vid": "com.nevion.evertz_5782enc-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "evertz_5782enc" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom setting fields for Evertz drivers", + "label": "Evertz" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.nevion.evertz.enable_frame_controller": { + "_schema": { + "default": false, + "descriptor": { + "desc": "Control card through Frame Controller", + "label": "Enable Frame Controller" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.evertz.frame_controller_slot": { + "_schema": { + "default": 1, + "descriptor": { + "desc": "Defines which slot will be used for communication", + "label": "Frame Controller Slot" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 1, + 15, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "Driver for Evertz 5782 encoder", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "encoder", + "ipAddress": null, + "label": "Evertz 5782 Encoder", + "modules": [], + "name": "evertz_5782enc", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "CoreLike", + "DeviceLike", + "StatusLike", + "PortLike" + ], + "supportsExternal": true, + "supportsParameterApi": false, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.evertz_7800fc-0.1.0", + "_vid": "com.nevion.evertz_7800fc-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "evertz_7800fc" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "Driver for Evertz 7800 Frame Controller", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "default", + "ipAddress": null, + "label": "Evertz 7800 FC", + "modules": [], + "name": "evertz_7800fc", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "CoreLike", + "DeviceLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": false, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.evertz_7880ipg8_10ge2-0.1.0", + "_vid": "com.nevion.evertz_7880ipg8_10ge2-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "evertz_7880ipg8_10ge2" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "Media gateway", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "none", + "ipAddress": null, + "label": "Evertz 7880IPG8-10GE2", + "modules": [], + "name": "evertz_7880ipg8_10ge2", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "ParameterControlLike", + "CoreLike", + "DeviceLike", + "PortLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.evertz_7882dec-0.1.0", + "_vid": "com.nevion.evertz_7882dec-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "evertz_7882dec" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom setting fields for Evertz drivers", + "label": "Evertz" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.nevion.evertz.enable_frame_controller": { + "_schema": { + "default": false, + "descriptor": { + "desc": "Control card through Frame Controller", + "label": "Enable Frame Controller" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.evertz.frame_controller_slot": { + "_schema": { + "default": 1, + "descriptor": { + "desc": "Defines which slot will be used for communication", + "label": "Frame Controller Slot" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 1, + 15, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "Driver for Evertz 7882 decoder", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "decoder", + "ipAddress": null, + "label": "Evertz 7882 Decoder", + "modules": [], + "name": "evertz_7882dec", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "CoreLike", + "DeviceLike", + "StatusLike", + "PortLike" + ], + "supportsExternal": true, + "supportsParameterApi": false, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.evertz_7882enc-0.1.0", + "_vid": "com.nevion.evertz_7882enc-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "evertz_7882enc" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom setting fields for Evertz drivers", + "label": "Evertz" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.nevion.evertz.enable_frame_controller": { + "_schema": { + "default": false, + "descriptor": { + "desc": "Control card through Frame Controller", + "label": "Enable Frame Controller" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.evertz.frame_controller_slot": { + "_schema": { + "default": 1, + "descriptor": { + "desc": "Defines which slot will be used for communication", + "label": "Frame Controller Slot" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 1, + 15, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "Driver for Evertz 7882 encoder", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "encoder", + "ipAddress": null, + "label": "Evertz 7882 Encoder", + "modules": [], + "name": "evertz_7882enc", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "CoreLike", + "DeviceLike", + "StatusLike", + "PortLike" + ], + "supportsExternal": true, + "supportsParameterApi": false, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.evertz_quartz-0.1.0", + "_vid": "com.nevion.evertz_quartz-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "evertz_quartz" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom setting fields for Evertz Quartz", + "label": "Evertz Quartz" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.nevion.evertz_quartz.destination": { + "_schema": { + "default": 32, + "descriptor": { + "desc": "Max number of destinations", + "label": "Destinations" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 0, + 32, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + }, + "com.nevion.evertz_quartz.port": { + "_schema": { + "default": 10023, + "descriptor": { + "desc": "Port", + "label": "Port" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 0, + 65535, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + }, + "com.nevion.evertz_quartz.sources": { + "_schema": { + "default": 32, + "descriptor": { + "desc": "Max number of sources", + "label": "Sources" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 0, + 32, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "Driver for Evertz Quartz", + "deviceType": "switch", + "discoveredBy": null, + "exists": "No", + "iconType": "none", + "ipAddress": null, + "label": "Evertz Quartz", + "modules": [], + "name": "evertz_quartz", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "CoreLike", + "DeviceLike", + "PortLike" + ], + "supportsExternal": true, + "supportsParameterApi": false, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.flexAI-0.1.0", + "_vid": "com.nevion.flexAI-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "flexAI" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom setting fields for flexAI", + "label": "flexAI" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.nevion.emberplus.keepalives": { + "_schema": { + "default": true, + "descriptor": { + "desc": "If selected, keep-alives will be used to determine reachability", + "label": "Send keep-alives" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.emberplus.port": { + "_schema": { + "default": 9000, + "descriptor": { + "desc": "Port", + "label": "Port" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 0, + 65535, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + }, + "com.nevion.emberplus.queue": { + "_schema": { + "default": true, + "descriptor": { + "desc": "", + "label": "Request queueing" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.emberplus.suppress_illegal": { + "_schema": { + "default": false, + "descriptor": { + "desc": "", + "label": "Suppress illegal update warnings" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.emberplus.trace": { + "_schema": { + "default": false, + "descriptor": { + "desc": "", + "label": "Tracing (logging intensive)" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "A driver for the Flexible Audio Infrastructure (FlexAI) made by Jünger", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "none", + "ipAddress": null, + "label": "Jünger FlexAI", + "modules": [], + "name": "flexAI", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "ParameterControlLike", + "CoreLike", + "DeviceLike", + "PortLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.generic_emberplus-0.1.0", + "_vid": "com.nevion.generic_emberplus-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "generic_emberplus" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom setting fields for generic_emberplus", + "label": "generic_emberplus" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.nevion.emberplus.keepalives": { + "_schema": { + "default": true, + "descriptor": { + "desc": "If selected, keep-alives will be used to determine reachability", + "label": "Send keep-alives" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.emberplus.port": { + "_schema": { + "default": 9000, + "descriptor": { + "desc": "Port", + "label": "Port" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 0, + 65535, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + }, + "com.nevion.emberplus.queue": { + "_schema": { + "default": true, + "descriptor": { + "desc": "", + "label": "Request queueing" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.emberplus.suppress_illegal": { + "_schema": { + "default": false, + "descriptor": { + "desc": "", + "label": "Suppress illegal update warnings" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.emberplus.trace": { + "_schema": { + "default": false, + "descriptor": { + "desc": "", + "label": "Tracing (logging intensive)" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "Driver for a generic Ember+ device", + "deviceType": "generic", + "discoveredBy": null, + "exists": "No", + "iconType": "none", + "ipAddress": null, + "label": "Generic Ember+ driver", + "modules": [], + "name": "generic_emberplus", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "ParameterControlLike", + "CoreLike", + "DeviceLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.generic_snmp-0.1.0", + "_vid": "com.nevion.generic_snmp-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "generic_snmp" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "Driver for a generic SNMP device", + "deviceType": "generic", + "discoveredBy": null, + "exists": "No", + "iconType": "none", + "ipAddress": null, + "label": "Generic SNMP driver", + "modules": [], + "name": "generic_snmp", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "ParameterControlLike", + "CoreLike", + "DeviceLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.gigacaster2-0.1.0", + "_vid": "com.nevion.gigacaster2-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "gigacaster2" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "Driver for Enensys GigaCaster II", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "gateway", + "ipAddress": null, + "label": "GigaCaster II", + "modules": [], + "name": "gigacaster2", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0", + "1.3.6.1.2.1.1.1.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "ParameterControlLike", + "DeviceLike", + "DynamicLike", + "PortLike", + "CoreLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.gredos-02.22.01", + "_vid": "com.nevion.gredos-02.22.01", + "attachments": [ + { + "description": "Default", + "name": "gredos" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "Broadcast Decoder", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "decoder", + "ipAddress": null, + "label": "SAPEC Gredos", + "modules": [], + "name": "gredos", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [ + { + "name": "GREDOS", + "swBuildTime": null, + "swVersion": null + } + ], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0", + "1.3.6.1.4.1.21664.201.1.1.5.1.2.98.114.97.110.100.73.100" + ], + "snmpSysObjectIdValue": "1.3.6.1.4.1.21664.201.10.10", + "status": "", + "supportedApis": [ + "ParameterControlLike", + "DeviceLike", + "DynamicLike", + "PortLike", + "CoreLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "02.22.01" + }, + { + "_id": "com.nevion.gv_kahuna-0.1.0", + "_vid": "com.nevion.gv_kahuna-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "gv_kahuna" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom setting fields for Grass Valley Kahuna", + "label": "Grass Valley Kahuna" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.nevion.gv_kahuna.port": { + "_schema": { + "default": 2022, + "descriptor": { + "desc": "Port", + "label": "Port" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 0, + 65535, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "Driver for Grass Valley Kahuna Vision Mixers", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "none", + "ipAddress": null, + "label": "GV Kahuna", + "modules": [], + "name": "gv_kahuna", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "CoreLike", + "DeviceLike", + "PortLike" + ], + "supportsExternal": true, + "supportsParameterApi": false, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.haivision-0.0.1", + "_vid": "com.nevion.haivision-0.0.1", + "attachments": [ + { + "description": "Default", + "name": "haivision" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "A driver towards HaiVision codecs, using HaiVision MIB-files", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "encoderDecoder", + "ipAddress": null, + "label": "HaiVision Codec", + "modules": [], + "name": "haivision", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [ + { + "name": "HaiVision Makito2", + "swBuildTime": null, + "swVersion": null + } + ], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "ParameterControlLike", + "DeviceLike", + "DynamicLike", + "PortLike", + "CoreLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.0.1" + }, + { + "_id": "com.nevion.huawei_ce8800_6800-0.1.0", + "_vid": "com.nevion.huawei_ce8800_6800-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "huawei_ce8800_6800" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom setting fields for Huawei Cloud Engine", + "label": "huawei_ce8800_6800" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.nevion.huawei_ce8800_6800.is_upstream": { + "_schema": { + "default": false, + "descriptor": { + "desc": "PIM Upstream is supported only on CE6885 CE8855 with V300R0024 software", + "label": "Enable PIM Upstream" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "Driver for devices in the Huawei CloudEngine 6800/8800 product families", + "deviceType": "switch", + "discoveredBy": null, + "exists": "No", + "iconType": "ipSwitchRouter", + "ipAddress": null, + "label": "Huawei CloudEngine 68XX/88XX", + "modules": [], + "name": "huawei_ce8800_6800", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "netconf": 830, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "CoreLike", + "DeviceLike", + "StatusLike", + "PortLike" + ], + "supportsExternal": true, + "supportsParameterApi": false, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.huawei_cloudengine-0.1.0", + "_vid": "com.nevion.huawei_cloudengine-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "huawei_cloudengine" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "Driver for Huawei CloudEngine devices", + "deviceType": "switch", + "discoveredBy": null, + "exists": "No", + "iconType": "ipSwitchRouter", + "ipAddress": null, + "label": "Huawei CloudEngine", + "modules": [], + "name": "huawei_cloudengine", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "netconf": 830, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "ParameterControlLike", + "DeviceLike", + "PortLike", + "CoreLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.huawei_netengine-0.1.0", + "_vid": "com.nevion.huawei_netengine-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "huawei_netengine" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "Driver for Huawei NetEngine devices", + "deviceType": "switch", + "discoveredBy": null, + "exists": "No", + "iconType": "ipSwitchRouter", + "ipAddress": null, + "label": "Huawei NetEngine", + "modules": [], + "name": "huawei_netengine", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "netconf": 830, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "ParameterControlLike", + "DeviceLike", + "PortLike", + "CoreLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.iothink-0.1.0", + "_vid": "com.nevion.iothink-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "iothink" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "Driver for Moxa ioThink 4510", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "gateway", + "ipAddress": null, + "label": "ioThink 4510", + "modules": [], + "name": "iothink", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "ParameterControlLike", + "GPIOLike", + "CoreLike", + "DeviceLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.iqoyalink_ic-0.1.0", + "_vid": "com.nevion.iqoyalink_ic-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "iqoyalink_ic" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "Driver for Digigram IQOYA *LINK/IC", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "encoderDecoder", + "ipAddress": null, + "label": "IQOYA *LINK/IC", + "modules": [], + "name": "iqoyalink_ic", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0", + "1.3.6.1.4.1.23901.1.3.1.0" + ], + "snmpSysObjectIdValue": "1.3.6.1.4.1.8072.3.2.10", + "status": "", + "supportedApis": [ + "ParameterControlLike", + "DeviceLike", + "PortLike", + "CoreLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.iqoyalink_le-0.1.0", + "_vid": "com.nevion.iqoyalink_le-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "iqoyalink_le" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "Driver for Digigram IQOYA *LINK/LE", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "encoderDecoder", + "ipAddress": null, + "label": "IQOYA *LINK/LE", + "modules": [], + "name": "iqoyalink_le", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0", + "1.3.6.1.4.1.23901.1.3.1.0" + ], + "snmpSysObjectIdValue": "1.3.6.1.4.1.8072.3.2.10", + "status": "", + "supportedApis": [ + "ParameterControlLike", + "DeviceLike", + "PortLike", + "CoreLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.juniper_ex-0.1.0", + "_vid": "com.nevion.juniper_ex-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "juniper_ex" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "Driver for Juniper EX devices", + "deviceType": "switch", + "discoveredBy": null, + "exists": "No", + "iconType": "ipSwitchRouter", + "ipAddress": null, + "label": "Juniper EX", + "modules": [], + "name": "juniper_ex", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "ParameterControlLike", + "CoreLike", + "DeviceLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.laguna-0.1.0", + "_vid": "com.nevion.laguna-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "laguna" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "Media processor", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "none", + "ipAddress": null, + "label": "Sapec Laguna", + "modules": [], + "name": "laguna", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [ + { + "name": "LAGUNA", + "swBuildTime": null, + "swVersion": null + } + ], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "CoreLike", + "DeviceLike", + "StatusLike", + "PortLike" + ], + "supportsExternal": true, + "supportsParameterApi": false, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.lawo_ravenna-0.1.0", + "_vid": "com.nevion.lawo_ravenna-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "lawo_ravenna" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom setting fields for lawo_ravenna", + "label": "lawo_ravenna" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.nevion.emberplus.keepalives": { + "_schema": { + "default": true, + "descriptor": { + "desc": "If selected, keep-alives will be used to determine reachability", + "label": "Send keep-alives" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.emberplus.port": { + "_schema": { + "default": 9000, + "descriptor": { + "desc": "Port", + "label": "Port" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 0, + 65535, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + }, + "com.nevion.emberplus.queue": { + "_schema": { + "default": true, + "descriptor": { + "desc": "", + "label": "Request queueing" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.emberplus.request_separation": { + "_schema": { + "default": 0, + "descriptor": { + "desc": "Set to zero to disable.", + "label": "Request Separation [ms]" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 0, + 250, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + }, + "com.nevion.emberplus.suppress_illegal": { + "_schema": { + "default": true, + "descriptor": { + "desc": "", + "label": "Suppress illegal update warnings" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.emberplus.trace": { + "_schema": { + "default": false, + "descriptor": { + "desc": "", + "label": "Tracing (logging intensive)" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.lawo_ravenna.ctrl_local_addr": { + "_schema": { + "default": false, + "descriptor": { + "desc": "", + "label": "Control Local Addresses" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "none", + "ipAddress": null, + "label": "Lawo Ravenna", + "modules": [], + "name": "lawo_ravenna", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "ParameterControlLike", + "DeviceLike", + "PortLike", + "CoreLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.liebert_nx-0.1.0", + "_vid": "com.nevion.liebert_nx-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "liebert_nx" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "A/V Content Monitoring & Multiviewer", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "device", + "ipAddress": null, + "label": "Liebert NX", + "modules": [], + "name": "liebert_nx", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": "1.3.6.1.4.1.2021.250.10", + "status": "", + "supportedApis": [ + "ParameterControlLike", + "CoreLike", + "DeviceLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.lvb440-1.0.0", + "_vid": "com.nevion.lvb440-1.0.0", + "attachments": [ + { + "description": "Default", + "name": "lvb440" + } + ], + "configurableDevice": true, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "A Driver for the monitoring device Leader LVB440", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "monitor", + "ipAddress": null, + "label": "Leader LVB440", + "modules": [], + "name": "lvb440", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [ + { + "name": "LVB440", + "swBuildTime": null, + "swVersion": null + } + ], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "MatrixControlLike", + "DeviceLike", + "MaintenanceLike", + "PortLike", + "CoreLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": false, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "1.0.0" + }, + { + "_id": "com.nevion.m2lx-1.0.0", + "_vid": "com.nevion.m2lx-1.0.0", + "attachments": [ + { + "description": "Default", + "name": "m2lx" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom setting fields for m2lx", + "label": "m2lx" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.nevion.m2lx.auto_start": { + "_schema": { + "default": true, + "descriptor": { + "desc": "", + "label": "Automatically start event" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.m2lx.event_name": { + "_schema": { + "default": "", + "descriptor": { + "desc": "", + "label": "Event Name" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "A Driver for the Sony M2LX", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "videoMixer", + "ipAddress": null, + "label": "Sony M2LX", + "modules": [], + "name": "m2lx", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [ + { + "name": "M2LX", + "swBuildTime": null, + "swVersion": null + } + ], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "CoreLike", + "DeviceLike", + "PortLike" + ], + "supportsExternal": true, + "supportsParameterApi": false, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "1.0.0" + }, + { + "_id": "com.nevion.maxiva-0.1.0", + "_vid": "com.nevion.maxiva-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "maxiva" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": " A/V Content Monitoring & Multiviewer", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "device", + "ipAddress": null, + "label": "Maxiva", + "modules": [], + "name": "maxiva", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [ + { + "name": "MSC2 LITE 1+1", + "swBuildTime": null, + "swVersion": null + }, + { + "name": "UAX-250T2", + "swBuildTime": null, + "swVersion": null + }, + { + "name": "UAX-500T2", + "swBuildTime": null, + "swVersion": null + } + ], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": "1.3.6.1.4.1.290.9.2.1.1", + "status": "", + "supportedApis": [ + "ParameterControlLike", + "CoreLike", + "DeviceLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.maxiva_uaxop4p6e-0.1.0", + "_vid": "com.nevion.maxiva_uaxop4p6e-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "maxiva_uaxop4p6e" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "Simple Network Management Protocol (SNMP) Gateway", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "device", + "ipAddress": null, + "label": "Maxiva UAX-OP-4P6E", + "modules": [], + "name": "maxiva_uaxop4p6e", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [ + { + "name": "Maxiva SNMP", + "swBuildTime": null, + "swVersion": null + } + ], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.4.1.43768.3.1.1.9.2.3.4.0" + ], + "snmpSysObjectIdValue": "1.3.6.1.4.1.100000", + "status": "", + "supportedApis": [ + "ParameterControlLike", + "CoreLike", + "DeviceLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.maxiva_uaxt30uc-0.1.0", + "_vid": "com.nevion.maxiva_uaxt30uc-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "maxiva_uaxt30uc" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "Simple Network Management Protocol (SNMP) Gateway", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "device", + "ipAddress": null, + "label": "Maxiva UAXT-30-UC", + "modules": [], + "name": "maxiva_uaxt30uc", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [ + { + "name": "Maxiva SNMP", + "swBuildTime": null, + "swVersion": null + } + ], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.4.1.43768.3.1.1.8.2.3.4.0" + ], + "snmpSysObjectIdValue": "1.3.6.1.4.1.100000", + "status": "", + "supportedApis": [ + "ParameterControlLike", + "CoreLike", + "DeviceLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.md8000-0.1.0", + "_vid": "com.nevion.md8000-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "md8000" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom setting fields for MD8000 family", + "label": "MD8000 family" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.nevion.md8000.mac_table_cache_timeout": { + "_schema": { + "default": 10, + "descriptor": { + "desc": "Timeout in seconds. Upon reaching the timeout, the cache is considered stale and will be invalidated", + "label": "MAC table cache timeout" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 0, + 300, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + }, + "com.nevion.md8000.report_alerts": { + "_schema": { + "default": "yes", + "descriptor": { + "desc": "Toggles whether or not the driver reports alerts", + "label": "Report alerts" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [ + { + "descriptor": { + "desc": "The driver should not report any alerts", + "label": "No" + }, + "value": "no" + }, + { + "descriptor": { + "desc": "The driver should report alerts", + "label": "Yes" + }, + "value": "yes" + } + ], + "status": "Current", + "type": "string" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "Driver for Media Links MD8000", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "encoderDecoder", + "ipAddress": null, + "label": "Media Links MD8000", + "modules": [], + "name": "md8000", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": "1.3.6.1.4.1.17186.1.10", + "status": "", + "supportedApis": [ + "ParameterControlLike", + "DeviceLike", + "PortLike", + "CoreLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.mediakind_ce1-0.1.0", + "_vid": "com.nevion.mediakind_ce1-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "mediakind_ce1" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "Contribution Encoder", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "none", + "ipAddress": null, + "label": "MediaKind CE1", + "modules": [], + "name": "mediakind_ce1", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "CoreLike", + "DeviceLike", + "PortLike" + ], + "supportsExternal": true, + "supportsParameterApi": false, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.mediakind_rx1-0.1.0", + "_vid": "com.nevion.mediakind_rx1-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "mediakind_rx1" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "Contribution Decoder", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "none", + "ipAddress": null, + "label": "MediaKind RX1", + "modules": [], + "name": "mediakind_rx1", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "CoreLike", + "DeviceLike", + "PortLike" + ], + "supportsExternal": true, + "supportsParameterApi": false, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.mock-0.1.0", + "_vid": "com.nevion.mock-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "mock" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom setting fields for mock", + "label": "mock" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.nevion.api.sample_flows_interval": { + "_schema": { + "default": 0, + "descriptor": { + "desc": "Interval at which to poll flow stats. 0 to disable.", + "label": "Flow stats interval [s]" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 0, + 0, + 1 + ], + [ + 2, + 3600, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + }, + "com.nevion.mock.always_compute_rx_sdp": { + "_schema": { + "default": false, + "descriptor": { + "desc": "If enabled, VIP will generate a SDP for a receiver even if the sender does not publish a SDP itself", + "label": "Always compute Rx SDP" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.mock.always_different": { + "_schema": { + "default": true, + "descriptor": { + "desc": "Skip config apply checks (always different)", + "label": "Skip config apply checks" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.mock.bulk": { + "_schema": { + "default": true, + "descriptor": { + "desc": "Bulk config", + "label": "Bulk config" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.mock.codecFormat": { + "_schema": { + "default": "Video", + "descriptor": { + "desc": "", + "label": "Codec format type" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [ + { + "descriptor": { + "desc": "", + "label": "ASI" + }, + "value": "ASI" + }, + { + "descriptor": { + "desc": "", + "label": "Ancillary" + }, + "value": "Ancillary" + }, + { + "descriptor": { + "desc": "", + "label": "Audio" + }, + "value": "Audio" + }, + { + "descriptor": { + "desc": "", + "label": "Video" + }, + "value": "Video" + } + ], + "status": "Current", + "type": "string" + } + }, + "com.nevion.mock.delay": { + "_schema": { + "default": 0, + "descriptor": { + "desc": "Delay", + "label": "Delay" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 0, + 10000, + 10 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + }, + "com.nevion.mock.enableIntercom": { + "_schema": { + "default": false, + "descriptor": { + "desc": "Generate sample intercom data", + "label": "Enable intercom" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.mock.matrix_type": { + "_schema": { + "default": "1:N", + "descriptor": { + "desc": "", + "label": "Matrix Type" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [ + { + "descriptor": { + "desc": "", + "label": "N:N" + }, + "value": "N:N" + }, + { + "descriptor": { + "desc": "", + "label": "1:N" + }, + "value": "1:N" + }, + { + "descriptor": { + "desc": "", + "label": "1:1" + }, + "value": "1:1" + } + ], + "status": "Current", + "type": "string" + } + }, + "com.nevion.mock.nmetrics": { + "_schema": { + "default": 0, + "descriptor": { + "desc": "Number of metrics per device", + "label": "Number of ports for metrics (nPorts * 12)" + }, + "isNullable": false, + "options": [], + "ranges": [], + "status": "Current", + "type": "number", + "units": "" + } + }, + "com.nevion.mock.num_codec_modules": { + "_schema": { + "default": 2, + "descriptor": { + "desc": "Number of codec modules", + "label": "#Codecs" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 0, + 10, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + }, + "com.nevion.mock.num_dynamic_resource_modules": { + "_schema": { + "default": 0, + "descriptor": { + "desc": "Number of dynamic resource modules", + "label": "#DynamicResourceMods" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 0, + 10, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + }, + "com.nevion.mock.num_gpis": { + "_schema": { + "default": 0, + "descriptor": { + "desc": "Number of GPIs. Automatically flips every 2.", + "label": "#GPIs" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 0, + 10000, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + }, + "com.nevion.mock.num_gpos": { + "_schema": { + "default": 0, + "descriptor": { + "desc": "Number of GPOs", + "label": "#GPOs" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 0, + 10000, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + }, + "com.nevion.mock.num_hevc_modules": { + "_schema": { + "default": 0, + "descriptor": { + "desc": "Number of HEVC modules", + "label": "#HEVCMods" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 0, + 10, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + }, + "com.nevion.mock.num_resource_modules": { + "_schema": { + "default": 0, + "descriptor": { + "desc": "Number of resource modules", + "label": "#ResourceMods" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 0, + 10, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + }, + "com.nevion.mock.num_router_modules": { + "_schema": { + "default": 0, + "descriptor": { + "desc": "Number of router modules", + "label": "#VRouters" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 0, + 10, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + }, + "com.nevion.mock.num_router_ports": { + "_schema": { + "default": 32, + "descriptor": { + "desc": "Number of in/out ports per router module", + "label": "#VRouterPorts" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 0, + 10000, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + }, + "com.nevion.mock.num_switch_modules": { + "_schema": { + "default": 0, + "descriptor": { + "desc": "Number of switch modules", + "label": "#Switches" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 0, + 10, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + }, + "com.nevion.mock.persist": { + "_schema": { + "default": true, + "descriptor": { + "desc": "If enabled configs, source ips etc. will be persisted to disk", + "label": "Persist data" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.mock.populate_router_matrix": { + "_schema": { + "default": false, + "descriptor": { + "desc": "Populate default router matrix crosspoints", + "label": "Populate router matrix" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.mock.ptpClockType": { + "_schema": { + "default": 0, + "descriptor": { + "desc": "0: Ordinary, 1: Transparent, 2: Boundary, 3: Grandmaster", + "label": "PTP clock type" + }, + "isNullable": false, + "options": [], + "ranges": [], + "status": "Current", + "type": "number", + "units": "" + } + }, + "com.nevion.mock.tally_ids": { + "_schema": { + "default": "", + "descriptor": { + "desc": "Comma separated list of tally ids", + "label": "Tally ids" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "com.nevion.mock.tally_master": { + "_schema": { + "default": "", + "descriptor": { + "desc": "Comma separated list of 'domain/group/color' triples", + "label": "Tally Master data" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "matrixId": { + "_schema": { + "default": "", + "descriptor": { + "desc": "", + "label": "Custom matrix ID" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "A mock driver to fake active drivers without using simulators.", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "none", + "ipAddress": null, + "label": "Mock Driver", + "modules": [], + "name": "mock", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "DeviceLike", + "DynamicLike", + "PortLike", + "HwPanelLike", + "GPIOLike", + "TestLike", + "TallyLike", + "StatusLike", + "MatrixControlLike", + "ParameterControlLike", + "CoreLike", + "IntercomLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.mock_cloud-0.1.0", + "_vid": "com.nevion.mock_cloud-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "mock_cloud" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "A mock cloud driver to fake active drivers without using simulators.", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "none", + "ipAddress": null, + "label": "Mock Cloud Driver", + "modules": [], + "name": "mock_cloud", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "DeviceLike", + "DynamicLike", + "PortLike", + "HwPanelLike", + "GPIOLike", + "TestLike", + "TallyLike", + "StatusLike", + "MatrixControlLike", + "ParameterControlLike", + "CoreLike", + "IntercomLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.montone42-0.1.0", + "_vid": "com.nevion.montone42-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "montone42" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "Driver for DirectOut Technologies Montone.42", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "gateway", + "ipAddress": null, + "label": "Montone.42", + "modules": [], + "name": "montone42", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "CoreLike", + "DeviceLike", + "StatusLike", + "PortLike" + ], + "supportsExternal": true, + "supportsParameterApi": false, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.multicon-0.1.0", + "_vid": "com.nevion.multicon-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "multicon" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "Driver for Multicon element manager", + "deviceType": "switch", + "discoveredBy": null, + "exists": "No", + "iconType": "processingDevice", + "ipAddress": null, + "label": "Nevion Multicon", + "modules": [], + "name": "multicon", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [ + { + "name": "MULTICON", + "swBuildTime": null, + "swVersion": null + } + ], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.1.0", + "1.3.6.1.2.1.1.2.0", + "1.3.6.1.4.1.2021.100.6.0", + "1.3.6.1.2.1.2.2.1.6.3", + "1.3.6.1.2.1.2.2.1.6.3" + ], + "snmpSysObjectIdValue": "1.3.6.1.4.1.8072.3.2.10", + "status": "", + "supportedApis": [ + "ParameterControlLike", + "DeviceLike", + "DynamicLike", + "PortLike", + "CoreLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.mwedge-0.1.0", + "_vid": "com.nevion.mwedge-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "mwedge" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "TS protection and monitoring gateway", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "none", + "ipAddress": null, + "label": "Techex MWEDGE", + "modules": [], + "name": "mwedge", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "CoreLike", + "DeviceLike", + "PortLike" + ], + "supportsExternal": true, + "supportsParameterApi": false, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.ndi-0.1.0", + "_vid": "com.nevion.ndi-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "ndi" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom setting fields for Ndi Router", + "label": "Ndi Router" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.nevion.ndi.num_virtual_routing_instances": { + "_schema": { + "default": 10, + "descriptor": { + "desc": "The number of Virtual Routing instances (destinations) to create", + "label": "Virtual Routing instances" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 0, + 65535, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + }, + "com.nevion.ndi.port": { + "_schema": { + "default": 8765, + "descriptor": { + "desc": "Port used to connect to the NDI router", + "label": "Port" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 0, + 65535, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "Specialized driver for Controlling NDI Virtual Routing Instances", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "none", + "ipAddress": null, + "label": "NDI Matrix driver", + "modules": [], + "name": "ndi", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "MatrixControlLike", + "CoreLike" + ], + "supportsExternal": true, + "supportsParameterApi": false, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.nec_dtl_30-0.1.0", + "_vid": "com.nevion.nec_dtl_30-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "nec_dtl_30" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "Driver for DTL-30 system", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "device", + "ipAddress": null, + "label": "NEC DTL-30", + "modules": [], + "name": "nec_dtl_30", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [ + { + "name": "DTL-30", + "swBuildTime": null, + "swVersion": null + } + ], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": "1.3.6.1.4.1.119.2.3.96.29", + "status": "", + "supportedApis": [ + "ParameterControlLike", + "CoreLike", + "DeviceLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.nec_dtu_70d-0.1.0", + "_vid": "com.nevion.nec_dtu_70d-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "nec_dtu_70d" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "Driver for DTU-70D system", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "device", + "ipAddress": null, + "label": "NEC DTU-70D", + "modules": [], + "name": "nec_dtu_70d", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [ + { + "name": "DTU-70D", + "swBuildTime": null, + "swVersion": null + } + ], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": "1.3.6.1.4.1.119.2.3.96.28", + "status": "", + "supportedApis": [ + "ParameterControlLike", + "CoreLike", + "DeviceLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.nec_dtu_l10-0.1.0", + "_vid": "com.nevion.nec_dtu_l10-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "nec_dtu_l10" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "Driver for DTU-L10 system", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "device", + "ipAddress": null, + "label": "NEC DTU-L10", + "modules": [], + "name": "nec_dtu_l10", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [ + { + "name": "DTU-L10", + "swBuildTime": null, + "swVersion": null + } + ], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": "1.3.6.1.4.1.119.2.3.96.71", + "status": "", + "supportedApis": [ + "ParameterControlLike", + "CoreLike", + "DeviceLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.net_vision-0.1.0", + "_vid": "com.nevion.net_vision-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "net_vision" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "UPS WEB/SNMP Card", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "device", + "ipAddress": null, + "label": "Socomec NET VISION", + "modules": [], + "name": "net_vision", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [ + { + "name": "NET VISION", + "swBuildTime": null, + "swVersion": null + } + ], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": "1.3.6.1.4.1.4555.1.1.1", + "status": "", + "supportedApis": [ + "ParameterControlLike", + "CoreLike", + "DeviceLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.nodectrl-0.1.0", + "_vid": "com.nevion.nodectrl-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "nodectrl" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom setting fields for nodectrl", + "label": "nodectrl" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.nevion.emberplus.keepalives": { + "_schema": { + "default": true, + "descriptor": { + "desc": "If selected, keep-alives will be used to determine reachability", + "label": "Send keep-alives" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.emberplus.port": { + "_schema": { + "default": 9000, + "descriptor": { + "desc": "Port", + "label": "Port" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 0, + 65535, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + }, + "com.nevion.emberplus.queue": { + "_schema": { + "default": true, + "descriptor": { + "desc": "", + "label": "Request queueing" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.emberplus.suppress_illegal": { + "_schema": { + "default": false, + "descriptor": { + "desc": "", + "label": "Suppress illegal update warnings" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.emberplus.trace": { + "_schema": { + "default": false, + "descriptor": { + "desc": "", + "label": "Tracing (logging intensive)" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "A driver for devices using NodeCtrl standard of Ember+ API", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "none", + "ipAddress": null, + "label": "NodeCtrl", + "modules": [], + "name": "nodectrl", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "ParameterControlLike", + "CoreLike", + "DeviceLike", + "PortLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.nokia7210-0.1.0", + "_vid": "com.nevion.nokia7210-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "nokia7210" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "Driver for Nokia 7210 switches", + "deviceType": "switch", + "discoveredBy": null, + "exists": "No", + "iconType": "device", + "ipAddress": null, + "label": "Nokia 7210", + "modules": [], + "name": "nokia7210", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "ParameterControlLike", + "DeviceLike", + "PortLike", + "CoreLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.nokia7705-0.1.0", + "_vid": "com.nevion.nokia7705-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "nokia7705" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "Driver for Nokia 7705 service aggregation router", + "deviceType": "switch", + "discoveredBy": null, + "exists": "No", + "iconType": "device", + "ipAddress": null, + "label": "Nokia 7705", + "modules": [], + "name": "nokia7705", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "ParameterControlLike", + "DeviceLike", + "PortLike", + "CoreLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.nso-0.1.0", + "_vid": "com.nevion.nso-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "nso" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "A driver for Cisco Network Services Orchestration (NSO)", + "deviceType": "switch", + "discoveredBy": null, + "exists": "No", + "iconType": "none", + "ipAddress": null, + "label": "Cisco NSO", + "modules": [], + "name": "nso", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [ + { + "name": "NSO", + "swBuildTime": null, + "swVersion": null + } + ], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "CoreLike", + "DeviceLike", + "PortLike" + ], + "supportsExternal": true, + "supportsParameterApi": false, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.nx4600-0.1.0", + "_vid": "com.nevion.nx4600-0.1.0", + "attachments": [ + { + "description": "Base configuration (partial or full)", + "name": "null.base" + } + ], + "configurableDevice": true, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom setting fields for Platform4000", + "label": "Platform4000" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.nevion.null.reuse_ts_element": { + "_schema": { + "default": false, + "descriptor": { + "desc": "", + "label": "Enable to activate logic to join existing TS input element for ASI outputs when setting up multicast with identical settings" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "Driver for the Nevion NX4600 Media Gateway", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "gateway", + "ipAddress": null, + "label": "Nevion NX4600", + "modules": [ + "Slot [0-4]" + ], + "name": "nx4600", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [ + { + "name": "NX4600", + "swBuildTime": null, + "swVersion": "1.4" + } + ], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": "1.3.6.1.4.1.8768.10.37", + "status": "", + "supportedApis": [ + "ParameterControlLike", + "DeviceLike", + "DynamicLike", + "PortLike", + "CoreLike", + "StatusLike", + "MaintenanceLike", + "NetworkServiceLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [ + { + "descriptor": { + "desc": "Last bitrate measured.", + "label": "Ts Pid Bitrate" + }, + "id": "ts.pid.bit.rate", + "isAlarmTrigger": false, + "isCounter": false, + "unit": "" + }, + { + "descriptor": { + "desc": "Last average bitrate measured.", + "label": "Ts Pid Average Bitrate" + }, + "id": "ts.pid.average.bit.rate", + "isAlarmTrigger": false, + "isCounter": false, + "unit": "" + }, + { + "descriptor": { + "desc": "Continuity error counter.", + "label": "Ts Pid Continuity Error Counter" + }, + "id": "ts.pid.continuity.error.counter", + "isAlarmTrigger": false, + "isCounter": false, + "unit": "" + }, + { + "descriptor": { + "desc": "Last bitrate sampled for program.", + "label": "Ts Service Bitrate" + }, + "id": "ts.service.bit.rate", + "isAlarmTrigger": false, + "isCounter": false, + "unit": "" + }, + { + "descriptor": { + "desc": "Effective bitrate, i.e., bitrate without null packets.", + "label": "Ts Effective Bitrate" + }, + "id": "ts.effective.bit.rate", + "isAlarmTrigger": false, + "isCounter": false, + "unit": "" + }, + { + "descriptor": { + "desc": "Total bitrate.", + "label": "Ts Total Bitrate" + }, + "id": "ts.total.bit.rate", + "isAlarmTrigger": false, + "isCounter": false, + "unit": "" + }, + { + "descriptor": { + "desc": "Inter arrival time, i.e., the time between arrivals into the system.", + "label": "Tsoip Rx Sips Iat" + }, + "id": "tsoip.rx.sips.iat", + "isAlarmTrigger": false, + "isCounter": false, + "unit": "" + }, + { + "descriptor": { + "desc": "Packet delay factor.", + "label": "Tsoip Rx Sips Pdv" + }, + "id": "tsoip.rx.sips.pdv", + "isAlarmTrigger": false, + "isCounter": false, + "unit": "" + }, + { + "descriptor": { + "desc": "Current buffer.", + "label": "Tsoip Rx Buff Lat" + }, + "id": "tsoip.rx.buff.lat", + "isAlarmTrigger": false, + "isCounter": false, + "unit": "" + }, + { + "descriptor": { + "desc": "Current utilization fraction of buffer, with the signal currently received and configured parameters.", + "label": "Tsoip Rx Buff Util" + }, + "id": "tsoip.rx.buff.util", + "isAlarmTrigger": false, + "isCounter": false, + "unit": "" + }, + { + "descriptor": { + "desc": "SFP Channel internal bias current", + "label": "Sfp Internal Current" + }, + "id": "sfp.internal.current", + "isAlarmTrigger": false, + "isCounter": false, + "unit": "mA" + }, + { + "descriptor": { + "desc": "SFP Channel Internal Vcc", + "label": "Sfp Internal Voltage" + }, + "id": "sfp.internal.voltage", + "isAlarmTrigger": false, + "isCounter": false, + "unit": "V" + }, + { + "descriptor": { + "desc": "SFP Channel internal temperature", + "label": "Sfp Internal Temp" + }, + "id": "sfp.internal.temp", + "isAlarmTrigger": false, + "isCounter": false, + "unit": "C" + }, + { + "descriptor": { + "desc": "SFP Channel RX input power", + "label": "Sfp Rx Effect" + }, + "id": "sfp.rx.effect", + "isAlarmTrigger": false, + "isCounter": false, + "unit": "mW" + }, + { + "descriptor": { + "desc": "SFP Channel RX input power", + "label": "Sfp Rx Effect Log" + }, + "id": "sfp.rx.effect.log", + "isAlarmTrigger": false, + "isCounter": false, + "unit": "dBm" + }, + { + "descriptor": { + "desc": "SFP Channel TX output power", + "label": "Sfp Tx Effect" + }, + "id": "sfp.tx.effect", + "isAlarmTrigger": false, + "isCounter": false, + "unit": "mW" + }, + { + "descriptor": { + "desc": "SFP Channel TX output power", + "label": "Sfp Tx Effect Log" + }, + "id": "sfp.tx.effect.log", + "isAlarmTrigger": false, + "isCounter": false, + "unit": "dBm" + } + ], + "updateableDevice": false, + "updateableModule": { + "ASI-Input/Output-Board": { + "availableBanks": [], + "rebootOption": 0 + }, + "DVB-T/T2-Demodulator": { + "availableBanks": [], + "rebootOption": 0 + }, + "GNSS-clock-reference-board": { + "availableBanks": [], + "rebootOption": 0 + }, + "H-264-Encoder/Decoder": { + "availableBanks": [], + "rebootOption": 0 + }, + "High-Bit-Rate-Accelerator": { + "availableBanks": [], + "rebootOption": 0 + }, + "Main-Board": { + "availableBanks": [], + "rebootOption": 0 + }, + "Multi-codec": { + "availableBanks": [], + "rebootOption": 0 + } + }, + "version": "0.1.0" + }, + { + "_id": "com.nevion.nxl_me80-1.0.0", + "_vid": "com.nevion.nxl_me80-1.0.0", + "attachments": [ + { + "description": "Default", + "name": "nxl_me80" + } + ], + "configurableDevice": true, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom setting fields for NXL-ME80", + "label": "NXL-ME80" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.nevion.nxl_me80.always_enable_rtp": { + "_schema": { + "default": false, + "descriptor": { + "desc": "The \"rtp_enabled\" field in \"transport_params\" will always be set to true", + "label": "Always enable RTP" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.nxl_me80.auth_client_id": { + "_schema": { + "default": "", + "descriptor": { + "desc": "Client ID from registered ME80 Authorization Code", + "label": "NXL-ME80 Authorization Code Client ID" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "com.nevion.nxl_me80.cc_client_id": { + "_schema": { + "default": "", + "descriptor": { + "desc": "Client ID from registered ME80 Client Credential", + "label": "NXL-ME80 Client Credential Client ID" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "com.nevion.nxl_me80.client_secret": { + "_schema": { + "default": "", + "descriptor": { + "desc": "Client Secret from registered ME80 Client Credential", + "label": "NXL-ME80 Client Credential Client Secret" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "com.nevion.nxl_me80.disable_rx_sdp": { + "_schema": { + "default": false, + "descriptor": { + "desc": "Configure this unit's receivers with regular transport parameters only", + "label": "Disable Rx SDP" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.nxl_me80.disable_rx_sdp_with_null": { + "_schema": { + "default": true, + "descriptor": { + "desc": "Configures how RX SDPs are disabled. If unchecked, an empty string is used", + "label": "Disable Rx SDP with null" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.nxl_me80.enable_bulk_config": { + "_schema": { + "default": false, + "descriptor": { + "desc": "Configure this unit using bulk API", + "label": "Enable bulk config" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.nxl_me80.enable_experimental_alarm": { + "_schema": { + "default": false, + "descriptor": { + "desc": "Enables experimental alarms over websockets using IS-07 on certain Vizrt devices. Disables alarms completely if disabled", + "label": "Enable experimental alarms using IS-07" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.nxl_me80.experimental_alarm_port": { + "_schema": { + "default": 0, + "descriptor": { + "desc": "HTTP port for location of experimental IS-07 alarm websocket. If empty or 0 it uses Port field instead", + "label": "Experimental alarm port" + }, + "isNullable": true, + "options": [], + "ranges": [ + [ + 1, + 65535, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + }, + "com.nevion.nxl_me80.is05_api_version": { + "_schema": { + "default": false, + "descriptor": { + "desc": "Configure IS05 API version to use max", + "label": "Enable Max IS05 API version" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.nxl_me80.me80_port": { + "_schema": { + "default": 443, + "descriptor": { + "desc": "NXL-ME80 port setting used for CTRL", + "label": "NXL-ME80 Port" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 0, + 65535, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + }, + "com.nevion.nxl_me80.port": { + "_schema": { + "default": 80, + "descriptor": { + "desc": "The HTTP port used to reach the Node directly", + "label": "Port" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 1, + 65535, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "A Driver for the Sony Media Edge Processor NXL-ME80", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "gateway", + "ipAddress": null, + "label": "Sony NXL-ME80", + "modules": [], + "name": "nxl_me80", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [ + { + "name": "NXL-ME80", + "swBuildTime": null, + "swVersion": null + } + ], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": "1.3.6.1.2.1.1.2.0", + "status": "", + "supportedApis": [ + "MatrixControlLike", + "DeviceLike", + "MaintenanceLike", + "PortLike", + "CoreLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": false, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "1.0.0" + }, + { + "_id": "com.nevion.openflow-0.0.1", + "_vid": "com.nevion.openflow-0.0.1", + "attachments": [ + { + "description": "Default", + "name": "openflow" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom setting fields for Openflow drivers", + "label": "Openflow" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.nevion.api.sample_flows_interval": { + "_schema": { + "default": 0, + "descriptor": { + "desc": "Interval at which to poll flow stats. 0 to disable.", + "label": "Flow stats interval [s]" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 0, + 0, + 1 + ], + [ + 2, + 3600, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + }, + "com.nevion.openflow_allow_groups": { + "_schema": { + "default": true, + "descriptor": { + "desc": "Allow use of group actions in flows", + "label": "Allow groups" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.openflow_flow_priority": { + "_schema": { + "default": 60000, + "descriptor": { + "desc": "Flow priority used by videoipath", + "label": "Flow Priority" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 2, + 65535, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + }, + "com.nevion.openflow_interface_shutdown_alarms": { + "_schema": { + "default": false, + "descriptor": { + "desc": "Allow service correlated alarms when admin shuts down an interface", + "label": "Interface shutdown alarms" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.openflow_max_buckets": { + "_schema": { + "default": 65535, + "descriptor": { + "desc": "Max number of buckets in an openflow group", + "label": "Max buckets" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 2, + 65535, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + }, + "com.nevion.openflow_max_groups": { + "_schema": { + "default": 65535, + "descriptor": { + "desc": "Max number of groups on the switch", + "label": "Max groups" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 1, + 65535, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + }, + "com.nevion.openflow_max_meters": { + "_schema": { + "default": 65535, + "descriptor": { + "desc": "Max number of meters on the switch", + "label": "Max meters" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 2, + 65535, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + }, + "com.nevion.openflow_table_id": { + "_schema": { + "default": 0, + "descriptor": { + "desc": "Table ID to use for videoipath flows", + "label": "Table ID" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 0, + 255, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "A generic driver for Openflow devices (via an Openflow controller)", + "deviceType": "switch", + "discoveredBy": null, + "exists": "No", + "iconType": "ipSwitchRouter", + "ipAddress": null, + "label": "Openflow", + "modules": [], + "name": "openflow", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [ + { + "name": "Openflow", + "swBuildTime": null, + "swVersion": null + } + ], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "ParameterControlLike", + "DeviceLike", + "PortLike", + "CoreLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.0.1" + }, + { + "_id": "com.nevion.powercore-0.1.0", + "_vid": "com.nevion.powercore-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "powercore" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom settings field for PowerCore driver", + "label": "PowerCore" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.nevion.emberplus.keepalives": { + "_schema": { + "default": true, + "descriptor": { + "desc": "If selected, keep-alives will be used to determine reachability", + "label": "Send keep-alives" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.emberplus.port": { + "_schema": { + "default": 9000, + "descriptor": { + "desc": "Port", + "label": "Port" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 0, + 65535, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + }, + "com.nevion.emberplus.queue": { + "_schema": { + "default": true, + "descriptor": { + "desc": "", + "label": "Request queueing" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.emberplus.request_separation": { + "_schema": { + "default": 0, + "descriptor": { + "desc": "Set to zero to disable.", + "label": "Request Separation [ms]" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 0, + 250, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + }, + "com.nevion.emberplus.suppress_illegal": { + "_schema": { + "default": false, + "descriptor": { + "desc": "", + "label": "Suppress illegal update warnings" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.emberplus.trace": { + "_schema": { + "default": false, + "descriptor": { + "desc": "", + "label": "Tracing (logging intensive)" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.powercore.bulk_config": { + "_schema": { + "default": "Set single configs in parallel", + "descriptor": { + "desc": "Bulk config mode: None = default set single configs in parallel", + "label": "Bulk config setting mode" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [ + { + "descriptor": { + "desc": "", + "label": "Aggregate configs in bigger requests" + }, + "value": "Aggregate configs in bigger requests" + }, + { + "descriptor": { + "desc": "", + "label": "One by one" + }, + "value": "One by one" + }, + { + "descriptor": { + "desc": "", + "label": "Set single configs in parallel" + }, + "value": "Set single configs in parallel" + } + ], + "status": "Current", + "type": "string" + } + }, + "com.nevion.powercore.env_alarms": { + "_schema": { + "default": false, + "descriptor": { + "desc": "", + "label": "Enable environmental alarm reporting" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.powercore.keep_alive_period": { + "_schema": { + "default": 2000, + "descriptor": { + "desc": "", + "label": "Send KeepAlive request period in millis" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 100, + 60000, + 100 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + }, + "com.nevion.powercore.max_bulk_transactions": { + "_schema": { + "default": 1000, + "descriptor": { + "desc": "", + "label": "Max number of bulk transactions" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 1, + 1000, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + }, + "com.nevion.powercore.stream_alerts": { + "_schema": { + "default": false, + "descriptor": { + "desc": "", + "label": "Enable Output(RX) flag notifications" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "none", + "ipAddress": null, + "label": "Lawo PowerCore", + "modules": [], + "name": "powercore", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "ParameterControlLike", + "DeviceLike", + "PortLike", + "CoreLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.prismon-1.0.0", + "_vid": "com.nevion.prismon-1.0.0", + "attachments": [ + { + "description": "Default", + "name": "prismon" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom setting fields for R&S PRISMON", + "label": "R&S PRISMON" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.nevion.prismon.always_enable_rtp": { + "_schema": { + "default": false, + "descriptor": { + "desc": "The \"rtp_enabled\" field in \"transport_params\" will always be set to true", + "label": "Always enable RTP" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.prismon.disable_rx_sdp": { + "_schema": { + "default": false, + "descriptor": { + "desc": "Configure this unit's receivers with regular transport parameters only", + "label": "Disable Rx SDP" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.prismon.disable_rx_sdp_with_null": { + "_schema": { + "default": true, + "descriptor": { + "desc": "Configures how RX SDPs are disabled. If unchecked, an empty string is used", + "label": "Disable Rx SDP with null" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.prismon.enable_bulk_config": { + "_schema": { + "default": false, + "descriptor": { + "desc": "Configure this unit using bulk API", + "label": "Enable bulk config" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.prismon.enable_experimental_alarm": { + "_schema": { + "default": false, + "descriptor": { + "desc": "Enables experimental alarms over websockets using IS-07 on certain Vizrt devices. Disables alarms completely if disabled", + "label": "Enable experimental alarms using IS-07" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.prismon.experimental_alarm_port": { + "_schema": { + "default": 0, + "descriptor": { + "desc": "HTTP port for location of experimental IS-07 alarm websocket. If empty or 0 it uses Port field instead", + "label": "Experimental alarm port" + }, + "isNullable": true, + "options": [], + "ranges": [ + [ + 1, + 65535, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + }, + "com.nevion.prismon.is05_api_version": { + "_schema": { + "default": false, + "descriptor": { + "desc": "Configure IS05 API version to use max", + "label": "Enable Max IS05 API version" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.prismon.port": { + "_schema": { + "default": 80, + "descriptor": { + "desc": "The HTTP port used to reach the Node directly", + "label": "Port" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 1, + 65535, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "A/V Content Monitoring & Multiviewer", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "device", + "ipAddress": null, + "label": "R&S PRISMON", + "modules": [], + "name": "prismon", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": "1.3.6.1.4.1.2566.127.1.2.216", + "status": "", + "supportedApis": [ + "DeviceLike", + "PortLike", + "CoreLike", + "StatusLike", + "MatrixControlLike", + "ParameterControlLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "1.0.0" + }, + { + "_id": "com.nevion.probel_sw_p_08-0.1.0", + "_vid": "com.nevion.probel_sw_p_08-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "probel_sw_p_08" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom setting fields for probel_sw_p_08", + "label": "probel_sw_p_08" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.nevion.probel_sw_p_08.disconnect_source_address": { + "_schema": { + "default": 1023, + "descriptor": { + "desc": "Must match disconnect source address in custom matrix", + "label": "Disconnect Source Address" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 0, + 1023, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + }, + "com.nevion.probel_sw_p_08.matrix_module_index": { + "_schema": { + "default": 0, + "descriptor": { + "desc": "This must be one higher than level in custom matrix", + "label": "Matrix Level" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 0, + 16, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + }, + "com.nevion.probel_sw_p_08.name_length": { + "_schema": { + "default": 32, + "descriptor": { + "desc": "Must be in range [0,2,4,8,16,32]", + "label": "Length of labels" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 0, + 32, + 2 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + }, + "com.nevion.probel_sw_p_08.num_router_levels": { + "_schema": { + "default": 0, + "descriptor": { + "desc": "Support up to 16", + "label": "SWP08 Level" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 0, + 16, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + }, + "com.nevion.probel_sw_p_08.num_router_modules": { + "_schema": { + "default": 1, + "descriptor": { + "desc": "The number of matrices", + "label": "Number of matrices" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 0, + 15, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + }, + "com.nevion.probel_sw_p_08.num_router_ports": { + "_schema": { + "default": 32, + "descriptor": { + "desc": "This must be the same number of ports as on the device", + "label": "Number of router ports" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 0, + 1023, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + }, + "com.nevion.probel_sw_p_08.park_port": { + "_schema": { + "default": 0, + "descriptor": { + "desc": "Must match park port in topology", + "label": "Custom park port" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 0, + 1023, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + }, + "com.nevion.probel_sw_p_08.port": { + "_schema": { + "default": 8910, + "descriptor": { + "desc": "Port", + "label": "Port" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 0, + 65535, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "Driver for Pro-bel-SW-P-08", + "deviceType": "switch", + "discoveredBy": null, + "exists": "No", + "iconType": "videoAudioRouterMatrix", + "ipAddress": null, + "label": "Pro-bel-SW-P-08 Driver", + "modules": [], + "name": "probel_sw_p_08", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "MatrixControlLike", + "CoreLike", + "DeviceLike", + "PortLike" + ], + "supportsExternal": true, + "supportsParameterApi": false, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.r3lay-0.1.0", + "_vid": "com.nevion.r3lay-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "r3lay" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom setting fields for Lawo R3lay", + "label": "Lawo R3lay" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.nevion.r3lay.port": { + "_schema": { + "default": 9998, + "descriptor": { + "desc": "Port", + "label": "Port" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 0, + 65535, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "none", + "ipAddress": null, + "label": "Lawo R3LAY", + "modules": [], + "name": "r3lay", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "ParameterControlLike", + "DeviceLike", + "PortLike", + "TestLike", + "CoreLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.riedel_rrcs-0.1.0", + "_vid": "com.nevion.riedel_rrcs-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "riedel_rrcs" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "Riedel RRCS Intercom", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "device", + "ipAddress": null, + "label": "Riedel RRCS", + "modules": [], + "name": "riedel_rrcs", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [ + { + "name": "Riedel Artist", + "swBuildTime": null, + "swVersion": null + } + ], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "MatrixControlLike", + "CoreLike", + "DeviceLike", + "IntercomLike" + ], + "supportsExternal": true, + "supportsParameterApi": false, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.selenio_13p-0.1.0", + "_vid": "com.nevion.selenio_13p-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "selenio_13p" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom setting fields for Selenio drivers", + "label": "Selenio" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.nevion.selenio_13p.assume_success_after": { + "_schema": { + "default": 0, + "descriptor": { + "desc": "Assume a configuration was successfully applied after time given in milliseconds, only use if slow response time from Selenio is a problem. Use with care.", + "label": "Assume successful response after [ms]" + }, + "isNullable": false, + "options": [], + "ranges": [], + "status": "Current", + "type": "number", + "units": "" + } + }, + "com.nevion.selenio_13p.cache_alarm_config_timeout": { + "_schema": { + "default": 1800, + "descriptor": { + "desc": "Alarm config cache timeout in seconds. The alarm config is used to fetch severity level for each alarm", + "label": "Alarm config cache timeout [s]" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 0, + 252635728, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + }, + "com.nevion.selenio_13p.cache_timeout": { + "_schema": { + "default": 60, + "descriptor": { + "desc": "Driver cache timeout in seconds", + "label": "Cache timeout [s]" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 0, + 600, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + }, + "com.nevion.selenio_13p.manager_ip": { + "_schema": { + "default": "", + "descriptor": { + "desc": "Network address of the manager controlling this element", + "label": "Manager Address" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "com.nevion.selenio_13p.nmos_port": { + "_schema": { + "default": 8100, + "descriptor": { + "desc": "The HTTP port used to reach the Node directly", + "label": "Port" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 1, + 65535, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "Driver for a Selenio Network Processor", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "gateway", + "ipAddress": null, + "label": "Imagine SNP", + "modules": [], + "name": "selenio_13p", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "ParameterControlLike", + "DeviceLike", + "PortLike", + "CoreLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.sencore_dmg-0.1.0", + "_vid": "com.nevion.sencore_dmg-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "sencore_dmg" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom setting fields for Sencore DMG devices", + "label": "Sencore DMG" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.nevion.sencore_dmg.coder_ip_mapping": { + "_schema": { + "default": "", + "descriptor": { + "desc": "Coder module - IP module association map", + "label": "Coder-IP mapping" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "com.nevion.sencore_dmg.lan_wan_mapping": { + "_schema": { + "default": "", + "descriptor": { + "desc": "LAN/WAN module association map", + "label": "LAN-WAN mapping" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "Driver for Sencore DMG devices with dynamic topology", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "none", + "ipAddress": null, + "label": "Sencore DMG", + "modules": [], + "name": "sencore_dmg", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "ParameterControlLike", + "DeviceLike", + "PortLike", + "CoreLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.snell_probelrouter-0.0.1", + "_vid": "com.nevion.snell_probelrouter-0.0.1", + "attachments": [ + { + "description": "Default", + "name": "snell_probelrouter" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "A driver for Snell Pro-Bel router, using standard PROBEL-ROUTER Mib-file", + "deviceType": "switch", + "discoveredBy": null, + "exists": "No", + "iconType": "videoAudioRouterMatrix", + "ipAddress": null, + "label": "Snell Pro-Bel Router", + "modules": [], + "name": "snell_probelrouter", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [ + { + "name": "Snell Pro-Bel", + "swBuildTime": null, + "swVersion": null + } + ], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "ParameterControlLike", + "DeviceLike", + "PortLike", + "CoreLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.0.1" + }, + { + "_id": "com.nevion.sonifex_gpio-0.1.0", + "_vid": "com.nevion.sonifex_gpio-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "sonifex_gpio" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "Transport stream monitor", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "none", + "ipAddress": null, + "label": "SONIFEX-GPIO", + "modules": [], + "name": "sonifex_gpio", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "ParameterControlLike", + "GPIOLike", + "CoreLike", + "DeviceLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.sony_nxlk-ip50y-0.1.0", + "_vid": "com.nevion.sony_nxlk-ip50y-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "sony_nxlk-ip50y" + } + ], + "configurableDevice": true, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom setting fields for NDCP drivers", + "label": "NDCP" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.nevion.ndcp.deviceId": { + "_schema": { + "default": "", + "descriptor": { + "desc": "Device id usually auto-populated by device discovery", + "label": "NDCP device id" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "com.nevion.sony_nxlk-ip50y.always_enable_rtp": { + "_schema": { + "default": false, + "descriptor": { + "desc": "The \"rtp_enabled\" field in \"transport_params\" will always be set to true", + "label": "Always enable RTP" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.sony_nxlk-ip50y.disable_rx_sdp": { + "_schema": { + "default": false, + "descriptor": { + "desc": "Configure this unit's receivers with regular transport parameters only", + "label": "Disable Rx SDP" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.sony_nxlk-ip50y.disable_rx_sdp_with_null": { + "_schema": { + "default": true, + "descriptor": { + "desc": "Configures how RX SDPs are disabled. If unchecked, an empty string is used", + "label": "Disable Rx SDP with null" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.sony_nxlk-ip50y.enable_bulk_config": { + "_schema": { + "default": false, + "descriptor": { + "desc": "Configure this unit using bulk API", + "label": "Enable bulk config" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.sony_nxlk-ip50y.enable_experimental_alarm": { + "_schema": { + "default": false, + "descriptor": { + "desc": "Enables experimental alarms over websockets using IS-07 on certain Vizrt devices. Disables alarms completely if disabled", + "label": "Enable experimental alarms using IS-07" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.sony_nxlk-ip50y.experimental_alarm_port": { + "_schema": { + "default": 0, + "descriptor": { + "desc": "HTTP port for location of experimental IS-07 alarm websocket. If empty or 0 it uses Port field instead", + "label": "Experimental alarm port" + }, + "isNullable": true, + "options": [], + "ranges": [ + [ + 1, + 65535, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + }, + "com.nevion.sony_nxlk-ip50y.is05_api_version": { + "_schema": { + "default": false, + "descriptor": { + "desc": "Configure IS05 API version to use max", + "label": "Enable Max IS05 API version" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.sony_nxlk-ip50y.port": { + "_schema": { + "default": 80, + "descriptor": { + "desc": "The HTTP port used to reach the Node directly", + "label": "Port" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 1, + 65535, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "Driver for NXLK_IP50Y Devices", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "none", + "ipAddress": null, + "label": "Sony NXLK-IP50Y", + "modules": [], + "name": "sony_nxlk-ip50y", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [ + { + "name": "NDCP", + "swBuildTime": null, + "swVersion": null + }, + { + "name": "NMOS", + "swBuildTime": null, + "swVersion": null + } + ], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "MatrixControlLike", + "DeviceLike", + "DynamicLike", + "MaintenanceLike", + "PortLike", + "CoreLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": false, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.sony_nxlk-ip51y-0.1.0", + "_vid": "com.nevion.sony_nxlk-ip51y-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "sony_nxlk-ip51y" + } + ], + "configurableDevice": true, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom setting fields for NDCP drivers", + "label": "NDCP" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.nevion.ndcp.deviceId": { + "_schema": { + "default": "", + "descriptor": { + "desc": "Device id usually auto-populated by device discovery", + "label": "NDCP device id" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "com.nevion.sony_nxlk-ip51y.always_enable_rtp": { + "_schema": { + "default": false, + "descriptor": { + "desc": "The \"rtp_enabled\" field in \"transport_params\" will always be set to true", + "label": "Always enable RTP" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.sony_nxlk-ip51y.disable_rx_sdp": { + "_schema": { + "default": false, + "descriptor": { + "desc": "Configure this unit's receivers with regular transport parameters only", + "label": "Disable Rx SDP" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.sony_nxlk-ip51y.disable_rx_sdp_with_null": { + "_schema": { + "default": true, + "descriptor": { + "desc": "Configures how RX SDPs are disabled. If unchecked, an empty string is used", + "label": "Disable Rx SDP with null" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.sony_nxlk-ip51y.enable_bulk_config": { + "_schema": { + "default": false, + "descriptor": { + "desc": "Configure this unit using bulk API", + "label": "Enable bulk config" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.sony_nxlk-ip51y.enable_experimental_alarm": { + "_schema": { + "default": false, + "descriptor": { + "desc": "Enables experimental alarms over websockets using IS-07 on certain Vizrt devices. Disables alarms completely if disabled", + "label": "Enable experimental alarms using IS-07" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.sony_nxlk-ip51y.experimental_alarm_port": { + "_schema": { + "default": 0, + "descriptor": { + "desc": "HTTP port for location of experimental IS-07 alarm websocket. If empty or 0 it uses Port field instead", + "label": "Experimental alarm port" + }, + "isNullable": true, + "options": [], + "ranges": [ + [ + 1, + 65535, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + }, + "com.nevion.sony_nxlk-ip51y.is05_api_version": { + "_schema": { + "default": false, + "descriptor": { + "desc": "Configure IS05 API version to use max", + "label": "Enable Max IS05 API version" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.sony_nxlk-ip51y.port": { + "_schema": { + "default": 80, + "descriptor": { + "desc": "The HTTP port used to reach the Node directly", + "label": "Port" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 1, + 65535, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "Driver for NXLK_IP51Y Devices", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "none", + "ipAddress": null, + "label": "Sony NXLK-IP51Y", + "modules": [], + "name": "sony_nxlk-ip51y", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [ + { + "name": "NDCP", + "swBuildTime": null, + "swVersion": null + }, + { + "name": "NMOS", + "swBuildTime": null, + "swVersion": null + } + ], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "MatrixControlLike", + "DeviceLike", + "DynamicLike", + "MaintenanceLike", + "PortLike", + "CoreLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": false, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.spg9000-0.1.0", + "_vid": "com.nevion.spg9000-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "spg9000" + } + ], + "configurableDevice": true, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom settings for SPG9000", + "label": "SPG9000" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.nevion.spg9000.x_api_key": { + "_schema": { + "default": "apikey", + "descriptor": { + "desc": "x-api-key (configurable in SPG9000's System tab)", + "label": "x-api-key" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "Driver for Telestream SPG9000: Timing and Reference System", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "none", + "ipAddress": null, + "label": "SPG9000", + "modules": [], + "name": "spg9000", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "ParameterControlLike", + "MaintenanceLike", + "CoreLike", + "DeviceLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.ssl_system_t-0.1.0", + "_vid": "com.nevion.ssl_system_t-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "ssl_system_t" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "none", + "ipAddress": null, + "label": "SSL System T", + "modules": [], + "name": "ssl_system_t", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "ParameterControlLike", + "GPIOLike", + "CoreLike", + "DeviceLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.starfish_splicer-0.1.0", + "_vid": "com.nevion.starfish_splicer-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "starfish_splicer" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom setting fields for Starfish TS Splicer devices", + "label": "starfish_splicer" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.nevion.starfish_splicer.api_port": { + "_schema": { + "default": 8080, + "descriptor": { + "desc": "The HTTP port used to reach the API of the device directly", + "label": "API Port" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 1, + 65535, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "Software based transport stream splicing", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "none", + "ipAddress": null, + "label": "Starfish Splicer", + "modules": [], + "name": "starfish_splicer", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "CoreLike", + "DeviceLike", + "PortLike" + ], + "supportsExternal": true, + "supportsParameterApi": false, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.streamdeck_studio-0.1.0", + "_vid": "com.nevion.streamdeck_studio-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "streamdeck_studio" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "GPIO Driver for StreamDeck Studio", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "device", + "ipAddress": null, + "label": "StreamDeck Studio", + "modules": [], + "name": "streamdeck_studio", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "CoreLike", + "DeviceLike", + "HwPanelLike" + ], + "supportsExternal": true, + "supportsParameterApi": false, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.sublime-0.1.0", + "_vid": "com.nevion.sublime-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "sublime" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "Driver for Sublime Routers", + "deviceType": "switch", + "discoveredBy": null, + "exists": "No", + "iconType": "videoAudioRouterMatrix", + "ipAddress": null, + "label": "Nevion Sublime", + "modules": [], + "name": "sublime", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [ + { + "name": "SUBLIME", + "swBuildTime": null, + "swVersion": null + } + ], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "DeviceLike", + "PortLike", + "CoreLike", + "StatusLike", + "MatrixControlLike", + "ParameterControlLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.tag_mcm9000-0.1.0", + "_vid": "com.nevion.tag_mcm9000-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "tag_mcm9000" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom setting fields for TAG MCM 9000 Nodes", + "label": "tag_mcm9000" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.nevion.tag_mcm9000.enable_bulk_config": { + "_schema": { + "default": false, + "descriptor": { + "desc": "Configure this unit using bulk API", + "label": "Enable bulk config" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.tag_mcm9000.enable_legacy_uuid_api": { + "_schema": { + "default": false, + "descriptor": { + "desc": "Uses legacy uppercase UUIDs in API to match previously synced topologies", + "label": "Enable 4.1 API (legacy UUIDs)" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "none", + "ipAddress": null, + "label": "TAG MCM9000", + "modules": [], + "name": "tag_mcm9000", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "CoreLike", + "DeviceLike", + "PortLike" + ], + "supportsExternal": true, + "supportsParameterApi": false, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.tag_mcs-0.1.0", + "_vid": "com.nevion.tag_mcs-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "tag_mcs" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom setting fields for TAG MCS Nodes", + "label": "tag_mcs" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.nevion.tag_mcs.enable_bulk_config": { + "_schema": { + "default": true, + "descriptor": { + "desc": "Configure this unit using bulk API", + "label": "Enable bulk config" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.tag_mcs.update_extra_address_timer": { + "_schema": { + "default": 5, + "descriptor": { + "desc": "Configure timer for checking for changed managed addresses for SNMP traps in minutes", + "label": "Update SNMP trap extra address timer" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 1, + 2147483647, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "minute(s)" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "none", + "ipAddress": null, + "label": "TAG MCS", + "modules": [], + "name": "tag_mcs", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [ + { + "name": "TAG MCS", + "swBuildTime": null, + "swVersion": null + } + ], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "DeviceLike", + "PortLike", + "CoreLike", + "StatusLike", + "MatrixControlLike", + "ParameterControlLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.tally-0.1.0", + "_vid": "com.nevion.tally-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "tally" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom setting fields for Tally devices", + "label": "Tally" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.nevion.tally.primary_port": { + "_schema": { + "default": 8900, + "descriptor": { + "desc": "Primary Port", + "label": "Primary Port" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 1, + 65535, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + }, + "com.nevion.tally.screen_id": { + "_schema": { + "default": 0, + "descriptor": { + "desc": "Screen ID", + "label": "Static Screen ID" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 0, + 65535, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + }, + "com.nevion.tally.secondary_port": { + "_schema": { + "default": 8900, + "descriptor": { + "desc": "Secondary Port", + "label": "Secondary Port" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 1, + 65535, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + }, + "com.nevion.tally.tally_brightness": { + "_schema": { + "default": 3, + "descriptor": { + "desc": "Tally Brightness", + "label": "Static Tally Brightness" + }, + "isNullable": false, + "options": [ + { + "descriptor": { + "desc": "", + "label": "Full" + }, + "value": 3 + }, + { + "descriptor": { + "desc": "", + "label": "Half" + }, + "value": 2 + }, + { + "descriptor": { + "desc": "", + "label": "1/7th" + }, + "value": 1 + }, + { + "descriptor": { + "desc": "", + "label": "Zero" + }, + "value": 0 + } + ], + "ranges": [], + "status": "Current", + "type": "number", + "units": "" + } + }, + "com.nevion.tally.tally_character_set": { + "_schema": { + "default": 1, + "descriptor": { + "desc": "Character Set", + "label": "Tally Character Set" + }, + "isNullable": false, + "options": [ + { + "descriptor": { + "desc": "", + "label": "ASCII" + }, + "value": 0 + }, + { + "descriptor": { + "desc": "", + "label": "UTF-16LE" + }, + "value": 1 + } + ], + "ranges": [], + "status": "Current", + "type": "number", + "units": "" + } + }, + "com.nevion.tally.x_number_of_umd": { + "_schema": { + "default": 32, + "descriptor": { + "desc": "Number of UMDs", + "label": "Number of UMDs" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 1, + 256, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "Driver for Tally devices", + "deviceType": "panel", + "discoveredBy": null, + "exists": "No", + "iconType": "none", + "ipAddress": null, + "label": "Tally", + "modules": [], + "name": "tally", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "TallyLike", + "CoreLike", + "DeviceLike" + ], + "supportsExternal": false, + "supportsParameterApi": false, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.telestream_surveyor-0.1.0", + "_vid": "com.nevion.telestream_surveyor-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "telestream_surveyor" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "Driver for a Telestream Surveyor device", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "none", + "ipAddress": null, + "label": "Telestream Surveyor driver", + "modules": [], + "name": "telestream_surveyor", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "CoreLike", + "DeviceLike", + "StatusLike", + "PortLike" + ], + "supportsExternal": true, + "supportsParameterApi": false, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.thomson_mxs-0.1.0", + "_vid": "com.nevion.thomson_mxs-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "thomson_mxs" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "Unified Management System", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "device", + "ipAddress": null, + "label": "Thomson MXS", + "modules": [], + "name": "thomson_mxs", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "ParameterControlLike", + "CoreLike", + "DeviceLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.thomson_vibe-0.1.0", + "_vid": "com.nevion.thomson_vibe-0.1.0", + "attachments": [ + { + "description": "Encoder configuration", + "name": "thomson_vibe.enc" + }, + { + "description": "Decoder configuration", + "name": "thomson_vibe.dec" + }, + { + "description": "IP configuration", + "name": "thomson_vibe.ip" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "Driver for Thomson ViBE codecs", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "encoderDecoder", + "ipAddress": null, + "label": "Thomson ViBE Codec", + "modules": [ + "Controller", + "Encoder|Decoder|FE-100BT" + ], + "name": "thomson_vibe", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [ + { + "name": "Thomson VIBE", + "swBuildTime": null, + "swVersion": null + } + ], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": "1.3.6.1.4.1.4947.2.11.2", + "status": "", + "supportedApis": [ + "ParameterControlLike", + "DeviceLike", + "DynamicLike", + "PortLike", + "CoreLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.tns4200-0.1.0", + "_vid": "com.nevion.tns4200-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "tns4200" + } + ], + "configurableDevice": true, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom setting fields for Platform4000", + "label": "Platform4000" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.nevion.null.reuse_ts_element": { + "_schema": { + "default": false, + "descriptor": { + "desc": "", + "label": "Enable to activate logic to join existing TS input element for ASI outputs when setting up multicast with identical settings" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "Driver for the Nevion TNS4200 Monitoring Probe", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "monitor", + "ipAddress": null, + "label": "Nevion TNS4200", + "modules": [ + "System", + "Slot [0-4]" + ], + "name": "tns4200", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [ + { + "name": "TNS4200", + "swBuildTime": null, + "swVersion": "1.2.2" + } + ], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": "1.3.6.1.4.1.8768.10.35", + "status": "", + "supportedApis": [ + "ParameterControlLike", + "DeviceLike", + "DynamicLike", + "PortLike", + "CoreLike", + "StatusLike", + "MaintenanceLike", + "NetworkServiceLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [ + { + "descriptor": { + "desc": "Last bitrate measured.", + "label": "Ts Pid Bitrate" + }, + "id": "ts.pid.bit.rate", + "isAlarmTrigger": false, + "isCounter": false, + "unit": "" + }, + { + "descriptor": { + "desc": "Last average bitrate measured.", + "label": "Ts Pid Average Bitrate" + }, + "id": "ts.pid.average.bit.rate", + "isAlarmTrigger": false, + "isCounter": false, + "unit": "" + }, + { + "descriptor": { + "desc": "Continuity error counter.", + "label": "Ts Pid Continuity Error Counter" + }, + "id": "ts.pid.continuity.error.counter", + "isAlarmTrigger": false, + "isCounter": false, + "unit": "" + }, + { + "descriptor": { + "desc": "Last bitrate sampled for program.", + "label": "Ts Service Bitrate" + }, + "id": "ts.service.bit.rate", + "isAlarmTrigger": false, + "isCounter": false, + "unit": "" + }, + { + "descriptor": { + "desc": "Effective bitrate, i.e., bitrate without null packets.", + "label": "Ts Effective Bitrate" + }, + "id": "ts.effective.bit.rate", + "isAlarmTrigger": false, + "isCounter": false, + "unit": "" + }, + { + "descriptor": { + "desc": "Total bitrate.", + "label": "Ts Total Bitrate" + }, + "id": "ts.total.bit.rate", + "isAlarmTrigger": false, + "isCounter": false, + "unit": "" + }, + { + "descriptor": { + "desc": "Inter arrival time, i.e., the time between arrivals into the system.", + "label": "Tsoip Rx Sips Iat" + }, + "id": "tsoip.rx.sips.iat", + "isAlarmTrigger": false, + "isCounter": false, + "unit": "" + }, + { + "descriptor": { + "desc": "Packet delay factor.", + "label": "Tsoip Rx Sips Pdv" + }, + "id": "tsoip.rx.sips.pdv", + "isAlarmTrigger": false, + "isCounter": false, + "unit": "" + }, + { + "descriptor": { + "desc": "Current buffer.", + "label": "Tsoip Rx Buff Lat" + }, + "id": "tsoip.rx.buff.lat", + "isAlarmTrigger": false, + "isCounter": false, + "unit": "" + }, + { + "descriptor": { + "desc": "Current utilization fraction of buffer, with the signal currently received and configured parameters.", + "label": "Tsoip Rx Buff Util" + }, + "id": "tsoip.rx.buff.util", + "isAlarmTrigger": false, + "isCounter": false, + "unit": "" + }, + { + "descriptor": { + "desc": "SFP Channel internal bias current", + "label": "Sfp Internal Current" + }, + "id": "sfp.internal.current", + "isAlarmTrigger": false, + "isCounter": false, + "unit": "mA" + }, + { + "descriptor": { + "desc": "SFP Channel Internal Vcc", + "label": "Sfp Internal Voltage" + }, + "id": "sfp.internal.voltage", + "isAlarmTrigger": false, + "isCounter": false, + "unit": "V" + }, + { + "descriptor": { + "desc": "SFP Channel internal temperature", + "label": "Sfp Internal Temp" + }, + "id": "sfp.internal.temp", + "isAlarmTrigger": false, + "isCounter": false, + "unit": "C" + }, + { + "descriptor": { + "desc": "SFP Channel RX input power", + "label": "Sfp Rx Effect" + }, + "id": "sfp.rx.effect", + "isAlarmTrigger": false, + "isCounter": false, + "unit": "mW" + }, + { + "descriptor": { + "desc": "SFP Channel RX input power", + "label": "Sfp Rx Effect Log" + }, + "id": "sfp.rx.effect.log", + "isAlarmTrigger": false, + "isCounter": false, + "unit": "dBm" + }, + { + "descriptor": { + "desc": "SFP Channel TX output power", + "label": "Sfp Tx Effect" + }, + "id": "sfp.tx.effect", + "isAlarmTrigger": false, + "isCounter": false, + "unit": "mW" + }, + { + "descriptor": { + "desc": "SFP Channel TX output power", + "label": "Sfp Tx Effect Log" + }, + "id": "sfp.tx.effect.log", + "isAlarmTrigger": false, + "isCounter": false, + "unit": "dBm" + } + ], + "updateableDevice": false, + "updateableModule": { + "ASI-Input/Output-Board": { + "availableBanks": [], + "rebootOption": 0 + }, + "DVB-T/T2-Demodulator": { + "availableBanks": [], + "rebootOption": 0 + }, + "GNSS-clock-reference-board": { + "availableBanks": [], + "rebootOption": 0 + }, + "Main-Board": { + "availableBanks": [], + "rebootOption": 0 + } + }, + "version": "0.1.0" + }, + { + "_id": "com.nevion.tns460-0.1.0", + "_vid": "com.nevion.tns460-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "tns460" + } + ], + "configurableDevice": true, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "Driver for the Nevion TNS460 HD/SD-SDI Monitor", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "monitor", + "ipAddress": null, + "label": "Nevion TNS460", + "modules": [ + "System", + "Network", + "Clock Regulator", + "SDI Inputs", + "Monitors" + ], + "name": "tns460", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [ + { + "name": "TNS460", + "swBuildTime": null, + "swVersion": null + } + ], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": "1.3.6.1.4.1.22909.3.30", + "status": "", + "supportedApis": [ + "ParameterControlLike", + "DeviceLike", + "DynamicLike", + "CoreLike", + "StatusLike", + "MaintenanceLike", + "NetworkServiceLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": true, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.tns541-0.1.0", + "_vid": "com.nevion.tns541-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "tns541" + } + ], + "configurableDevice": true, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "Driver for the Nevion TNS541 Seamless TS Monitoring Switch", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "monitor", + "ipAddress": null, + "label": "Nevion TNS541", + "modules": [ + "System", + "Network", + "Clock Regulator", + "Relay [N+1]" + ], + "name": "tns541", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [ + { + "name": "TNS541", + "swBuildTime": null, + "swVersion": null + } + ], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": "1.3.6.1.4.1.22909.3.11", + "status": "", + "supportedApis": [ + "ParameterControlLike", + "DeviceLike", + "DynamicLike", + "MaintenanceLike", + "CoreLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": true, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.tns544-0.1.0", + "_vid": "com.nevion.tns544-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "tns544" + } + ], + "configurableDevice": true, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "Driver for the Nevion TNS544 TSoIP Switch", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "monitor", + "ipAddress": null, + "label": "Nevion TNS544", + "modules": [ + "System", + "Network", + "Switch Inputs", + "IP Inputs" + ], + "name": "tns544", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [ + { + "name": "TNS544", + "swBuildTime": null, + "swVersion": null + } + ], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": "1.3.6.1.4.1.22909.3.21", + "status": "", + "supportedApis": [ + "ParameterControlLike", + "DeviceLike", + "DynamicLike", + "MaintenanceLike", + "CoreLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": true, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.tns546-0.1.0", + "_vid": "com.nevion.tns546-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "tns546" + } + ], + "configurableDevice": true, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "Driver for the Nevion TNS546 TS Monitor", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "monitor", + "ipAddress": null, + "label": "Nevion TNS546", + "modules": [ + "System", + "Network", + "ASI Inputs", + "ASI Outputs", + "IP Inputs" + ], + "name": "tns546", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [ + { + "name": "TNS546", + "swBuildTime": null, + "swVersion": null + } + ], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": "1.3.6.1.4.1.22909.3.16", + "status": "", + "supportedApis": [ + "ParameterControlLike", + "DeviceLike", + "DynamicLike", + "MaintenanceLike", + "CoreLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": true, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.tns547-0.1.0", + "_vid": "com.nevion.tns547-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "tns547" + } + ], + "configurableDevice": true, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "Driver for the Nevion TNS547 DTT Monitor", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "monitor", + "ipAddress": null, + "label": "Nevion TNS547", + "modules": [], + "name": "tns547", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [ + { + "name": "TNS547", + "swBuildTime": null, + "swVersion": null + } + ], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": "1.3.6.1.4.1.22909.3.27", + "status": "", + "supportedApis": [ + "ParameterControlLike", + "DeviceLike", + "DynamicLike", + "MaintenanceLike", + "CoreLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": true, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.tvg420-0.1.0", + "_vid": "com.nevion.tvg420-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "tvg420" + } + ], + "configurableDevice": true, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "Driver for the Nevion TVG420 ASI to IP Video Gateway", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "gateway", + "ipAddress": null, + "label": "Nevion TVG420", + "modules": [ + "System", + "Network", + "Clock Regulator", + "ASI Inputs", + "ASI Outputs", + "IP Inputs", + "IP Outputs" + ], + "name": "tvg420", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [ + { + "name": "TVG420", + "swBuildTime": null, + "swVersion": null + } + ], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": "1.3.6.1.4.1.22909.3.1", + "status": "", + "supportedApis": [ + "ParameterControlLike", + "DeviceLike", + "DynamicLike", + "MaintenanceLike", + "PortLike", + "CoreLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": true, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.tvg425-0.1.0", + "_vid": "com.nevion.tvg425-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "tvg425" + } + ], + "configurableDevice": true, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "Driver for the Nevion TVG425 ASI to IP Video Gateway", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "gateway", + "ipAddress": null, + "label": "Nevion TVG425", + "modules": [ + "System", + "Network", + "Clock Regulator", + "ASI Inputs", + "ASI Outputs", + "Switch Inputs", + "Streams", + "IP Inputs" + ], + "name": "tvg425", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [ + { + "name": "TVG425", + "swBuildTime": null, + "swVersion": null + } + ], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": "1.3.6.1.4.1.22909.3.18", + "status": "", + "supportedApis": [ + "ParameterControlLike", + "DeviceLike", + "DynamicLike", + "MaintenanceLike", + "PortLike", + "CoreLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": true, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.tvg430-0.1.0", + "_vid": "com.nevion.tvg430-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "tvg430" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "Driver for Nevion TVG430/TVG415 HD JPEG2000 gateways", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "gateway", + "ipAddress": null, + "label": "Nevion TVG430/TVG415", + "modules": [], + "name": "tvg430", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [ + { + "name": "TVG415", + "swBuildTime": null, + "swVersion": null + }, + { + "name": "TVG430", + "swBuildTime": null, + "swVersion": null + } + ], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": "1.3.6.1.4.1.22909.3.3", + "status": "", + "supportedApis": [ + "ParameterControlLike", + "DeviceLike", + "DynamicLike", + "PortLike", + "CoreLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.tvg450-0.1.0", + "_vid": "com.nevion.tvg450-0.1.0", + "attachments": [ + { + "description": "Base configuration (partial or full)", + "name": "tvg450.base" + } + ], + "configurableDevice": true, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "Driver for the Nevion TVG450 JPEG2000 Gateway", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "gateway", + "ipAddress": null, + "label": "Nevion TVG450", + "modules": [ + "System", + "Network", + "Encoder 1-4", + "Decoder 1-4", + "IP Inputs", + "IP Outputs", + "SDI Inputs", + "SDI Outputs" + ], + "name": "tvg450", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [ + { + "name": "TVG450", + "swBuildTime": null, + "swVersion": null + } + ], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": "1.3.6.1.4.1.22909.3.10", + "status": "", + "supportedApis": [ + "ParameterControlLike", + "DeviceLike", + "DynamicLike", + "MaintenanceLike", + "PortLike", + "CoreLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": true, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.tvg480-0.1.0", + "_vid": "com.nevion.tvg480-0.1.0", + "attachments": [ + { + "description": "Base configuration (partial or full)", + "name": "tvg480.base" + } + ], + "configurableDevice": true, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom setting fields for TVG480", + "label": "TVG480" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.nevion.tvg480.control_mode": { + "_schema": { + "default": "full_control", + "descriptor": { + "desc": "Which control mode has Videoipath over the device.", + "label": "Control Mode" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [ + { + "descriptor": { + "desc": "Standard control mode where Videoipath assumes it is the only master of a resource and takes full control over it.", + "label": "Full control" + }, + "value": "full_control" + }, + { + "descriptor": { + "desc": "Special control mode where Videoipath shares a resource with another external system. Videoipath assumes no control over the resource unless a connection is active. In addition, before establishing a connection the configuration is backed up on the resource and reloaded when the connection is ended.", + "label": "Partial control with config restore" + }, + "value": "partial_control_with_config_restore" + } + ], + "status": "Current", + "type": "string" + } + }, + "com.nevion.tvg480.partial_control_config_slot": { + "_schema": { + "default": 0, + "descriptor": { + "desc": "Config slot to use when partial control with config restore is used.", + "label": "Partial control config slot" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 0, + 7, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "Driver for the Nevion TVG480 Post Production Gateway", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "gateway", + "ipAddress": null, + "label": "Nevion TVG480", + "modules": [ + "System", + "Network", + "Encoder 1-4", + "Decoder 1-4", + "IP Inputs", + "IP Outputs", + "SDI Inputs", + "SDI Outputs" + ], + "name": "tvg480", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Operational mode setting fields for TVG480", + "label": "TVG480" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "enc": { + "_schema": { + "default": "Decode", + "descriptor": { + "desc": "enc", + "label": "Encoder/Decoder" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [ + { + "descriptor": { + "desc": "Both modules are configured as decoders.", + "label": "Decode only" + }, + "value": "Decode" + }, + { + "descriptor": { + "desc": "Both modules are configured as encoders.", + "label": "Encode only" + }, + "value": "Encode" + }, + { + "descriptor": { + "desc": "The first module is set as encoder while the second is set as decoder.", + "label": "Encode/Decode" + }, + "value": "EncodeDecode" + } + ], + "status": "Current", + "type": "string" + } + }, + "eth": { + "_schema": { + "default": "1000Base-T", + "descriptor": { + "desc": "eth", + "label": "Ethernet interface" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [ + { + "descriptor": { + "desc": "", + "label": "1000Base-T" + }, + "value": "1000Base-T" + }, + { + "descriptor": { + "desc": "", + "label": "SFP" + }, + "value": "SFP" + } + ], + "status": "Current", + "type": "string" + } + }, + "fieldrate": { + "_schema": { + "default": "50 Hz/24 Hz", + "descriptor": { + "desc": "fieldrate", + "label": "Video field rate" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [ + { + "descriptor": { + "desc": "", + "label": "50 Hz/24 Hz" + }, + "value": "50 Hz/24 Hz" + }, + { + "descriptor": { + "desc": "", + "label": "59.94 Hz/23.98 Hz" + }, + "value": "59.94 Hz/23.98 Hz" + }, + { + "descriptor": { + "desc": "", + "label": "60 Hz" + }, + "value": "60 Hz" + } + ], + "status": "Current", + "type": "string" + } + } + } + } + }, + "organization": "com.nevion", + "products": [ + { + "name": "TVG480", + "swBuildTime": null, + "swVersion": null + } + ], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": "1.3.6.1.4.1.22909.3.17", + "status": "", + "supportedApis": [ + "ParameterControlLike", + "DeviceLike", + "DynamicLike", + "MaintenanceLike", + "PortLike", + "TestLike", + "CoreLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [ + { + "descriptor": { + "desc": "", + "label": "Ethernet Rtp Rx Sequence Errors Count" + }, + "id": "ethernet.rtp.rx.sequence.errors.count", + "isAlarmTrigger": false, + "isCounter": false, + "unit": "" + }, + { + "descriptor": { + "desc": "", + "label": "Ethernet Rtp Rx Sequence Errors Diff" + }, + "id": "ethernet.rtp.rx.sequence.errors.diff", + "isAlarmTrigger": false, + "isCounter": false, + "unit": "" + } + ], + "updateableDevice": true, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.tx9-0.1.0", + "_vid": "com.nevion.tx9-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "tx9" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "Terrestrial Transmitter", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "device", + "ipAddress": null, + "label": "R&S Tx9", + "modules": [], + "name": "tx9", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": "1.3.6.1.4.1.2566.127.1.2.216", + "status": "", + "supportedApis": [ + "ParameterControlLike", + "CoreLike", + "DeviceLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.txdarwin_dynamic-0.1.0", + "_vid": "com.nevion.txdarwin_dynamic-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "txdarwin_dynamic" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom setting fields for Tx Darwin", + "label": "TxDarwin" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.nevion.txdarwin_dynamic.port": { + "_schema": { + "default": 9000, + "descriptor": { + "desc": "The HTTP port used to reach the GraphQL API", + "label": "GraphQL port" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 1, + 65535, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "Techex TX Darwin for controlling a generic device", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "gateway", + "ipAddress": null, + "label": "Techex tx darwin (Dynamic)", + "modules": [], + "name": "txdarwin_dynamic", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "CoreLike", + "DeviceLike", + "PortLike", + "TestLike" + ], + "supportsExternal": true, + "supportsParameterApi": false, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.txdarwin_static-0.1.0", + "_vid": "com.nevion.txdarwin_static-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "txdarwin_static" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom setting fields for Tx Darwin", + "label": "TxDarwin" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.nevion.txdarwin_static.port": { + "_schema": { + "default": 9000, + "descriptor": { + "desc": "The HTTP port used to reach the GraphQL API", + "label": "GraphQL port" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 1, + 65535, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "Techex TX Darwin for controlling an pre-setup device", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "gateway", + "ipAddress": null, + "label": "Techex tx darwin (Static)", + "modules": [], + "name": "txdarwin_static", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "CoreLike", + "DeviceLike", + "PortLike", + "TestLike" + ], + "supportsExternal": true, + "supportsParameterApi": false, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.txedge-0.1.0", + "_vid": "com.nevion.txedge-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "txedge" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom Data Fields for Techex tx edge", + "label": "Techex tx edge" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.nevion.txedge.selected_edge": { + "_schema": { + "default": "", + "descriptor": { + "desc": "Write down the name of the edge you want to use", + "label": "Choose tx edge" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "TS protection and monitoring gateway", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "gateway", + "ipAddress": null, + "label": "Techex tx edge", + "modules": [], + "name": "txedge", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "CoreLike", + "DeviceLike", + "StatusLike", + "PortLike" + ], + "supportsExternal": true, + "supportsParameterApi": false, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.v__matrix-0.1.0", + "_vid": "com.nevion.v__matrix-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "v__matrix" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "Software-defined IP-Routing, Processing & Multi-Viewing Platform", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "none", + "ipAddress": null, + "label": "Lawo V__matrix", + "modules": [], + "name": "v__matrix", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "DeviceLike", + "PortLike", + "CoreLike", + "StatusLike", + "MatrixControlLike", + "ParameterControlLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.v__matrix_smv-0.1.0", + "_vid": "com.nevion.v__matrix_smv-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "v__matrix_smv" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "V_matrix Standalone Multiviewer", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "none", + "ipAddress": null, + "label": "Lawo V__matrix (SMV)", + "modules": [], + "name": "v__matrix_smv", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "DeviceLike", + "PortLike", + "CoreLike", + "StatusLike", + "MatrixControlLike", + "ParameterControlLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.ventura-0.1.0", + "_vid": "com.nevion.ventura-0.1.0", + "attachments": [ + { + "description": "System configuration", + "name": "ventura.vs906-da.config" + }, + { + "description": "System configuration", + "name": "ventura.vs906-da.input" + }, + { + "description": "System configuration", + "name": "ventura.vs906-da.output" + }, + { + "description": "System configuration", + "name": "ventura.vs902-j2ke3g.config" + }, + { + "description": "System configuration", + "name": "ventura.vs902-j2ke3g.input" + }, + { + "description": "System configuration", + "name": "ventura.vs902-aed.config" + }, + { + "description": "System configuration", + "name": "ventura.vs902-aed.input" + }, + { + "description": "System configuration", + "name": "ventura.vs902-aed.output" + }, + { + "description": "System configuration", + "name": "ventura.vs906-aa.config" + }, + { + "description": "System configuration", + "name": "ventura.vs906-aa.input" + }, + { + "description": "System configuration", + "name": "ventura.vs906-aa.output" + }, + { + "description": "System configuration", + "name": "ventura.vs902-j2k-fs-codec.config" + }, + { + "description": "System configuration", + "name": "ventura.vs902-j2k-fs-codec.input" + }, + { + "description": "System configuration", + "name": "ventura.vs902-j2k-fs-codec.output" + }, + { + "description": "System configuration", + "name": "ventura.vs902-j2kd3g.config" + }, + { + "description": "System configuration", + "name": "ventura.vs902-j2kd3g.output" + }, + { + "description": "System configuration", + "name": "ventura.vs902-j2k-fs-dec.config" + }, + { + "description": "System configuration", + "name": "ventura.vs902-j2k-fs-dec.output" + }, + { + "description": "System configuration", + "name": "ventura.vs902-j2kc.config" + }, + { + "description": "System configuration", + "name": "ventura.vs902-j2kc.input" + }, + { + "description": "System configuration", + "name": "ventura.vs902-j2kc.output" + }, + { + "description": "System configuration", + "name": "ventura.vs902-j2ketr01.config" + }, + { + "description": "System configuration", + "name": "ventura.vs902-j2ketr01.input" + }, + { + "description": "System configuration", + "name": "ventura.vs906-e1.config" + }, + { + "description": "System configuration", + "name": "ventura.vs906-e1.input" + }, + { + "description": "System configuration", + "name": "ventura.vs906-e1.output" + }, + { + "description": "System configuration", + "name": "ventura.vs908-demux.config" + }, + { + "description": "System configuration", + "name": "ventura.vs908-demux.output" + }, + { + "description": "System configuration", + "name": "ventura.vs902-j2kd.config" + }, + { + "description": "System configuration", + "name": "ventura.vs902-j2kd.output" + }, + { + "description": "System configuration", + "name": "ventura.vs909.config" + }, + { + "description": "System configuration", + "name": "ventura.vs909.input" + }, + { + "description": "System configuration", + "name": "ventura.vs909.output" + }, + { + "description": "System configuration", + "name": "ventura.vs908-mux.config" + }, + { + "description": "System configuration", + "name": "ventura.vs908-mux.input" + }, + { + "description": "System configuration", + "name": "ventura.vs902-j2ke10ge.config" + }, + { + "description": "System configuration", + "name": "ventura.vs902-j2ke10ge.input" + }, + { + "description": "System configuration", + "name": "ventura.vs902-j2ke.config" + }, + { + "description": "System configuration", + "name": "ventura.vs902-j2ke.input" + }, + { + "description": "System configuration", + "name": "ventura.vs902-j2kd10ge.config" + }, + { + "description": "System configuration", + "name": "ventura.vs902-j2kd10ge.output" + }, + { + "description": "System configuration", + "name": "ventura.vs902-ma.config" + }, + { + "description": "System configuration", + "name": "ventura.vs902-ma.input" + }, + { + "description": "System configuration", + "name": "ventura.vs902-ma.output" + }, + { + "description": "System configuration", + "name": "ventura.vs902-lc.config" + }, + { + "description": "System configuration", + "name": "ventura.vs902-lc.input" + }, + { + "description": "System configuration", + "name": "ventura.vs902-lc.output" + }, + { + "description": "System configuration", + "name": "ventura.vs902-j2k-fs-enc.config" + }, + { + "description": "System configuration", + "name": "ventura.vs902-j2k-fs-enc.input" + }, + { + "description": "System configuration", + "name": "ventura.vs902-j2kdtr01.config" + }, + { + "description": "System configuration", + "name": "ventura.vs902-j2kdtr01.output" + } + ], + "configurableDevice": false, + "configurableModule": [ + "VS901ASIEncoder", + "VS901ASIDecoder", + "VS901VOIPEncoder", + "VS901VOIPDecoder", + "VS902", + "VS902J2KC", + "VS902J2KE", + "VS902J2KD", + "VS902J2KFSCodec", + "VS902J2KFSEnc", + "VS902J2KFSDec", + "VS902J2KE3G", + "VS902J2KD3G", + "VS902J2KE10GE", + "VS902J2KD10GE", + "VS902J2KETR01", + "VS902J2KDTR01", + "VS902AED", + "VS902LC", + "VS902MA", + "VS904AID", + "VS904AIE2AES", + "VS904AIE4AES", + "VS906", + "VS906AA", + "VS906DA", + "VS906E1", + "VS908Mux", + "VS908DeMux", + "VS909", + "VS811Mux", + "VS811DeMux", + "VS861Mux", + "VS861DeMux" + ], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "A driver for the Ventura family products", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "gateway", + "ipAddress": null, + "label": "Nevion Ventura", + "modules": [], + "name": "ventura", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [ + { + "name": "VS101 Chassis", + "swBuildTime": null, + "swVersion": null + }, + { + "name": "VS103 Chassis", + "swBuildTime": null, + "swVersion": null + } + ], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": "1.3.6.1.4.1.13130.4", + "status": "", + "supportedApis": [ + "ParameterControlLike", + "DeviceLike", + "DynamicLike", + "MaintenanceLike", + "PortLike", + "CoreLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": { + "AEMS": { + "availableBanks": [], + "rebootOption": 1 + }, + "VS902": { + "availableBanks": [ + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9" + ], + "rebootOption": 0 + }, + "VS902AED": { + "availableBanks": [ + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9" + ], + "rebootOption": 0 + }, + "VS902J2KC": { + "availableBanks": [ + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9" + ], + "rebootOption": 0 + }, + "VS902J2KD": { + "availableBanks": [ + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9" + ], + "rebootOption": 0 + }, + "VS902J2KD10GE": { + "availableBanks": [ + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9" + ], + "rebootOption": 0 + }, + "VS902J2KD3G": { + "availableBanks": [ + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9" + ], + "rebootOption": 0 + }, + "VS902J2KDTR01": { + "availableBanks": [ + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9" + ], + "rebootOption": 0 + }, + "VS902J2KE": { + "availableBanks": [ + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9" + ], + "rebootOption": 0 + }, + "VS902J2KE10GE": { + "availableBanks": [ + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9" + ], + "rebootOption": 0 + }, + "VS902J2KE3G": { + "availableBanks": [ + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9" + ], + "rebootOption": 0 + }, + "VS902J2KETR01": { + "availableBanks": [ + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9" + ], + "rebootOption": 0 + }, + "VS902J2KFSCodec": { + "availableBanks": [ + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9" + ], + "rebootOption": 0 + }, + "VS902J2KFSDec": { + "availableBanks": [ + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9" + ], + "rebootOption": 0 + }, + "VS902J2KFSEnc": { + "availableBanks": [ + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9" + ], + "rebootOption": 0 + }, + "VS902LC": { + "availableBanks": [ + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9" + ], + "rebootOption": 0 + }, + "VS902MA": { + "availableBanks": [ + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9" + ], + "rebootOption": 0 + }, + "VS906": { + "availableBanks": [ + "1", + "2", + "3", + "4", + "5", + "6" + ], + "rebootOption": 0 + }, + "VS906AA": { + "availableBanks": [ + "1", + "2", + "3", + "4", + "5", + "6" + ], + "rebootOption": 0 + }, + "VS906DA": { + "availableBanks": [ + "1", + "2", + "3", + "4", + "5", + "6" + ], + "rebootOption": 0 + }, + "VS906E1": { + "availableBanks": [ + "1", + "2", + "3", + "4", + "5", + "6" + ], + "rebootOption": 0 + } + }, + "version": "0.1.0" + }, + { + "_id": "com.nevion.virtuoso-0.1.0", + "_vid": "com.nevion.virtuoso-0.1.0", + "attachments": [ + { + "description": "Base configuration (partial or full)", + "name": "null.base" + } + ], + "configurableDevice": true, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom setting fields for Platform4000", + "label": "Platform4000" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.nevion.null.reuse_ts_element": { + "_schema": { + "default": false, + "descriptor": { + "desc": "", + "label": "Enable to activate logic to join existing TS input element for ASI outputs when setting up multicast with identical settings" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "Driver for the Virtuoso Media Gateway", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "gateway", + "ipAddress": null, + "label": "Nevion Virtuoso FA", + "modules": [ + "Slot [0-4]" + ], + "name": "virtuoso", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [ + { + "name": "Virtuoso", + "swBuildTime": null, + "swVersion": null + } + ], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": "1.3.6.1.4.1.8768.10.39", + "status": "", + "supportedApis": [ + "ParameterControlLike", + "DeviceLike", + "DynamicLike", + "PortLike", + "CoreLike", + "StatusLike", + "MaintenanceLike", + "NetworkServiceLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [ + { + "descriptor": { + "desc": "Last bitrate measured.", + "label": "Ts Pid Bitrate" + }, + "id": "ts.pid.bit.rate", + "isAlarmTrigger": false, + "isCounter": false, + "unit": "" + }, + { + "descriptor": { + "desc": "Last average bitrate measured.", + "label": "Ts Pid Average Bitrate" + }, + "id": "ts.pid.average.bit.rate", + "isAlarmTrigger": false, + "isCounter": false, + "unit": "" + }, + { + "descriptor": { + "desc": "Continuity error counter.", + "label": "Ts Pid Continuity Error Counter" + }, + "id": "ts.pid.continuity.error.counter", + "isAlarmTrigger": false, + "isCounter": false, + "unit": "" + }, + { + "descriptor": { + "desc": "Last bitrate sampled for program.", + "label": "Ts Service Bitrate" + }, + "id": "ts.service.bit.rate", + "isAlarmTrigger": false, + "isCounter": false, + "unit": "" + }, + { + "descriptor": { + "desc": "Effective bitrate, i.e., bitrate without null packets.", + "label": "Ts Effective Bitrate" + }, + "id": "ts.effective.bit.rate", + "isAlarmTrigger": false, + "isCounter": false, + "unit": "" + }, + { + "descriptor": { + "desc": "Total bitrate.", + "label": "Ts Total Bitrate" + }, + "id": "ts.total.bit.rate", + "isAlarmTrigger": false, + "isCounter": false, + "unit": "" + }, + { + "descriptor": { + "desc": "Inter arrival time, i.e., the time between arrivals into the system.", + "label": "Tsoip Rx Sips Iat" + }, + "id": "tsoip.rx.sips.iat", + "isAlarmTrigger": false, + "isCounter": false, + "unit": "" + }, + { + "descriptor": { + "desc": "Packet delay factor.", + "label": "Tsoip Rx Sips Pdv" + }, + "id": "tsoip.rx.sips.pdv", + "isAlarmTrigger": false, + "isCounter": false, + "unit": "" + }, + { + "descriptor": { + "desc": "Current buffer.", + "label": "Tsoip Rx Buff Lat" + }, + "id": "tsoip.rx.buff.lat", + "isAlarmTrigger": false, + "isCounter": false, + "unit": "" + }, + { + "descriptor": { + "desc": "Current utilization fraction of buffer, with the signal currently received and configured parameters.", + "label": "Tsoip Rx Buff Util" + }, + "id": "tsoip.rx.buff.util", + "isAlarmTrigger": false, + "isCounter": false, + "unit": "" + }, + { + "descriptor": { + "desc": "SFP Channel internal bias current", + "label": "Sfp Internal Current" + }, + "id": "sfp.internal.current", + "isAlarmTrigger": false, + "isCounter": false, + "unit": "mA" + }, + { + "descriptor": { + "desc": "SFP Channel Internal Vcc", + "label": "Sfp Internal Voltage" + }, + "id": "sfp.internal.voltage", + "isAlarmTrigger": false, + "isCounter": false, + "unit": "V" + }, + { + "descriptor": { + "desc": "SFP Channel internal temperature", + "label": "Sfp Internal Temp" + }, + "id": "sfp.internal.temp", + "isAlarmTrigger": false, + "isCounter": false, + "unit": "C" + }, + { + "descriptor": { + "desc": "SFP Channel RX input power", + "label": "Sfp Rx Effect" + }, + "id": "sfp.rx.effect", + "isAlarmTrigger": false, + "isCounter": false, + "unit": "mW" + }, + { + "descriptor": { + "desc": "SFP Channel RX input power", + "label": "Sfp Rx Effect Log" + }, + "id": "sfp.rx.effect.log", + "isAlarmTrigger": false, + "isCounter": false, + "unit": "dBm" + }, + { + "descriptor": { + "desc": "SFP Channel TX output power", + "label": "Sfp Tx Effect" + }, + "id": "sfp.tx.effect", + "isAlarmTrigger": false, + "isCounter": false, + "unit": "mW" + }, + { + "descriptor": { + "desc": "SFP Channel TX output power", + "label": "Sfp Tx Effect Log" + }, + "id": "sfp.tx.effect.log", + "isAlarmTrigger": false, + "isCounter": false, + "unit": "dBm" + } + ], + "updateableDevice": false, + "updateableModule": { + "ASI-Input/Output-Board": { + "availableBanks": [], + "rebootOption": 0 + }, + "DVB-T/T2-Demodulator": { + "availableBanks": [], + "rebootOption": 0 + }, + "GNSS-clock-reference-board": { + "availableBanks": [], + "rebootOption": 0 + }, + "H-264-Encoder/Decoder": { + "availableBanks": [], + "rebootOption": 0 + }, + "High-Bit-Rate-Accelerator": { + "availableBanks": [], + "rebootOption": 0 + }, + "Main-Board": { + "availableBanks": [], + "rebootOption": 0 + }, + "Multi-codec": { + "availableBanks": [], + "rebootOption": 0 + } + }, + "version": "0.1.0" + }, + { + "_id": "com.nevion.virtuoso_fa-0.1.0", + "_vid": "com.nevion.virtuoso_fa-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "virtuoso_fa" + } + ], + "configurableDevice": false, + "configurableModule": [ + "AUD-AES3", + "TICO-UHD-E1D1", + "TICO-UHD-E2", + "TICO-UHD-D2", + "TICO-UHD-E2-12G", + "TICO-UHD-D2-IP-25G", + "TICO-UHD-E2-IP-25G", + "SDI-IP-2022", + "SDI-IP-2110", + "HW-H264-X1", + "MADI", + "AUD-PROC-MADI-IP", + "XS-ENC", + "XS-DEC", + "TXS-HD-E3", + "TXS-HD-D3", + "IPME-RTP", + "JPEG2000-ENC", + "JPEG2000-DEC", + "J2K-HD-E2D2", + "J2K-HD-D4", + "J2K-HD-E4", + "UPLINK-10G", + "UPLINK-25G", + "VID-PROC-UHD-12G" + ], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom Data Fields for Nevion Virtuoso FA", + "label": "Nevion Virtuoso FA" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.nevion.virtuoso_fa.enable_hibernation": { + "_schema": { + "default": false, + "descriptor": { + "desc": "Automatically put modules not involved in any connection into hibernation. Automatically wake up hibernating modules when setting up a connection involving them.", + "label": "Enable hibernation & wake up(supported for v.3.2.14 and above)" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "Driver for Virtuoso device", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "gateway", + "ipAddress": null, + "label": "Virtuoso FA", + "modules": [], + "name": "virtuoso_fa", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [ + { + "name": "Virtuoso", + "swBuildTime": null, + "swVersion": null + } + ], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "ParameterControlLike", + "DeviceLike", + "DynamicLike", + "PortLike", + "CoreLike", + "StatusLike", + "MaintenanceLike", + "NetworkServiceLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": { + "ASI-Input/Output-Board": { + "availableBanks": [], + "rebootOption": 0 + }, + "AUD-PROC-MADI-IP": { + "availableBanks": [], + "rebootOption": 0 + }, + "DVB-T/T2-Demodulator": { + "availableBanks": [], + "rebootOption": 0 + }, + "GNSS-clock-reference-board": { + "availableBanks": [], + "rebootOption": 0 + }, + "HW-H264-X1": { + "availableBanks": [], + "rebootOption": 0 + }, + "High-Bit-Rate-Accelerator": { + "availableBanks": [], + "rebootOption": 0 + }, + "J2K-HD-D4": { + "availableBanks": [], + "rebootOption": 0 + }, + "J2K-HD-E2D2": { + "availableBanks": [], + "rebootOption": 0 + }, + "J2K-HD-E4": { + "availableBanks": [], + "rebootOption": 0 + }, + "JPEG2000-DEC": { + "availableBanks": [], + "rebootOption": 0 + }, + "JPEG2000-ENC": { + "availableBanks": [], + "rebootOption": 0 + }, + "Main-Board": { + "availableBanks": [], + "rebootOption": 0 + }, + "Multi-codec": { + "availableBanks": [], + "rebootOption": 0 + }, + "SDI-IP-2022": { + "availableBanks": [], + "rebootOption": 0 + }, + "SDI-IP-2110": { + "availableBanks": [], + "rebootOption": 0 + } + }, + "version": "0.1.0" + }, + { + "_id": "com.nevion.virtuoso_mi-0.1.0", + "_vid": "com.nevion.virtuoso_mi-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "virtuoso_mi" + } + ], + "configurableDevice": false, + "configurableModule": [ + "AUD-AES3", + "TICO-UHD-E1D1", + "TICO-UHD-E2", + "TICO-UHD-D2", + "TICO-UHD-E2-12G", + "TICO-UHD-D2-IP-25G", + "TICO-UHD-E2-IP-25G", + "SDI-IP-2022", + "SDI-IP-2110", + "SDI-IP-H25", + "MADI", + "AUD-PROC-MADI-IP", + "XS-ENC", + "XS-DEC", + "TXS-HD-E3", + "TXS-HD-D3", + "IPME-RTP", + "JPEG2000-ENC", + "JPEG2000-DEC", + "JXS-D3", + "JXS-E3", + "JXS-TS-E4", + "JXS-TS-D4", + "JXS-D4-H25", + "JXS-E4-H25", + "JXS-TS-D3-H25", + "JXS-TS-E3-H25", + "J2K-HD-E2D2", + "J2K-HD-D4", + "J2K-HD-E4", + "UDC-IP-H25", + "UPLINK-10G", + "UPLINK-25G", + "VID-PROC-UHD-12G", + "HEVC-ENC", + "HEVC-DEC" + ], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom Data Fields for Nevion Virtuoso MI", + "label": "Nevion Virtuoso MI" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.nevion.virtuoso_mi.AdvancedReachabilityCheck": { + "_schema": { + "default": true, + "descriptor": { + "desc": "Use a more thorough communication check, this will report an IP address as down if all HBR cards have a status of 'Booting' ", + "label": "Enable advanced communication check" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.virtuoso_mi.enable_bulk_config": { + "_schema": { + "default": false, + "descriptor": { + "desc": "Configure this unit's audio elements using bulk API", + "label": "Enable bulk config" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.virtuoso_mi.enable_hibernation": { + "_schema": { + "default": false, + "descriptor": { + "desc": "Automatically put modules not involved in any connection into hibernation. Automatically wake up hibernating modules when setting up a connection involving them.", + "label": "Enable hibernation & wake up(supported for v.1.8.8 and above)" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.virtuoso_mi.linear_uplink_support": { + "_schema": { + "default": false, + "descriptor": { + "desc": "Support backplane routing to Uplink cards for Linear cards", + "label": "Support uplink routing for Linear cards" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.virtuoso_mi.madi_uplink_support": { + "_schema": { + "default": false, + "descriptor": { + "desc": "Support backplane routing to Uplink cards for MADI cards", + "label": "Support uplink routing for MADI cards" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "Driver for Virtuoso MI device", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "device", + "ipAddress": null, + "label": "Nevion Virtuoso MI", + "modules": [], + "name": "virtuoso_mi", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": "1.3.6.1.4.1.8768.10.40", + "status": "", + "supportedApis": [ + "DeviceLike", + "DynamicLike", + "PortLike", + "CoreLike", + "StatusLike", + "MatrixControlLike", + "ParameterControlLike", + "MaintenanceLike", + "NetworkServiceLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": { + "AUD-AES3": { + "availableBanks": [], + "rebootOption": 0 + }, + "AUD-PROC-MADI-IP": { + "availableBanks": [], + "rebootOption": 0 + }, + "HEVC-DEC": { + "availableBanks": [], + "rebootOption": 0 + }, + "HEVC-ENC": { + "availableBanks": [], + "rebootOption": 0 + }, + "IPME-RTP": { + "availableBanks": [], + "rebootOption": 0 + }, + "J2K-HD-D4": { + "availableBanks": [], + "rebootOption": 0 + }, + "J2K-HD-E2D2": { + "availableBanks": [], + "rebootOption": 0 + }, + "J2K-HD-E4": { + "availableBanks": [], + "rebootOption": 0 + }, + "JPEG2000-DEC": { + "availableBanks": [], + "rebootOption": 0 + }, + "JPEG2000-ENC": { + "availableBanks": [], + "rebootOption": 0 + }, + "JXS-D3": { + "availableBanks": [], + "rebootOption": 0 + }, + "JXS-D4-H25": { + "availableBanks": [], + "rebootOption": 0 + }, + "JXS-E3": { + "availableBanks": [], + "rebootOption": 0 + }, + "JXS-E4-H25": { + "availableBanks": [], + "rebootOption": 0 + }, + "JXS-TS-D3-H25": { + "availableBanks": [], + "rebootOption": 0 + }, + "JXS-TS-D4": { + "availableBanks": [], + "rebootOption": 0 + }, + "JXS-TS-E3-H25": { + "availableBanks": [], + "rebootOption": 0 + }, + "JXS-TS-E4": { + "availableBanks": [], + "rebootOption": 0 + }, + "MADI": { + "availableBanks": [], + "rebootOption": 0 + }, + "SDI-IP-2022": { + "availableBanks": [], + "rebootOption": 0 + }, + "SDI-IP-2110": { + "availableBanks": [], + "rebootOption": 0 + }, + "SDI-IP-H25": { + "availableBanks": [], + "rebootOption": 0 + }, + "TICO-UHD-D2": { + "availableBanks": [], + "rebootOption": 0 + }, + "TICO-UHD-D2-IP-25G": { + "availableBanks": [], + "rebootOption": 0 + }, + "TICO-UHD-E1D1": { + "availableBanks": [], + "rebootOption": 0 + }, + "TICO-UHD-E2": { + "availableBanks": [], + "rebootOption": 0 + }, + "TICO-UHD-E2-12G": { + "availableBanks": [], + "rebootOption": 0 + }, + "TICO-UHD-E2-IP-25G": { + "availableBanks": [], + "rebootOption": 0 + }, + "TXS-HD-D3": { + "availableBanks": [], + "rebootOption": 0 + }, + "TXS-HD-E3": { + "availableBanks": [], + "rebootOption": 0 + }, + "UDC-IP-H25": { + "availableBanks": [], + "rebootOption": 0 + }, + "UPLINK-10G": { + "availableBanks": [], + "rebootOption": 0 + }, + "UPLINK-25G": { + "availableBanks": [], + "rebootOption": 0 + }, + "VID-PROC-UHD-12G": { + "availableBanks": [], + "rebootOption": 0 + }, + "Virtuoso-MI": { + "availableBanks": [], + "rebootOption": 0 + }, + "XS-DEC": { + "availableBanks": [], + "rebootOption": 0 + }, + "XS-ENC": { + "availableBanks": [], + "rebootOption": 0 + } + }, + "version": "0.1.0" + }, + { + "_id": "com.nevion.virtuoso_re-0.1.0", + "_vid": "com.nevion.virtuoso_re-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "virtuoso_re" + } + ], + "configurableDevice": false, + "configurableModule": [ + "ASI", + "AUD-AES3", + "TICO-UHD-E1D1", + "TICO-UHD-E2", + "TICO-UHD-D2", + "TICO-UHD-E2-12G", + "TICO-UHD-D2-IP-25G", + "TICO-UHD-E2-IP-25G", + "SDI-IP-2022", + "SDI-IP-2110", + "SDI-IP-H25", + "MADI", + "AUD-PROC-MADI-IP", + "XS-ENC", + "XS-DEC", + "TXS-HD-E3", + "TXS-HD-D3", + "IPME-RTP", + "JPEG2000-ENC", + "JPEG2000-DEC", + "JXS-D3", + "JXS-E3", + "JXS-TS-E4", + "JXS-TS-D4", + "JXS-D4-H25", + "JXS-E4-H25", + "JXS-TS-D3-H25", + "JXS-TS-E3-H25", + "J2K-HD-E2D2", + "J2K-HD-D4", + "J2K-HD-E4", + "UDC-IP-H25", + "UPLINK-10G", + "UPLINK-25G", + "VID-PROC-UHD-12G", + "HEVC-ENC", + "HEVC-DEC" + ], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom Data Fields for Nevion Virtuoso RE", + "label": "Nevion Virtuoso RE" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.nevion.virtuoso_re.AdvancedReachabilityCheck": { + "_schema": { + "default": true, + "descriptor": { + "desc": "Use a more thorough communication check, this will report an IP address as down if all HBR cards have a status of 'Booting' ", + "label": "Enable advanced communication check" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.virtuoso_re.enable_bulk_config": { + "_schema": { + "default": false, + "descriptor": { + "desc": "Configure this unit's audio elements using bulk API", + "label": "Enable bulk config" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.virtuoso_re.linear_uplink_support": { + "_schema": { + "default": false, + "descriptor": { + "desc": "Support backplane routing to Uplink cards for Linear cards", + "label": "Support uplink routing for Linear cards" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.virtuoso_re.madi_uplink_support": { + "_schema": { + "default": false, + "descriptor": { + "desc": "Support backplane routing to Uplink cards for MADI cards", + "label": "Support uplink routing for MADI cards" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "Driver for Virtuoso RE device", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "device", + "ipAddress": null, + "label": "Nevion Virtuoso RE", + "modules": [], + "name": "virtuoso_re", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": "1.3.6.1.4.1.8768.10.41", + "status": "", + "supportedApis": [ + "ParameterControlLike", + "DeviceLike", + "MaintenanceLike", + "PortLike", + "CoreLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": { + "ASI": { + "availableBanks": [], + "rebootOption": 0 + }, + "AUD-AES3": { + "availableBanks": [], + "rebootOption": 0 + }, + "AUD-PROC-MADI-IP": { + "availableBanks": [], + "rebootOption": 0 + }, + "HEVC-DEC": { + "availableBanks": [], + "rebootOption": 0 + }, + "HEVC-ENC": { + "availableBanks": [], + "rebootOption": 0 + }, + "IPME-RTP": { + "availableBanks": [], + "rebootOption": 0 + }, + "J2K-HD-D4": { + "availableBanks": [], + "rebootOption": 0 + }, + "J2K-HD-E2D2": { + "availableBanks": [], + "rebootOption": 0 + }, + "J2K-HD-E4": { + "availableBanks": [], + "rebootOption": 0 + }, + "JPEG2000-DEC": { + "availableBanks": [], + "rebootOption": 0 + }, + "JPEG2000-ENC": { + "availableBanks": [], + "rebootOption": 0 + }, + "JXS-D3": { + "availableBanks": [], + "rebootOption": 0 + }, + "JXS-D4-H25": { + "availableBanks": [], + "rebootOption": 0 + }, + "JXS-E3": { + "availableBanks": [], + "rebootOption": 0 + }, + "JXS-E4-H25": { + "availableBanks": [], + "rebootOption": 0 + }, + "JXS-TS-D3-H25": { + "availableBanks": [], + "rebootOption": 0 + }, + "JXS-TS-D4": { + "availableBanks": [], + "rebootOption": 0 + }, + "JXS-TS-E3-H25": { + "availableBanks": [], + "rebootOption": 0 + }, + "JXS-TS-E4": { + "availableBanks": [], + "rebootOption": 0 + }, + "MADI": { + "availableBanks": [], + "rebootOption": 0 + }, + "SDI-IP-2022": { + "availableBanks": [], + "rebootOption": 0 + }, + "SDI-IP-2110": { + "availableBanks": [], + "rebootOption": 0 + }, + "SDI-IP-H25": { + "availableBanks": [], + "rebootOption": 0 + }, + "TICO-UHD-D2": { + "availableBanks": [], + "rebootOption": 0 + }, + "TICO-UHD-D2-IP-25G": { + "availableBanks": [], + "rebootOption": 0 + }, + "TICO-UHD-E1D1": { + "availableBanks": [], + "rebootOption": 0 + }, + "TICO-UHD-E2": { + "availableBanks": [], + "rebootOption": 0 + }, + "TICO-UHD-E2-12G": { + "availableBanks": [], + "rebootOption": 0 + }, + "TICO-UHD-E2-IP-25G": { + "availableBanks": [], + "rebootOption": 0 + }, + "TXS-HD-D3": { + "availableBanks": [], + "rebootOption": 0 + }, + "TXS-HD-E3": { + "availableBanks": [], + "rebootOption": 0 + }, + "UDC-IP-H25": { + "availableBanks": [], + "rebootOption": 0 + }, + "UPLINK-10G": { + "availableBanks": [], + "rebootOption": 0 + }, + "UPLINK-25G": { + "availableBanks": [], + "rebootOption": 0 + }, + "VID-PROC-UHD-12G": { + "availableBanks": [], + "rebootOption": 0 + }, + "Virtuoso-RE": { + "availableBanks": [], + "rebootOption": 0 + }, + "XS-DEC": { + "availableBanks": [], + "rebootOption": 0 + }, + "XS-ENC": { + "availableBanks": [], + "rebootOption": 0 + } + }, + "version": "0.1.0" + }, + { + "_id": "com.nevion.vizrt_vizengine-0.1.0", + "_vid": "com.nevion.vizrt_vizengine-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "vizrt_vizengine" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom setting fields for Viz Engine", + "label": "Viz Engine" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.nevion.vizrt_vizengine.port": { + "_schema": { + "default": 6100, + "descriptor": { + "desc": "Port", + "label": "Port" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 0, + 65535, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "Driver for Viz Engine", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "gateway", + "ipAddress": null, + "label": "Vizrt Viz Engine", + "modules": [], + "name": "vizrt_vizengine", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "CoreLike", + "DeviceLike", + "PortLike" + ], + "supportsExternal": true, + "supportsParameterApi": false, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.zman-0.1.0", + "_vid": "com.nevion.zman-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "zman" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "none", + "ipAddress": null, + "label": "Zman", + "modules": [], + "name": "zman", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "CoreLike", + "DeviceLike", + "PortLike" + ], + "supportsExternal": true, + "supportsParameterApi": false, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.sony.MLS-Manager-1.0", + "_vid": "com.sony.MLS-Manager-1.0", + "attachments": [ + { + "description": "Default", + "name": "MLS-Manager" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "MLS Manager parameter control", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "none", + "ipAddress": null, + "label": "MLS-Manager", + "modules": [], + "name": "MLS-Manager", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.sony", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "mlsm": 31042, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "ParameterControlLike", + "CoreLike", + "DeviceLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "1.0" + }, + { + "_id": "com.sony.MLS-X1-1.0", + "_vid": "com.sony.MLS-X1-1.0", + "attachments": [ + { + "description": "Default", + "name": "MLS-X1" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom setting fields for NS-BUS MLS-X1 driver", + "label": "MLS-X1" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.nevion.nsbus.deviceId": { + "_schema": { + "default": "", + "descriptor": { + "desc": "Device ID for primary management address usually auto-populated by device discovery", + "label": "NS-BUS Device ID" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "com.nevion.nsbus.router.force_tcp": { + "_schema": { + "default": false, + "descriptor": { + "desc": "Don't use TLS on outgoing connection. Note: Depends on support from device, e.g. SC1 may not support this.", + "label": "NS-BUS Router Matrix Protocol: Force TCP" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.nsbus.tallyType": { + "_schema": { + "default": "NOT_USE_TALLY", + "descriptor": { + "desc": "Tally type usually auto-populated by device discovery", + "label": "NS-BUS Tally Type" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [ + { + "descriptor": { + "desc": "", + "label": "No Tally" + }, + "value": "NOT_USE_TALLY" + }, + { + "descriptor": { + "desc": "", + "label": "Tally Master Device" + }, + "value": "TALLY_MASTER_DEVICE" + }, + { + "descriptor": { + "desc": "", + "label": "Tally Display Device" + }, + "value": "TALLY_DISPLAY_DEVICE" + }, + { + "descriptor": { + "desc": "", + "label": "Tally Master and Display Device" + }, + "value": "MASTER_AND_DISPLAY_DEVICE" + } + ], + "status": "Current", + "type": "string" + } + }, + "matrixId": { + "_schema": { + "default": "", + "descriptor": { + "desc": "", + "label": "Custom matrix ID" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "Driver for NS-BUS MLS-X1 Device", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "device", + "ipAddress": null, + "label": "MLS-X1", + "modules": [], + "name": "MLS-X1", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.sony", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "MatrixControlLike", + "CoreLike", + "DeviceLike" + ], + "supportsExternal": false, + "supportsParameterApi": false, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "1.0" + }, + { + "_id": "com.sony.MLS-X1_API-1.0", + "_vid": "com.sony.MLS-X1_API-1.0", + "attachments": [ + { + "description": "Default", + "name": "MLS-X1_API" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "Driver for controlling settings on MLS-X1 units using the gRPC control API", + "deviceType": "panel", + "discoveredBy": null, + "exists": "No", + "iconType": "none", + "ipAddress": null, + "label": "MLS-X1 Control API", + "modules": [], + "name": "MLS-X1_API", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.sony", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "molisx": 9181, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "ParameterControlLike", + "CoreLike", + "DeviceLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "1.0" + }, + { + "_id": "com.sony.Panel-1.0", + "_vid": "com.sony.Panel-1.0", + "attachments": [ + { + "description": "Default", + "name": "Panel" + } + ], + "configurableDevice": true, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom setting fields for NS-BUS Panel drivers", + "label": "NS-BUS Panel" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.nevion.nsbus.config.force_tcp": { + "_schema": { + "default": false, + "descriptor": { + "desc": "Don't use TLS, useful for debugging.", + "label": "NS-BUS Configuration Protocol: Force TCP" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.nsbus.deviceId": { + "_schema": { + "default": "", + "descriptor": { + "desc": "Device ID for primary management address usually auto-populated by device discovery", + "label": "NS-BUS Device ID" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "com.nevion.nsbus.tallyType": { + "_schema": { + "default": "NOT_USE_TALLY", + "descriptor": { + "desc": "Tally type usually auto-populated by device discovery", + "label": "NS-BUS Tally Type" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [ + { + "descriptor": { + "desc": "", + "label": "No Tally" + }, + "value": "NOT_USE_TALLY" + }, + { + "descriptor": { + "desc": "", + "label": "Tally Master Device" + }, + "value": "TALLY_MASTER_DEVICE" + }, + { + "descriptor": { + "desc": "", + "label": "Tally Display Device" + }, + "value": "TALLY_DISPLAY_DEVICE" + }, + { + "descriptor": { + "desc": "", + "label": "Tally Master and Display Device" + }, + "value": "MASTER_AND_DISPLAY_DEVICE" + } + ], + "status": "Current", + "type": "string" + } + }, + "matrixId": { + "_schema": { + "default": "", + "descriptor": { + "desc": "", + "label": "Custom matrix ID" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "Driver for NS-BUS Panel Driver", + "deviceType": "panel", + "discoveredBy": null, + "exists": "No", + "iconType": "device", + "ipAddress": null, + "label": "NS-BUS Panel", + "modules": [], + "name": "Panel", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.sony", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "MaintenanceLike", + "CoreLike", + "DeviceLike", + "DynamicLike" + ], + "supportsExternal": false, + "supportsParameterApi": false, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "1.0" + }, + { + "_id": "com.sony.SC1-1.0", + "_vid": "com.sony.SC1-1.0", + "attachments": [ + { + "description": "Default", + "name": "SC1" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom setting fields for NS-BUS PWS-110SC1 drivers", + "label": "SC1" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.nevion.nsbus.deviceId": { + "_schema": { + "default": "", + "descriptor": { + "desc": "Device ID for primary management address usually auto-populated by device discovery", + "label": "NS-BUS Device ID" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "com.nevion.nsbus.router.force_tcp": { + "_schema": { + "default": false, + "descriptor": { + "desc": "Don't use TLS on outgoing connection. Note: Depends on support from device, e.g. SC1 may not support this.", + "label": "NS-BUS Router Matrix Protocol: Force TCP" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.nsbus.tallyType": { + "_schema": { + "default": "NOT_USE_TALLY", + "descriptor": { + "desc": "Tally type usually auto-populated by device discovery", + "label": "NS-BUS Tally Type" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [ + { + "descriptor": { + "desc": "", + "label": "No Tally" + }, + "value": "NOT_USE_TALLY" + }, + { + "descriptor": { + "desc": "", + "label": "Tally Master Device" + }, + "value": "TALLY_MASTER_DEVICE" + }, + { + "descriptor": { + "desc": "", + "label": "Tally Display Device" + }, + "value": "TALLY_DISPLAY_DEVICE" + }, + { + "descriptor": { + "desc": "", + "label": "Tally Master and Display Device" + }, + "value": "MASTER_AND_DISPLAY_DEVICE" + } + ], + "status": "Current", + "type": "string" + } + }, + "matrixId": { + "_schema": { + "default": "", + "descriptor": { + "desc": "", + "label": "Custom matrix ID" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "Driver for NS-BUS PWS-110SC1 Driver", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "device", + "ipAddress": null, + "label": "Sony SC1", + "modules": [], + "name": "SC1", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.sony", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "MatrixControlLike", + "CoreLike", + "DeviceLike" + ], + "supportsExternal": false, + "supportsParameterApi": false, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "1.0" + }, + { + "_id": "com.sony.XVS-G1-1.0", + "_vid": "com.sony.XVS-G1-1.0", + "attachments": [ + { + "description": "Default", + "name": "XVS-G1" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom setting fields for NS-BUS XVS-G1 driver", + "label": "XVS-G1" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.nevion.nsbus.deviceId": { + "_schema": { + "default": "", + "descriptor": { + "desc": "Device ID for primary management address usually auto-populated by device discovery", + "label": "NS-BUS Device ID" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "com.nevion.nsbus.router.force_tcp": { + "_schema": { + "default": false, + "descriptor": { + "desc": "Don't use TLS on outgoing connection. Note: Depends on support from device, e.g. SC1 may not support this.", + "label": "NS-BUS Router Matrix Protocol: Force TCP" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.nsbus.tallyType": { + "_schema": { + "default": "NOT_USE_TALLY", + "descriptor": { + "desc": "Tally type usually auto-populated by device discovery", + "label": "NS-BUS Tally Type" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [ + { + "descriptor": { + "desc": "", + "label": "No Tally" + }, + "value": "NOT_USE_TALLY" + }, + { + "descriptor": { + "desc": "", + "label": "Tally Master Device" + }, + "value": "TALLY_MASTER_DEVICE" + }, + { + "descriptor": { + "desc": "", + "label": "Tally Display Device" + }, + "value": "TALLY_DISPLAY_DEVICE" + }, + { + "descriptor": { + "desc": "", + "label": "Tally Master and Display Device" + }, + "value": "MASTER_AND_DISPLAY_DEVICE" + } + ], + "status": "Current", + "type": "string" + } + }, + "matrixId": { + "_schema": { + "default": "", + "descriptor": { + "desc": "", + "label": "Custom matrix ID" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "Driver for NS-BUS XVS-G1 Device", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "device", + "ipAddress": null, + "label": "XVS-G1", + "modules": [], + "name": "XVS-G1", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.sony", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "MatrixControlLike", + "CoreLike", + "DeviceLike" + ], + "supportsExternal": true, + "supportsParameterApi": false, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "1.0" + }, + { + "_id": "com.sony.cna2-0.1.0", + "_vid": "com.sony.cna2-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "cna2" + } + ], + "configurableDevice": true, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom setting fields for CNA-2 driver", + "label": "CNA-2" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.sony.cna2.domain_number": { + "_schema": { + "default": 0, + "descriptor": { + "desc": "", + "label": "Domain Number" + }, + "isNullable": false, + "options": [], + "ranges": [], + "status": "Current", + "type": "number", + "units": "" + } + }, + "com.sony.cna2.matrix_type": { + "_schema": { + "default": "1:1", + "descriptor": { + "desc": "", + "label": "MatrixType" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "com.sony.cna2.total_cameras": { + "_schema": { + "default": 96, + "descriptor": { + "desc": "", + "label": "Total Number of System Cameras" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 1, + 96, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + }, + "com.sony.cna2.webhook_url": { + "_schema": { + "default": "", + "descriptor": { + "desc": "Typically http://[VIP address]/api", + "label": "Webhook URL" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "Driver for CNA-2 device", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "none", + "ipAddress": null, + "label": "CNA-2", + "modules": [], + "name": "cna2", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.sony", + "products": [ + { + "name": "CNA-2", + "swBuildTime": null, + "swVersion": null + } + ], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "DeviceLike", + "CoreLike", + "MatrixControlLike", + "ParameterControlLike", + "MaintenanceLike", + "WebhooksLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.sony.generic_ccm-0.1.0", + "_vid": "com.sony.generic_ccm-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "generic_ccm" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "none", + "ipAddress": null, + "label": "Generic CCM", + "modules": [], + "name": "generic_ccm", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.sony", + "products": [ + { + "name": "CBK-RPU7", + "swBuildTime": null, + "swVersion": null + }, + { + "name": "NXL-ME80", + "swBuildTime": null, + "swVersion": null + } + ], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "CoreLike", + "DeviceLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": false, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.sony.generic_external_control-1.0", + "_vid": "com.sony.generic_external_control-1.0", + "attachments": [ + { + "description": "Default", + "name": "generic_external_control" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom setting fields for NS-BUS Generic External Control drivers", + "label": "NS-BUS Generic" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.nevion.nsbus.deviceId": { + "_schema": { + "default": "", + "descriptor": { + "desc": "Device ID for primary management address usually auto-populated by device discovery", + "label": "NS-BUS Device ID" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "com.nevion.nsbus.tallyType": { + "_schema": { + "default": "NOT_USE_TALLY", + "descriptor": { + "desc": "Tally type usually auto-populated by device discovery", + "label": "NS-BUS Tally Type" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [ + { + "descriptor": { + "desc": "", + "label": "No Tally" + }, + "value": "NOT_USE_TALLY" + }, + { + "descriptor": { + "desc": "", + "label": "Tally Master Device" + }, + "value": "TALLY_MASTER_DEVICE" + }, + { + "descriptor": { + "desc": "", + "label": "Tally Display Device" + }, + "value": "TALLY_DISPLAY_DEVICE" + }, + { + "descriptor": { + "desc": "", + "label": "Tally Master and Display Device" + }, + "value": "MASTER_AND_DISPLAY_DEVICE" + } + ], + "status": "Current", + "type": "string" + } + }, + "matrixId": { + "_schema": { + "default": "", + "descriptor": { + "desc": "", + "label": "Custom matrix ID" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "Driver for NS-BUS Generic External Control Driver", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "device", + "ipAddress": null, + "label": "NS-BUS External Control", + "modules": [], + "name": "generic_external_control", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.sony", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "CoreLike" + ], + "supportsExternal": false, + "supportsParameterApi": false, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "1.0" + }, + { + "_id": "com.sony.mks-1.0.0", + "_vid": "com.sony.mks-1.0.0", + "attachments": [ + { + "description": "Default", + "name": "mks" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom setting fields for mks", + "label": "mks" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.sony.mks.uuid": { + "_schema": { + "default": "", + "descriptor": { + "desc": "", + "label": "MKS Panel UUID" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "A Driver for Sony MKS panels (Leo mode)", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "none", + "ipAddress": null, + "label": "Sony MKS Panel", + "modules": [], + "name": "mks", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.sony", + "products": [ + { + "name": "MKS", + "swBuildTime": null, + "swVersion": null + } + ], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "CoreLike", + "DeviceLike", + "HwPanelLike" + ], + "supportsExternal": true, + "supportsParameterApi": false, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "1.0.0" + }, + { + "_id": "com.sony.nsbus_generic_router-1.0", + "_vid": "com.sony.nsbus_generic_router-1.0", + "attachments": [ + { + "description": "Default", + "name": "nsbus_generic_router" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom setting fields for Generic NS-BUS Router drivers", + "label": "Generic NS-BUS Router" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.nevion.nsbus.deviceId": { + "_schema": { + "default": "", + "descriptor": { + "desc": "Device ID for primary management address usually auto-populated by device discovery", + "label": "NS-BUS Device ID" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "com.nevion.nsbus.router.force_tcp": { + "_schema": { + "default": false, + "descriptor": { + "desc": "Don't use TLS on outgoing connection. Note: Depends on support from device, e.g. SC1 may not support this.", + "label": "NS-BUS Router Matrix Protocol: Force TCP" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.nsbus.tallyType": { + "_schema": { + "default": "NOT_USE_TALLY", + "descriptor": { + "desc": "Tally type usually auto-populated by device discovery", + "label": "NS-BUS Tally Type" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [ + { + "descriptor": { + "desc": "", + "label": "No Tally" + }, + "value": "NOT_USE_TALLY" + }, + { + "descriptor": { + "desc": "", + "label": "Tally Master Device" + }, + "value": "TALLY_MASTER_DEVICE" + }, + { + "descriptor": { + "desc": "", + "label": "Tally Display Device" + }, + "value": "TALLY_DISPLAY_DEVICE" + }, + { + "descriptor": { + "desc": "", + "label": "Tally Master and Display Device" + }, + "value": "MASTER_AND_DISPLAY_DEVICE" + } + ], + "status": "Current", + "type": "string" + } + }, + "matrixId": { + "_schema": { + "default": "", + "descriptor": { + "desc": "", + "label": "Custom matrix ID" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "Driver for NS-BUS Generic Router Driver", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "device", + "ipAddress": null, + "label": "NS-BUS Router", + "modules": [], + "name": "nsbus_generic_router", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.sony", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "MatrixControlLike", + "CoreLike", + "DeviceLike" + ], + "supportsExternal": false, + "supportsParameterApi": false, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "1.0" + }, + { + "_id": "com.sony.rcp3500-0.1.0", + "_vid": "com.sony.rcp3500-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "rcp3500" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom setting fields for rcp3500", + "label": "rcp3500" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.nevion.emberplus.keepalives": { + "_schema": { + "default": true, + "descriptor": { + "desc": "If selected, keep-alives will be used to determine reachability", + "label": "Send keep-alives" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.emberplus.port": { + "_schema": { + "default": 9000, + "descriptor": { + "desc": "Port", + "label": "Port" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 0, + 65535, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + }, + "com.nevion.emberplus.queue": { + "_schema": { + "default": true, + "descriptor": { + "desc": "", + "label": "Request queueing" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.emberplus.suppress_illegal": { + "_schema": { + "default": false, + "descriptor": { + "desc": "", + "label": "Suppress illegal update warnings" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.emberplus.trace": { + "_schema": { + "default": false, + "descriptor": { + "desc": "", + "label": "Tracing (logging intensive)" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "none", + "ipAddress": null, + "label": "RCP 3500", + "modules": [], + "name": "rcp3500", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.sony", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "ParameterControlLike", + "GPIOLike", + "CoreLike", + "DeviceLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.sony.sony_sdcp_group_all-0.1.0", + "_vid": "com.sony.sony_sdcp_group_all-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "sony_sdcp_group_all" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom setting fields for sony_sdcp_group_all", + "label": "sony_sdcp_group_all" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.nevion.sdcp.community": { + "_schema": { + "default": "SONY", + "descriptor": { + "desc": "Enter custom community string default ('SONY')", + "label": "Community" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "com.nevion.sdcp.groupId": { + "_schema": { + "default": 0, + "descriptor": { + "desc": "GroupId for monitor", + "label": "GroupId" + }, + "isNullable": false, + "options": [], + "ranges": [], + "status": "Current", + "type": "number", + "units": "" + } + }, + "com.nevion.sdcp.port": { + "_schema": { + "default": 53484, + "descriptor": { + "desc": "Port", + "label": "Port" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 0, + 65535, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "Driver for Sony Monitor Control Unit", + "deviceType": "panel", + "discoveredBy": null, + "exists": "No", + "iconType": "monitor", + "ipAddress": null, + "label": "Sony Monitor Control Unit Driver", + "modules": [], + "name": "sony_sdcp_group_all", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.sony", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "ParameterControlLike", + "CoreLike", + "DeviceLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.sony.sony_sdcp_single-0.1.0", + "_vid": "com.sony.sony_sdcp_single-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "sony_sdcp_single" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom setting fields for sony_sdcp_single", + "label": "sony_sdcp_single" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.nevion.sdcp.community": { + "_schema": { + "default": "SONY", + "descriptor": { + "desc": "Enter custom community string default ('SONY')", + "label": "Community" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "com.nevion.sdcp.port": { + "_schema": { + "default": 53484, + "descriptor": { + "desc": "Port", + "label": "Port" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 0, + 65535, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + }, + "com.nevion.sdcp.unitId": { + "_schema": { + "default": 1, + "descriptor": { + "desc": "UnitId for monitor", + "label": "UnitId" + }, + "isNullable": false, + "options": [], + "ranges": [], + "status": "Current", + "type": "number", + "units": "" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "Driver for Sony Monitor Control Unit", + "deviceType": "panel", + "discoveredBy": null, + "exists": "No", + "iconType": "monitor", + "ipAddress": null, + "label": "Sony Monitor Control Unit Driver", + "modules": [], + "name": "sony_sdcp_single", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.sony", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "ParameterControlLike", + "CoreLike", + "DeviceLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + } + ] + } + } + } + } +} \ No newline at end of file diff --git a/src/videoipath_automation_tool/apps/inventory/model/drivers.py b/src/videoipath_automation_tool/apps/inventory/model/drivers.py index f99475d..303feb2 100644 --- a/src/videoipath_automation_tool/apps/inventory/model/drivers.py +++ b/src/videoipath_automation_tool/apps/inventory/model/drivers.py @@ -28,6 +28,7 @@ "2025.2.0", "2025.3.2", "2025.4.3", + "2026.2.0", ] diff --git a/src/videoipath_automation_tool/apps/topology/errors.py b/src/videoipath_automation_tool/apps/topology/errors.py new file mode 100644 index 0000000..286f0a9 --- /dev/null +++ b/src/videoipath_automation_tool/apps/topology/errors.py @@ -0,0 +1,7 @@ +"""Typed exceptions for the Topology app.""" + +from __future__ import annotations + + +class TopologyUnsupportedError(Exception): + """Raised when TopologyApp is used against an unsupported VideoIPath version.""" diff --git a/src/videoipath_automation_tool/apps/topology/model/n_graph_elements/n_graph_schema/2026.2.0_nGraphSchemaFlat.json b/src/videoipath_automation_tool/apps/topology/model/n_graph_elements/n_graph_schema/2026.2.0_nGraphSchemaFlat.json new file mode 100644 index 0000000..4662e07 --- /dev/null +++ b/src/videoipath_automation_tool/apps/topology/model/n_graph_elements/n_graph_schema/2026.2.0_nGraphSchemaFlat.json @@ -0,0 +1,4011 @@ +{ + "data": { + "status": { + "network": { + "nGraphSchemaFlat": { + "_items": [ + { + "_id": "baseDevice", + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "BaseDevice" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "descriptor.desc": { + "_schema": { + "default": "", + "descriptor": { + "desc": "A more elaborate description of the entity", + "label": "Description" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "descriptor.label": { + "_schema": { + "default": "", + "descriptor": { + "desc": "A label for the described entity", + "label": "Label" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "fDescriptor.desc": { + "_schema": { + "default": "", + "descriptor": { + "desc": "A more elaborate description of the entity", + "label": "Description" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "fDescriptor.label": { + "_schema": { + "default": "", + "descriptor": { + "desc": "A label for the described entity", + "label": "Label" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "iconSize": { + "_schema": { + "default": "", + "descriptor": { + "desc": "", + "label": "Device icon size" + }, + "encoding": "UTF-8", + "gui": { + "tags": [], + "widget": "Dropdown" + }, + "isNullable": false, + "lengthRanges": [], + "options": [ + { + "descriptor": { + "desc": "", + "label": "Auto" + }, + "value": "auto" + }, + { + "descriptor": { + "desc": "", + "label": "Large" + }, + "value": "large" + }, + { + "descriptor": { + "desc": "", + "label": "Medium" + }, + "value": "medium" + }, + { + "descriptor": { + "desc": "", + "label": "Small" + }, + "value": "small" + } + ], + "status": "Current", + "type": "string" + } + }, + "iconType": { + "_schema": { + "default": "", + "descriptor": { + "desc": "", + "label": "Driver icon type" + }, + "encoding": "UTF-8", + "gui": { + "tags": [], + "widget": "Dropdown" + }, + "isNullable": false, + "lengthRanges": [], + "options": [ + { + "descriptor": { + "desc": "", + "label": "Audio Mixer" + }, + "value": "audioMixer" + }, + { + "descriptor": { + "desc": "", + "label": "Camera" + }, + "value": "camera" + }, + { + "descriptor": { + "desc": "", + "label": "Decoder" + }, + "value": "decoder" + }, + { + "descriptor": { + "desc": "", + "label": "Default Driver-Specified Icon" + }, + "value": "default" + }, + { + "descriptor": { + "desc": "", + "label": "Device" + }, + "value": "device" + }, + { + "descriptor": { + "desc": "", + "label": "Encoder" + }, + "value": "encoder" + }, + { + "descriptor": { + "desc": "", + "label": "Encoder/Decoder" + }, + "value": "encoderDecoder" + }, + { + "descriptor": { + "desc": "", + "label": "Gateway" + }, + "value": "gateway" + }, + { + "descriptor": { + "desc": "", + "label": "IP Switch/Router" + }, + "value": "ipSwitchRouter" + }, + { + "descriptor": { + "desc": "", + "label": "Media device" + }, + "value": "mediaDevice" + }, + { + "descriptor": { + "desc": "", + "label": "Monitor" + }, + "value": "monitor" + }, + { + "descriptor": { + "desc": "", + "label": "Unspecified" + }, + "value": "none" + }, + { + "descriptor": { + "desc": "", + "label": "Processing Device" + }, + "value": "processingDevice" + }, + { + "descriptor": { + "desc": "", + "label": "Server" + }, + "value": "server" + }, + { + "descriptor": { + "desc": "", + "label": "Transport Stream Processor" + }, + "value": "transportStreamProcessor" + }, + { + "descriptor": { + "desc": "", + "label": "VLAN Cloud virtual device" + }, + "value": "vlanCloud" + }, + { + "descriptor": { + "desc": "", + "label": "Video/Audio Router (Matrix)" + }, + "value": "videoAudioRouterMatrix" + }, + { + "descriptor": { + "desc": "", + "label": "Video Mixer" + }, + "value": "videoMixer" + } + ], + "status": "Current", + "type": "string" + } + }, + "isVirtual": { + "_schema": { + "default": true, + "descriptor": { + "desc": "True, if device does not correspond to physical device/driver.", + "label": "Virtual" + }, + "gui": { + "tags": [], + "widget": "CheckBox" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "maps": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "unknown" + } + }, + "sdpStrategy": { + "_schema": { + "default": "", + "descriptor": { + "desc": "", + "label": "Sdp Polling Strategy" + }, + "encoding": "UTF-8", + "gui": { + "tags": [], + "widget": "Dropdown" + }, + "isNullable": false, + "lengthRanges": [], + "options": [ + { + "descriptor": { + "desc": "", + "label": "Continuous" + }, + "value": "always" + }, + { + "descriptor": { + "desc": "", + "label": "Fetch and Confirm" + }, + "value": "once" + }, + { + "descriptor": { + "desc": "", + "label": "Continuous Video, Confirm Others" + }, + "value": "video" + } + ], + "status": "Current", + "type": "string" + } + }, + "siteId": { + "_schema": { + "default": "", + "descriptor": { + "desc": "The ID of the site this device is located at.", + "label": "Site ID" + }, + "encoding": "UTF-8", + "isNullable": true, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "tags": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "User defined tags", + "label": "Tags" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "unknown" + } + } + } + }, + "_vid": "baseDevice" + }, + { + "_id": "codecVertex", + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "CodecVertex" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "active": { + "_schema": { + "default": true, + "descriptor": { + "desc": "True, if the vertex can be used in service routing.", + "label": "Active" + }, + "gui": { + "tags": [], + "widget": "CheckBox" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "bidirPartnerId": { + "_schema": { + "default": "", + "descriptor": { + "desc": "", + "label": "Bidir partner id" + }, + "encoding": "UTF-8", + "isNullable": true, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "codecFormat": { + "_schema": { + "default": "", + "descriptor": { + "desc": "", + "label": "Codec format type" + }, + "encoding": "UTF-8", + "gui": { + "tags": [], + "widget": "Dropdown" + }, + "isNullable": false, + "lengthRanges": [], + "options": [ + { + "descriptor": { + "desc": "", + "label": "ASI" + }, + "value": "ASI" + }, + { + "descriptor": { + "desc": "", + "label": "Ancillary" + }, + "value": "Ancillary" + }, + { + "descriptor": { + "desc": "", + "label": "Audio" + }, + "value": "Audio" + }, + { + "descriptor": { + "desc": "", + "label": "Video" + }, + "value": "Video" + } + ], + "status": "Current", + "type": "string" + } + }, + "configPriority": { + "_schema": { + "default": "", + "descriptor": { + "desc": "Priority level of the endpoint during configuration", + "label": "Config priority level" + }, + "encoding": "UTF-8", + "gui": { + "tags": [], + "widget": "Dropdown" + }, + "isNullable": false, + "lengthRanges": [], + "options": [ + { + "descriptor": { + "desc": "", + "label": "High" + }, + "value": "high" + }, + { + "descriptor": { + "desc": "", + "label": "Low" + }, + "value": "low" + }, + { + "descriptor": { + "desc": "", + "label": "Off" + }, + "value": "off" + } + ], + "status": "Current", + "type": "string" + } + }, + "control": { + "_schema": { + "default": "", + "descriptor": { + "desc": "Determines how the vertex is configured, e.g. if it should be auto-reapplied (full).", + "label": "Control level" + }, + "encoding": "UTF-8", + "gui": { + "tags": [], + "widget": "Dropdown" + }, + "isNullable": false, + "lengthRanges": [], + "options": [ + { + "descriptor": { + "desc": "", + "label": "Full" + }, + "value": "full" + }, + { + "descriptor": { + "desc": "", + "label": "Off" + }, + "value": "off" + }, + { + "descriptor": { + "desc": "", + "label": "Initial" + }, + "value": "semi" + } + ], + "status": "Current", + "type": "string" + } + }, + "custom": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "A map structure containing String-DtValue pairs", + "label": "DtMap" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "unknown" + } + }, + "descriptor.desc": { + "_schema": { + "default": "", + "descriptor": { + "desc": "A more elaborate description of the entity", + "label": "Description" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "descriptor.label": { + "_schema": { + "default": "", + "descriptor": { + "desc": "A label for the described entity", + "label": "Label" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "deviceId": { + "_schema": { + "default": "", + "descriptor": { + "desc": "", + "label": "Device Id" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "exclusive": { + "_schema": { + "default": true, + "descriptor": { + "desc": "A connected source cannot have other receivers (even for multicast).", + "label": "Exclusive" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "extraAlertFilters": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "Extra Alert Filters" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "unknown" + } + }, + "extraFormats": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Formats added when used as endpoint", + "label": "Extra Formats" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "unknown" + } + }, + "fDescriptor.desc": { + "_schema": { + "default": "", + "descriptor": { + "desc": "A more elaborate description of the entity", + "label": "Description" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "fDescriptor.label": { + "_schema": { + "default": "", + "descriptor": { + "desc": "A label for the described entity", + "label": "Label" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "gpid.component": { + "_schema": { + "default": 0, + "descriptor": { + "desc": "", + "label": "Source component" + }, + "gui": { + "tags": [], + "widget": "Dropdown" + }, + "isNullable": false, + "options": [ + { + "descriptor": { + "desc": "", + "label": "Alert Manager" + }, + "value": 5 + }, + { + "descriptor": { + "desc": "", + "label": "Booking Manager" + }, + "value": 8 + }, + { + "descriptor": { + "desc": "", + "label": "Config" + }, + "value": 101 + }, + { + "descriptor": { + "desc": "", + "label": "Configuration Manager" + }, + "value": 7 + }, + { + "descriptor": { + "desc": "", + "label": "Device" + }, + "value": 1 + }, + { + "descriptor": { + "desc": "", + "label": "Device Manager" + }, + "value": 6 + }, + { + "descriptor": { + "desc": "", + "label": "Domain" + }, + "value": 13 + }, + { + "descriptor": { + "desc": "", + "label": "External Manager" + }, + "value": 16 + }, + { + "descriptor": { + "desc": "", + "label": "GPIO Manager" + }, + "value": 19 + }, + { + "descriptor": { + "desc": "", + "label": "Generic" + }, + "value": 0 + }, + { + "descriptor": { + "desc": "", + "label": "Licensing" + }, + "value": 10 + }, + { + "descriptor": { + "desc": "", + "label": "Matrix Manager" + }, + "value": 18 + }, + { + "descriptor": { + "desc": "", + "label": "Metrics" + }, + "value": 102 + }, + { + "descriptor": { + "desc": "", + "label": "NDCP Manager" + }, + "value": 17 + }, + { + "descriptor": { + "desc": "", + "label": "NSO Manager" + }, + "value": 22 + }, + { + "descriptor": { + "desc": "", + "label": "Reachability" + }, + "value": 14 + }, + { + "descriptor": { + "desc": "", + "label": "Redundancy Controller" + }, + "value": 12 + }, + { + "descriptor": { + "desc": "", + "label": "Resource" + }, + "value": 99 + }, + { + "descriptor": { + "desc": "", + "label": "REST" + }, + "value": 11 + }, + { + "descriptor": { + "desc": "", + "label": "Service" + }, + "value": 100 + }, + { + "descriptor": { + "desc": "", + "label": "System" + }, + "value": 2 + }, + { + "descriptor": { + "desc": "", + "label": "System Topology" + }, + "value": 9 + }, + { + "descriptor": { + "desc": "", + "label": "Tally Manager" + }, + "value": 20 + }, + { + "descriptor": { + "desc": "", + "label": "Topology Manager" + }, + "value": 15 + }, + { + "descriptor": { + "desc": "", + "label": "Unknown" + }, + "value": 999 + }, + { + "descriptor": { + "desc": "", + "label": "User" + }, + "value": 4 + }, + { + "descriptor": { + "desc": "", + "label": "Virtual Router Manager" + }, + "value": 21 + } + ], + "ranges": [], + "status": "Current", + "type": "number", + "units": "" + } + }, + "gpid.pointId": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "A hierarchical ID with dot separated levels", + "label": "Point ID" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "unknown" + } + }, + "imgUrl": { + "_schema": { + "default": "", + "descriptor": { + "desc": "", + "label": "Image URL" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "isIgmpSource": { + "_schema": { + "default": true, + "descriptor": { + "desc": "Vertex can function as last hop in IGMP config.", + "label": "Igmp Source" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "isVirtual": { + "_schema": { + "default": true, + "descriptor": { + "desc": "True, if vertex does not correspond to physical resource/driver.", + "label": "Virtual" + }, + "gui": { + "tags": [], + "widget": "CheckBox" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "mainDstIp": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "Main Destination Ip Address" + }, + "isNullable": true, + "options": [], + "status": "Current", + "type": "unknown" + } + }, + "mainDstMac": { + "_schema": { + "default": "", + "descriptor": { + "desc": "", + "label": "Main Destination Mac Address" + }, + "encoding": "UTF-8", + "isNullable": true, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "mainDstPort": { + "_schema": { + "default": 0, + "descriptor": { + "desc": "", + "label": "Main Destination TCP/UDP Port" + }, + "isNullable": true, + "options": [], + "ranges": [ + [ + 1, + 65535, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + }, + "mainDstVlan": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "Main Destination Vlan" + }, + "isNullable": true, + "options": [], + "status": "Current", + "type": "unknown" + } + }, + "mainSrcGateway": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "Main Source Ip Gateway" + }, + "isNullable": true, + "options": [], + "status": "Current", + "type": "unknown" + } + }, + "mainSrcIp": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "Main Source Ip Address" + }, + "isNullable": true, + "options": [], + "status": "Current", + "type": "unknown" + } + }, + "mainSrcMac": { + "_schema": { + "default": "", + "descriptor": { + "desc": "", + "label": "Main Source Mac Address" + }, + "encoding": "UTF-8", + "isNullable": true, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "mainSrcNetmask": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "Main Source Ip Netmask" + }, + "isNullable": true, + "options": [], + "status": "Current", + "type": "unknown" + } + }, + "maps": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Collection of object coordinates for various maps/contexts.", + "label": "Maps" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "unknown" + } + }, + "multiplicity": { + "_schema": { + "default": 1, + "descriptor": { + "desc": "How many physical ip streams it can produce", + "label": "Multiplicity" + }, + "isNullable": false, + "options": [], + "ranges": [], + "status": "Current", + "type": "number", + "units": "" + } + }, + "partnerConfig": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "Vertex Partner Config" + }, + "isNullable": true, + "options": [], + "status": "Current", + "type": "unknown" + } + }, + "public": { + "_schema": { + "default": true, + "descriptor": { + "desc": "Share via distributed systems", + "label": "Public" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "sdpSupport": { + "_schema": { + "default": true, + "descriptor": { + "desc": "The vertex publishes an SDP", + "label": "SDP support" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "serviceId": { + "_schema": { + "default": 0, + "descriptor": { + "desc": "", + "label": "Service Id" + }, + "isNullable": true, + "options": [], + "ranges": [], + "status": "Current", + "type": "number", + "units": "" + } + }, + "sipsMode": { + "_schema": { + "default": "", + "descriptor": { + "desc": "", + "label": "SIPS mode type" + }, + "encoding": "UTF-8", + "gui": { + "tags": [], + "widget": "Dropdown" + }, + "isNullable": false, + "lengthRanges": [], + "options": [ + { + "descriptor": { + "desc": "", + "label": "NONE" + }, + "value": "NONE" + }, + { + "descriptor": { + "desc": "", + "label": "SIPS Auto" + }, + "value": "SIPSAuto" + }, + { + "descriptor": { + "desc": "", + "label": "SIPS Duplicate" + }, + "value": "SIPSDuplicate" + }, + { + "descriptor": { + "desc": "", + "label": "SIPS Merge" + }, + "value": "SIPSMerge" + }, + { + "descriptor": { + "desc": "", + "label": "SIPS Split" + }, + "value": "SIPSSplit" + } + ], + "status": "Current", + "type": "string" + } + }, + "spareDstIp": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "Spare Destination Ip Address" + }, + "isNullable": true, + "options": [], + "status": "Current", + "type": "unknown" + } + }, + "spareDstMac": { + "_schema": { + "default": "", + "descriptor": { + "desc": "", + "label": "Spare Destination Mac Address" + }, + "encoding": "UTF-8", + "isNullable": true, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "spareDstPort": { + "_schema": { + "default": 0, + "descriptor": { + "desc": "", + "label": "Spare Destination TCP/UDP Port" + }, + "isNullable": true, + "options": [], + "ranges": [ + [ + 1, + 65535, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + }, + "spareDstVlan": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "Spare Destination Vlan" + }, + "isNullable": true, + "options": [], + "status": "Current", + "type": "unknown" + } + }, + "spareSrcGateway": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "Spare Source Ip Gateway" + }, + "isNullable": true, + "options": [], + "status": "Current", + "type": "unknown" + } + }, + "spareSrcIp": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "Spare Source Ip Address" + }, + "isNullable": true, + "options": [], + "status": "Current", + "type": "unknown" + } + }, + "spareSrcMac": { + "_schema": { + "default": "", + "descriptor": { + "desc": "", + "label": "Spare Source Mac Address" + }, + "encoding": "UTF-8", + "isNullable": true, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "spareSrcNetmask": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "Spare Source Ip Netmask" + }, + "isNullable": true, + "options": [], + "status": "Current", + "type": "unknown" + } + }, + "tags": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "User defined tags", + "label": "Tags" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "unknown" + } + }, + "useAsEndpoint": { + "_schema": { + "default": true, + "descriptor": { + "desc": "", + "label": "Use as Endpoint" + }, + "gui": { + "tags": [], + "widget": "CheckBox" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "vertexType": { + "_schema": { + "default": "", + "descriptor": { + "desc": "", + "label": "Vertex type" + }, + "encoding": "UTF-8", + "gui": { + "tags": [], + "widget": "Dropdown" + }, + "isNullable": false, + "lengthRanges": [], + "options": [ + { + "descriptor": { + "desc": "", + "label": "BiDirectional" + }, + "value": "BiDirectional" + }, + { + "descriptor": { + "desc": "", + "label": "Input" + }, + "value": "In" + }, + { + "descriptor": { + "desc": "", + "label": "Internal" + }, + "value": "Internal" + }, + { + "descriptor": { + "desc": "", + "label": "Output" + }, + "value": "Out" + }, + { + "descriptor": { + "desc": "", + "label": "Undecided" + }, + "value": "Undecided" + } + ], + "status": "Current", + "type": "string" + } + } + } + }, + "_vid": "codecVertex" + }, + { + "_id": "genericVertex", + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "GenericVertex" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "active": { + "_schema": { + "default": true, + "descriptor": { + "desc": "True, if the vertex can be used in service routing.", + "label": "Active" + }, + "gui": { + "tags": [], + "widget": "CheckBox" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "configPriority": { + "_schema": { + "default": "", + "descriptor": { + "desc": "Priority level of the endpoint during configuration", + "label": "Config priority level" + }, + "encoding": "UTF-8", + "gui": { + "tags": [], + "widget": "Dropdown" + }, + "isNullable": false, + "lengthRanges": [], + "options": [ + { + "descriptor": { + "desc": "", + "label": "High" + }, + "value": "high" + }, + { + "descriptor": { + "desc": "", + "label": "Low" + }, + "value": "low" + }, + { + "descriptor": { + "desc": "", + "label": "Off" + }, + "value": "off" + } + ], + "status": "Current", + "type": "string" + } + }, + "control": { + "_schema": { + "default": "", + "descriptor": { + "desc": "Determines how the vertex is configured, e.g. if it should be auto-reapplied (full).", + "label": "Control level" + }, + "encoding": "UTF-8", + "gui": { + "tags": [], + "widget": "Dropdown" + }, + "isNullable": false, + "lengthRanges": [], + "options": [ + { + "descriptor": { + "desc": "", + "label": "Full" + }, + "value": "full" + }, + { + "descriptor": { + "desc": "", + "label": "Off" + }, + "value": "off" + }, + { + "descriptor": { + "desc": "", + "label": "Initial" + }, + "value": "semi" + } + ], + "status": "Current", + "type": "string" + } + }, + "custom": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "A map structure containing String-DtValue pairs", + "label": "DtMap" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "unknown" + } + }, + "descriptor.desc": { + "_schema": { + "default": "", + "descriptor": { + "desc": "A more elaborate description of the entity", + "label": "Description" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "descriptor.label": { + "_schema": { + "default": "", + "descriptor": { + "desc": "A label for the described entity", + "label": "Label" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "deviceId": { + "_schema": { + "default": "", + "descriptor": { + "desc": "", + "label": "Device Id" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "extraAlertFilters": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "Extra Alert Filters" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "unknown" + } + }, + "fDescriptor.desc": { + "_schema": { + "default": "", + "descriptor": { + "desc": "A more elaborate description of the entity", + "label": "Description" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "fDescriptor.label": { + "_schema": { + "default": "", + "descriptor": { + "desc": "A label for the described entity", + "label": "Label" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "gpid.component": { + "_schema": { + "default": 0, + "descriptor": { + "desc": "", + "label": "Source component" + }, + "gui": { + "tags": [], + "widget": "Dropdown" + }, + "isNullable": false, + "options": [ + { + "descriptor": { + "desc": "", + "label": "Alert Manager" + }, + "value": 5 + }, + { + "descriptor": { + "desc": "", + "label": "Booking Manager" + }, + "value": 8 + }, + { + "descriptor": { + "desc": "", + "label": "Config" + }, + "value": 101 + }, + { + "descriptor": { + "desc": "", + "label": "Configuration Manager" + }, + "value": 7 + }, + { + "descriptor": { + "desc": "", + "label": "Device" + }, + "value": 1 + }, + { + "descriptor": { + "desc": "", + "label": "Device Manager" + }, + "value": 6 + }, + { + "descriptor": { + "desc": "", + "label": "Domain" + }, + "value": 13 + }, + { + "descriptor": { + "desc": "", + "label": "External Manager" + }, + "value": 16 + }, + { + "descriptor": { + "desc": "", + "label": "GPIO Manager" + }, + "value": 19 + }, + { + "descriptor": { + "desc": "", + "label": "Generic" + }, + "value": 0 + }, + { + "descriptor": { + "desc": "", + "label": "Licensing" + }, + "value": 10 + }, + { + "descriptor": { + "desc": "", + "label": "Matrix Manager" + }, + "value": 18 + }, + { + "descriptor": { + "desc": "", + "label": "Metrics" + }, + "value": 102 + }, + { + "descriptor": { + "desc": "", + "label": "NDCP Manager" + }, + "value": 17 + }, + { + "descriptor": { + "desc": "", + "label": "NSO Manager" + }, + "value": 22 + }, + { + "descriptor": { + "desc": "", + "label": "Reachability" + }, + "value": 14 + }, + { + "descriptor": { + "desc": "", + "label": "Redundancy Controller" + }, + "value": 12 + }, + { + "descriptor": { + "desc": "", + "label": "Resource" + }, + "value": 99 + }, + { + "descriptor": { + "desc": "", + "label": "REST" + }, + "value": 11 + }, + { + "descriptor": { + "desc": "", + "label": "Service" + }, + "value": 100 + }, + { + "descriptor": { + "desc": "", + "label": "System" + }, + "value": 2 + }, + { + "descriptor": { + "desc": "", + "label": "System Topology" + }, + "value": 9 + }, + { + "descriptor": { + "desc": "", + "label": "Tally Manager" + }, + "value": 20 + }, + { + "descriptor": { + "desc": "", + "label": "Topology Manager" + }, + "value": 15 + }, + { + "descriptor": { + "desc": "", + "label": "Unknown" + }, + "value": 999 + }, + { + "descriptor": { + "desc": "", + "label": "User" + }, + "value": 4 + }, + { + "descriptor": { + "desc": "", + "label": "Virtual Router Manager" + }, + "value": 21 + } + ], + "ranges": [], + "status": "Current", + "type": "number", + "units": "" + } + }, + "gpid.pointId": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "A hierarchical ID with dot separated levels", + "label": "Point ID" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "unknown" + } + }, + "imgUrl": { + "_schema": { + "default": "", + "descriptor": { + "desc": "", + "label": "Image URL" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "isVirtual": { + "_schema": { + "default": true, + "descriptor": { + "desc": "True, if vertex does not correspond to physical resource/driver.", + "label": "Virtual" + }, + "gui": { + "tags": [], + "widget": "CheckBox" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "maps": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Collection of object coordinates for various maps/contexts.", + "label": "Maps" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "unknown" + } + }, + "sipsMode": { + "_schema": { + "default": "", + "descriptor": { + "desc": "", + "label": "SIPS mode type" + }, + "encoding": "UTF-8", + "gui": { + "tags": [], + "widget": "Dropdown" + }, + "isNullable": false, + "lengthRanges": [], + "options": [ + { + "descriptor": { + "desc": "", + "label": "NONE" + }, + "value": "NONE" + }, + { + "descriptor": { + "desc": "", + "label": "SIPS Auto" + }, + "value": "SIPSAuto" + }, + { + "descriptor": { + "desc": "", + "label": "SIPS Duplicate" + }, + "value": "SIPSDuplicate" + }, + { + "descriptor": { + "desc": "", + "label": "SIPS Merge" + }, + "value": "SIPSMerge" + }, + { + "descriptor": { + "desc": "", + "label": "SIPS Split" + }, + "value": "SIPSSplit" + } + ], + "status": "Current", + "type": "string" + } + }, + "tags": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "User defined tags", + "label": "Tags" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "unknown" + } + }, + "useAsEndpoint": { + "_schema": { + "default": true, + "descriptor": { + "desc": "", + "label": "Use as Endpoint" + }, + "gui": { + "tags": [], + "widget": "CheckBox" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "vertexType": { + "_schema": { + "default": "", + "descriptor": { + "desc": "", + "label": "Vertex type" + }, + "encoding": "UTF-8", + "gui": { + "tags": [], + "widget": "Dropdown" + }, + "isNullable": false, + "lengthRanges": [], + "options": [ + { + "descriptor": { + "desc": "", + "label": "BiDirectional" + }, + "value": "BiDirectional" + }, + { + "descriptor": { + "desc": "", + "label": "Input" + }, + "value": "In" + }, + { + "descriptor": { + "desc": "", + "label": "Internal" + }, + "value": "Internal" + }, + { + "descriptor": { + "desc": "", + "label": "Output" + }, + "value": "Out" + }, + { + "descriptor": { + "desc": "", + "label": "Undecided" + }, + "value": "Undecided" + } + ], + "status": "Current", + "type": "string" + } + } + } + }, + "_vid": "genericVertex" + }, + { + "_id": "ipVertex", + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "IpVertex" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "active": { + "_schema": { + "default": true, + "descriptor": { + "desc": "True, if the vertex can be used in service routing.", + "label": "Active" + }, + "gui": { + "tags": [], + "widget": "CheckBox" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "configPriority": { + "_schema": { + "default": "", + "descriptor": { + "desc": "Priority level of the endpoint during configuration", + "label": "Config priority level" + }, + "encoding": "UTF-8", + "gui": { + "tags": [], + "widget": "Dropdown" + }, + "isNullable": false, + "lengthRanges": [], + "options": [ + { + "descriptor": { + "desc": "", + "label": "High" + }, + "value": "high" + }, + { + "descriptor": { + "desc": "", + "label": "Low" + }, + "value": "low" + }, + { + "descriptor": { + "desc": "", + "label": "Off" + }, + "value": "off" + } + ], + "status": "Current", + "type": "string" + } + }, + "control": { + "_schema": { + "default": "", + "descriptor": { + "desc": "Determines how the vertex is configured, e.g. if it should be auto-reapplied (full).", + "label": "Control level" + }, + "encoding": "UTF-8", + "gui": { + "tags": [], + "widget": "Dropdown" + }, + "isNullable": false, + "lengthRanges": [], + "options": [ + { + "descriptor": { + "desc": "", + "label": "Full" + }, + "value": "full" + }, + { + "descriptor": { + "desc": "", + "label": "Off" + }, + "value": "off" + }, + { + "descriptor": { + "desc": "", + "label": "Initial" + }, + "value": "semi" + } + ], + "status": "Current", + "type": "string" + } + }, + "custom": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "A map structure containing String-DtValue pairs", + "label": "DtMap" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "unknown" + } + }, + "descriptor.desc": { + "_schema": { + "default": "", + "descriptor": { + "desc": "A more elaborate description of the entity", + "label": "Description" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "descriptor.label": { + "_schema": { + "default": "", + "descriptor": { + "desc": "A label for the described entity", + "label": "Label" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "deviceId": { + "_schema": { + "default": "", + "descriptor": { + "desc": "", + "label": "Device Id" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "extraAlertFilters": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "Extra Alert Filters" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "unknown" + } + }, + "fDescriptor.desc": { + "_schema": { + "default": "", + "descriptor": { + "desc": "A more elaborate description of the entity", + "label": "Description" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "fDescriptor.label": { + "_schema": { + "default": "", + "descriptor": { + "desc": "A label for the described entity", + "label": "Label" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "gpid.component": { + "_schema": { + "default": 0, + "descriptor": { + "desc": "", + "label": "Source component" + }, + "gui": { + "tags": [], + "widget": "Dropdown" + }, + "isNullable": false, + "options": [ + { + "descriptor": { + "desc": "", + "label": "Alert Manager" + }, + "value": 5 + }, + { + "descriptor": { + "desc": "", + "label": "Booking Manager" + }, + "value": 8 + }, + { + "descriptor": { + "desc": "", + "label": "Config" + }, + "value": 101 + }, + { + "descriptor": { + "desc": "", + "label": "Configuration Manager" + }, + "value": 7 + }, + { + "descriptor": { + "desc": "", + "label": "Device" + }, + "value": 1 + }, + { + "descriptor": { + "desc": "", + "label": "Device Manager" + }, + "value": 6 + }, + { + "descriptor": { + "desc": "", + "label": "Domain" + }, + "value": 13 + }, + { + "descriptor": { + "desc": "", + "label": "External Manager" + }, + "value": 16 + }, + { + "descriptor": { + "desc": "", + "label": "GPIO Manager" + }, + "value": 19 + }, + { + "descriptor": { + "desc": "", + "label": "Generic" + }, + "value": 0 + }, + { + "descriptor": { + "desc": "", + "label": "Licensing" + }, + "value": 10 + }, + { + "descriptor": { + "desc": "", + "label": "Matrix Manager" + }, + "value": 18 + }, + { + "descriptor": { + "desc": "", + "label": "Metrics" + }, + "value": 102 + }, + { + "descriptor": { + "desc": "", + "label": "NDCP Manager" + }, + "value": 17 + }, + { + "descriptor": { + "desc": "", + "label": "NSO Manager" + }, + "value": 22 + }, + { + "descriptor": { + "desc": "", + "label": "Reachability" + }, + "value": 14 + }, + { + "descriptor": { + "desc": "", + "label": "Redundancy Controller" + }, + "value": 12 + }, + { + "descriptor": { + "desc": "", + "label": "Resource" + }, + "value": 99 + }, + { + "descriptor": { + "desc": "", + "label": "REST" + }, + "value": 11 + }, + { + "descriptor": { + "desc": "", + "label": "Service" + }, + "value": 100 + }, + { + "descriptor": { + "desc": "", + "label": "System" + }, + "value": 2 + }, + { + "descriptor": { + "desc": "", + "label": "System Topology" + }, + "value": 9 + }, + { + "descriptor": { + "desc": "", + "label": "Tally Manager" + }, + "value": 20 + }, + { + "descriptor": { + "desc": "", + "label": "Topology Manager" + }, + "value": 15 + }, + { + "descriptor": { + "desc": "", + "label": "Unknown" + }, + "value": 999 + }, + { + "descriptor": { + "desc": "", + "label": "User" + }, + "value": 4 + }, + { + "descriptor": { + "desc": "", + "label": "Virtual Router Manager" + }, + "value": 21 + } + ], + "ranges": [], + "status": "Current", + "type": "number", + "units": "" + } + }, + "gpid.pointId": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "A hierarchical ID with dot separated levels", + "label": "Point ID" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "unknown" + } + }, + "imgUrl": { + "_schema": { + "default": "", + "descriptor": { + "desc": "", + "label": "Image URL" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "ipAddress": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "IP Address" + }, + "isNullable": true, + "options": [], + "status": "Current", + "type": "unknown" + } + }, + "ipNetmask": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "IP Netmask" + }, + "isNullable": true, + "options": [], + "status": "Current", + "type": "unknown" + } + }, + "isVirtual": { + "_schema": { + "default": true, + "descriptor": { + "desc": "True, if vertex does not correspond to physical resource/driver.", + "label": "Virtual" + }, + "gui": { + "tags": [], + "widget": "CheckBox" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "maps": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Collection of object coordinates for various maps/contexts.", + "label": "Maps" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "unknown" + } + }, + "public": { + "_schema": { + "default": true, + "descriptor": { + "desc": "Share via distributed systems", + "label": "Public" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "sipsMode": { + "_schema": { + "default": "", + "descriptor": { + "desc": "", + "label": "SIPS mode type" + }, + "encoding": "UTF-8", + "gui": { + "tags": [], + "widget": "Dropdown" + }, + "isNullable": false, + "lengthRanges": [], + "options": [ + { + "descriptor": { + "desc": "", + "label": "NONE" + }, + "value": "NONE" + }, + { + "descriptor": { + "desc": "", + "label": "SIPS Auto" + }, + "value": "SIPSAuto" + }, + { + "descriptor": { + "desc": "", + "label": "SIPS Duplicate" + }, + "value": "SIPSDuplicate" + }, + { + "descriptor": { + "desc": "", + "label": "SIPS Merge" + }, + "value": "SIPSMerge" + }, + { + "descriptor": { + "desc": "", + "label": "SIPS Split" + }, + "value": "SIPSSplit" + } + ], + "status": "Current", + "type": "string" + } + }, + "supportsCpipeCfg": { + "_schema": { + "default": true, + "descriptor": { + "desc": "", + "label": "Supports C-Pipe Config" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "supportsIgmpCfg": { + "_schema": { + "default": true, + "descriptor": { + "desc": "", + "label": "Supports Igmp Config" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "supportsMacForwardingCfg": { + "_schema": { + "default": true, + "descriptor": { + "desc": "", + "label": "Supports Mac Forwarding Config" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "supportsNsoCfg": { + "_schema": { + "default": true, + "descriptor": { + "desc": "", + "label": "Supports Nso Config" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "supportsOpenflowCfg": { + "_schema": { + "default": true, + "descriptor": { + "desc": "", + "label": "Supports Openflow Config" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "supportsStaticIgmpCfg": { + "_schema": { + "default": true, + "descriptor": { + "desc": "", + "label": "Supports Static Igmp Config" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "supportsVlanCfg": { + "_schema": { + "default": true, + "descriptor": { + "desc": "", + "label": "Supports Vlan Config" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "supportsVplsCfg": { + "_schema": { + "default": true, + "descriptor": { + "desc": "", + "label": "Supports VPLS Config" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "supportsVxlanCfg": { + "_schema": { + "default": true, + "descriptor": { + "desc": "", + "label": "Supports Vxlan Config" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "tags": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "User defined tags", + "label": "Tags" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "unknown" + } + }, + "useAsEndpoint": { + "_schema": { + "default": true, + "descriptor": { + "desc": "", + "label": "Use as Endpoint" + }, + "gui": { + "tags": [], + "widget": "CheckBox" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "vertexType": { + "_schema": { + "default": "", + "descriptor": { + "desc": "", + "label": "Vertex type" + }, + "encoding": "UTF-8", + "gui": { + "tags": [], + "widget": "Dropdown" + }, + "isNullable": false, + "lengthRanges": [], + "options": [ + { + "descriptor": { + "desc": "", + "label": "BiDirectional" + }, + "value": "BiDirectional" + }, + { + "descriptor": { + "desc": "", + "label": "Input" + }, + "value": "In" + }, + { + "descriptor": { + "desc": "", + "label": "Internal" + }, + "value": "Internal" + }, + { + "descriptor": { + "desc": "", + "label": "Output" + }, + "value": "Out" + }, + { + "descriptor": { + "desc": "", + "label": "Undecided" + }, + "value": "Undecided" + } + ], + "status": "Current", + "type": "string" + } + }, + "vlanId": { + "_schema": { + "default": "", + "descriptor": { + "desc": "", + "label": "Vlan Id" + }, + "encoding": "UTF-8", + "isNullable": true, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "vrfId": { + "_schema": { + "default": "", + "descriptor": { + "desc": "", + "label": "VRF Id" + }, + "encoding": "UTF-8", + "isNullable": true, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + } + } + }, + "_vid": "ipVertex" + }, + { + "_id": "nGraphResourceTransform", + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "ResourceTransform" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "active": { + "_schema": { + "default": true, + "descriptor": { + "desc": "True, if the edge is active and used in service routing.", + "label": "Active" + }, + "gui": { + "tags": [], + "widget": "CheckBox" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "descriptor.desc": { + "_schema": { + "default": "", + "descriptor": { + "desc": "A more elaborate description of the entity", + "label": "Description" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "descriptor.label": { + "_schema": { + "default": "", + "descriptor": { + "desc": "A label for the described entity", + "label": "Label" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "fDescriptor.desc": { + "_schema": { + "default": "", + "descriptor": { + "desc": "A more elaborate description of the entity", + "label": "Description" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "fDescriptor.label": { + "_schema": { + "default": "", + "descriptor": { + "desc": "A label for the described entity", + "label": "Label" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "fResourceIds": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Factory resource ids", + "label": "fResourceIds" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "unknown" + } + }, + "fromId": { + "_schema": { + "default": "", + "descriptor": { + "desc": "ID of the from-vertex", + "label": "fromId" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "resourceIds": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "User resource ids", + "label": "resourceIds" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "unknown" + } + }, + "tags": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "Tags" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "unknown" + } + }, + "toId": { + "_schema": { + "default": "", + "descriptor": { + "desc": "ID of the to-vertex", + "label": "toId" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + } + } + }, + "_vid": "nGraphResourceTransform" + }, + { + "_id": "routerVertex", + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "RouterVertex" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "active": { + "_schema": { + "default": true, + "descriptor": { + "desc": "True, if the vertex can be used in service routing.", + "label": "Active" + }, + "gui": { + "tags": [], + "widget": "CheckBox" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "configPriority": { + "_schema": { + "default": "", + "descriptor": { + "desc": "Priority level of the endpoint during configuration", + "label": "Config priority level" + }, + "encoding": "UTF-8", + "gui": { + "tags": [], + "widget": "Dropdown" + }, + "isNullable": false, + "lengthRanges": [], + "options": [ + { + "descriptor": { + "desc": "", + "label": "High" + }, + "value": "high" + }, + { + "descriptor": { + "desc": "", + "label": "Low" + }, + "value": "low" + }, + { + "descriptor": { + "desc": "", + "label": "Off" + }, + "value": "off" + } + ], + "status": "Current", + "type": "string" + } + }, + "control": { + "_schema": { + "default": "", + "descriptor": { + "desc": "Determines how the vertex is configured, e.g. if it should be auto-reapplied (full).", + "label": "Control level" + }, + "encoding": "UTF-8", + "gui": { + "tags": [], + "widget": "Dropdown" + }, + "isNullable": false, + "lengthRanges": [], + "options": [ + { + "descriptor": { + "desc": "", + "label": "Full" + }, + "value": "full" + }, + { + "descriptor": { + "desc": "", + "label": "Off" + }, + "value": "off" + }, + { + "descriptor": { + "desc": "", + "label": "Initial" + }, + "value": "semi" + } + ], + "status": "Current", + "type": "string" + } + }, + "custom": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "A map structure containing String-DtValue pairs", + "label": "DtMap" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "unknown" + } + }, + "descriptor.desc": { + "_schema": { + "default": "", + "descriptor": { + "desc": "A more elaborate description of the entity", + "label": "Description" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "descriptor.label": { + "_schema": { + "default": "", + "descriptor": { + "desc": "A label for the described entity", + "label": "Label" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "deviceId": { + "_schema": { + "default": "", + "descriptor": { + "desc": "", + "label": "Device Id" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "extraAlertFilters": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "Extra Alert Filters" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "unknown" + } + }, + "fDescriptor.desc": { + "_schema": { + "default": "", + "descriptor": { + "desc": "A more elaborate description of the entity", + "label": "Description" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "fDescriptor.label": { + "_schema": { + "default": "", + "descriptor": { + "desc": "A label for the described entity", + "label": "Label" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "gpid.component": { + "_schema": { + "default": 0, + "descriptor": { + "desc": "", + "label": "Source component" + }, + "gui": { + "tags": [], + "widget": "Dropdown" + }, + "isNullable": false, + "options": [ + { + "descriptor": { + "desc": "", + "label": "Alert Manager" + }, + "value": 5 + }, + { + "descriptor": { + "desc": "", + "label": "Booking Manager" + }, + "value": 8 + }, + { + "descriptor": { + "desc": "", + "label": "Config" + }, + "value": 101 + }, + { + "descriptor": { + "desc": "", + "label": "Configuration Manager" + }, + "value": 7 + }, + { + "descriptor": { + "desc": "", + "label": "Device" + }, + "value": 1 + }, + { + "descriptor": { + "desc": "", + "label": "Device Manager" + }, + "value": 6 + }, + { + "descriptor": { + "desc": "", + "label": "Domain" + }, + "value": 13 + }, + { + "descriptor": { + "desc": "", + "label": "External Manager" + }, + "value": 16 + }, + { + "descriptor": { + "desc": "", + "label": "GPIO Manager" + }, + "value": 19 + }, + { + "descriptor": { + "desc": "", + "label": "Generic" + }, + "value": 0 + }, + { + "descriptor": { + "desc": "", + "label": "Licensing" + }, + "value": 10 + }, + { + "descriptor": { + "desc": "", + "label": "Matrix Manager" + }, + "value": 18 + }, + { + "descriptor": { + "desc": "", + "label": "Metrics" + }, + "value": 102 + }, + { + "descriptor": { + "desc": "", + "label": "NDCP Manager" + }, + "value": 17 + }, + { + "descriptor": { + "desc": "", + "label": "NSO Manager" + }, + "value": 22 + }, + { + "descriptor": { + "desc": "", + "label": "Reachability" + }, + "value": 14 + }, + { + "descriptor": { + "desc": "", + "label": "Redundancy Controller" + }, + "value": 12 + }, + { + "descriptor": { + "desc": "", + "label": "Resource" + }, + "value": 99 + }, + { + "descriptor": { + "desc": "", + "label": "REST" + }, + "value": 11 + }, + { + "descriptor": { + "desc": "", + "label": "Service" + }, + "value": 100 + }, + { + "descriptor": { + "desc": "", + "label": "System" + }, + "value": 2 + }, + { + "descriptor": { + "desc": "", + "label": "System Topology" + }, + "value": 9 + }, + { + "descriptor": { + "desc": "", + "label": "Tally Manager" + }, + "value": 20 + }, + { + "descriptor": { + "desc": "", + "label": "Topology Manager" + }, + "value": 15 + }, + { + "descriptor": { + "desc": "", + "label": "Unknown" + }, + "value": 999 + }, + { + "descriptor": { + "desc": "", + "label": "User" + }, + "value": 4 + }, + { + "descriptor": { + "desc": "", + "label": "Virtual Router Manager" + }, + "value": 21 + } + ], + "ranges": [], + "status": "Current", + "type": "number", + "units": "" + } + }, + "gpid.pointId": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "A hierarchical ID with dot separated levels", + "label": "Point ID" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "unknown" + } + }, + "imgUrl": { + "_schema": { + "default": "", + "descriptor": { + "desc": "", + "label": "Image URL" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "isVirtual": { + "_schema": { + "default": true, + "descriptor": { + "desc": "True, if vertex does not correspond to physical resource/driver.", + "label": "Virtual" + }, + "gui": { + "tags": [], + "widget": "CheckBox" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "maps": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Collection of object coordinates for various maps/contexts.", + "label": "Maps" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "unknown" + } + }, + "parkPort": { + "_schema": { + "default": 0, + "descriptor": { + "desc": "", + "label": "Park Port" + }, + "isNullable": true, + "options": [], + "ranges": [], + "status": "Current", + "type": "number", + "units": "" + } + }, + "sipsMode": { + "_schema": { + "default": "", + "descriptor": { + "desc": "", + "label": "SIPS mode type" + }, + "encoding": "UTF-8", + "gui": { + "tags": [], + "widget": "Dropdown" + }, + "isNullable": false, + "lengthRanges": [], + "options": [ + { + "descriptor": { + "desc": "", + "label": "NONE" + }, + "value": "NONE" + }, + { + "descriptor": { + "desc": "", + "label": "SIPS Auto" + }, + "value": "SIPSAuto" + }, + { + "descriptor": { + "desc": "", + "label": "SIPS Duplicate" + }, + "value": "SIPSDuplicate" + }, + { + "descriptor": { + "desc": "", + "label": "SIPS Merge" + }, + "value": "SIPSMerge" + }, + { + "descriptor": { + "desc": "", + "label": "SIPS Split" + }, + "value": "SIPSSplit" + } + ], + "status": "Current", + "type": "string" + } + }, + "tags": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "User defined tags", + "label": "Tags" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "unknown" + } + }, + "useAsEndpoint": { + "_schema": { + "default": true, + "descriptor": { + "desc": "", + "label": "Use as Endpoint" + }, + "gui": { + "tags": [], + "widget": "CheckBox" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "vertexType": { + "_schema": { + "default": "", + "descriptor": { + "desc": "", + "label": "Vertex type" + }, + "encoding": "UTF-8", + "gui": { + "tags": [], + "widget": "Dropdown" + }, + "isNullable": false, + "lengthRanges": [], + "options": [ + { + "descriptor": { + "desc": "", + "label": "BiDirectional" + }, + "value": "BiDirectional" + }, + { + "descriptor": { + "desc": "", + "label": "Input" + }, + "value": "In" + }, + { + "descriptor": { + "desc": "", + "label": "Internal" + }, + "value": "Internal" + }, + { + "descriptor": { + "desc": "", + "label": "Output" + }, + "value": "Out" + }, + { + "descriptor": { + "desc": "", + "label": "Undecided" + }, + "value": "Undecided" + } + ], + "status": "Current", + "type": "string" + } + } + } + }, + "_vid": "routerVertex" + }, + { + "_id": "unidirectionalEdge", + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "UnidirectionalEdge" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "active": { + "_schema": { + "default": true, + "descriptor": { + "desc": "True, if the edge is active and used in service routing.", + "label": "Active" + }, + "gui": { + "tags": [], + "widget": "CheckBox" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "bandwidth": { + "_schema": { + "default": -1.0, + "descriptor": { + "desc": "Max allowed bandwidth.", + "label": "Bandwidth capacity" + }, + "gui": { + "tags": [], + "widget": "TextField" + }, + "isNullable": false, + "options": [], + "ranges": [], + "status": "Current", + "type": "number", + "units": "Mbit/s" + } + }, + "capacity": { + "_schema": { + "default": 1, + "descriptor": { + "desc": "Max number of simultaneous services.", + "label": "Services capacity" + }, + "gui": { + "tags": [], + "widget": "TextField" + }, + "isNullable": false, + "options": [], + "ranges": [], + "status": "Current", + "type": "number", + "units": "" + } + }, + "conflictPri": { + "_schema": { + "default": 0, + "descriptor": { + "desc": "", + "label": "Conflict priority" + }, + "gui": { + "tags": [], + "widget": "Dropdown" + }, + "isNullable": false, + "options": [ + { + "descriptor": { + "desc": "", + "label": "High" + }, + "value": 1 + }, + { + "descriptor": { + "desc": "", + "label": "Low" + }, + "value": 3 + }, + { + "descriptor": { + "desc": "", + "label": "Normal" + }, + "value": 2 + }, + { + "descriptor": { + "desc": "", + "label": "Off" + }, + "value": 0 + } + ], + "ranges": [], + "status": "Current", + "type": "number", + "units": "" + } + }, + "descriptor.desc": { + "_schema": { + "default": "", + "descriptor": { + "desc": "A more elaborate description of the entity", + "label": "Description" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "descriptor.label": { + "_schema": { + "default": "", + "descriptor": { + "desc": "A label for the described entity", + "label": "Label" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "excludeFormats": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "Exclude Formats" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "unknown" + } + }, + "fDescriptor.desc": { + "_schema": { + "default": "", + "descriptor": { + "desc": "A more elaborate description of the entity", + "label": "Description" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "fDescriptor.label": { + "_schema": { + "default": "", + "descriptor": { + "desc": "A label for the described entity", + "label": "Label" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "fromId": { + "_schema": { + "default": "", + "descriptor": { + "desc": "ID of the from-vertex", + "label": "fromId" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "includeFormats": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "Include Formats" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "unknown" + } + }, + "redundancyMode": { + "_schema": { + "default": "", + "descriptor": { + "desc": "", + "label": "RedundancyMode" + }, + "encoding": "UTF-8", + "gui": { + "tags": [], + "widget": "Dropdown" + }, + "isNullable": false, + "lengthRanges": [], + "options": [ + { + "descriptor": { + "desc": "", + "label": "Any" + }, + "value": "Any" + }, + { + "descriptor": { + "desc": "", + "label": "OnlyMain" + }, + "value": "OnlyMain" + }, + { + "descriptor": { + "desc": "", + "label": "OnlySpare" + }, + "value": "OnlySpare" + } + ], + "status": "Current", + "type": "string" + } + }, + "tags": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "Tags" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "unknown" + } + }, + "toId": { + "_schema": { + "default": "", + "descriptor": { + "desc": "ID of the to-vertex", + "label": "toId" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "weight": { + "_schema": { + "default": 1, + "descriptor": { + "desc": "The edge weight/cost for routing.", + "label": "Fixed weight" + }, + "gui": { + "tags": [], + "widget": "TextField" + }, + "isNullable": false, + "options": [], + "ranges": [], + "status": "Current", + "type": "number", + "units": "" + } + }, + "weightFactors.bandwidth.weight": { + "_schema": { + "default": 1, + "descriptor": { + "desc": "Enables bandwidth-based weight calculation. The number corresponds to the weight at 100% link utilization.", + "label": "Bandwidth weight factor" + }, + "gui": { + "tags": [], + "widget": "TextField" + }, + "isNullable": false, + "options": [], + "ranges": [], + "status": "Current", + "type": "number", + "units": "" + } + }, + "weightFactors.service.max": { + "_schema": { + "default": 100, + "descriptor": { + "desc": "The maximum value that service weighting will contribute with. Useful to define an absolute.", + "label": "Max total" + }, + "gui": { + "tags": [], + "widget": "TextField" + }, + "isNullable": false, + "options": [], + "ranges": [], + "status": "Current", + "type": "number", + "units": "" + } + }, + "weightFactors.service.weight": { + "_schema": { + "default": 1, + "descriptor": { + "desc": "Enables service-based weight calculation. The given number is the weight that each service contributes with.", + "label": "Weight per service" + }, + "gui": { + "tags": [], + "widget": "TextField" + }, + "isNullable": false, + "options": [], + "ranges": [], + "status": "Current", + "type": "number", + "units": "" + } + } + } + }, + "_vid": "unidirectionalEdge" + } + ] + } + } + } + } +} \ No newline at end of file diff --git a/src/videoipath_automation_tool/apps/topology/topology_app.py b/src/videoipath_automation_tool/apps/topology/topology_app.py index b590dd9..32d123d 100644 --- a/src/videoipath_automation_tool/apps/topology/topology_app.py +++ b/src/videoipath_automation_tool/apps/topology/topology_app.py @@ -1,8 +1,10 @@ import logging +import warnings from typing import List, Literal, Optional from typing_extensions import deprecated +from videoipath_automation_tool.apps.topology.errors import TopologyUnsupportedError from videoipath_automation_tool.apps.topology.helper.placement import TopologyPlacement from videoipath_automation_tool.apps.topology.model.n_graph_elements.topology_base_device import BaseDevice from videoipath_automation_tool.apps.topology.model.n_graph_elements.topology_codec_vertex import CodecVertex @@ -20,6 +22,9 @@ from videoipath_automation_tool.utils.cross_app_utils import create_fallback_logger from videoipath_automation_tool.validators.device_id_including_virtual import validate_device_id_including_virtual +_DEPRECATION_MAJOR = 2025 +_UNSUPPORTED_MAJOR = 2026 + class TopologyApp: def __init__(self, vip_connector: VideoIPathConnector, logger: Optional[logging.Logger] = None): @@ -32,6 +37,8 @@ def __init__(self, vip_connector: VideoIPathConnector, logger: Optional[logging. # --- Setup Logging --- self._logger = logger or create_fallback_logger("videoipath_automation_tool_topology_app") + self._check_version_compatibility(vip_connector) + # --- Setup Topology API --- self._topology_api = TopologyAPI(vip_connector=vip_connector, logger=self._logger) @@ -46,6 +53,25 @@ def __init__(self, vip_connector: VideoIPathConnector, logger: Optional[logging. self._logger.debug("Topology APP initialized.") + def _check_version_compatibility(self, vip_connector: VideoIPathConnector) -> None: + version = vip_connector.videoipath_version + parsed = _parse_version(version) + if parsed is None: + return + + major, _minor = parsed + if major >= _UNSUPPORTED_MAJOR: + raise TopologyUnsupportedError( + f"TopologyApp is not supported on VideoIPath {version}. Use InspectApp (app.inspect) instead." + ) + if major == _DEPRECATION_MAJOR: + message = ( + "TopologyApp is deprecated on VideoIPath 2025.x and will not be supported on 2026.x. " + "Migrate to InspectApp (app.inspect)." + ) + warnings.warn(message, DeprecationWarning, stacklevel=3) + self._logger.warning(message) + # --- Topology Device CRUD Operations --- def get_device(self, device_id: str) -> TopologyDevice: @@ -411,6 +437,16 @@ def create_edges( ) +def _parse_version(version: str) -> Optional[tuple[int, int]]: + parts = version.split(".") + if len(parts) < 2: + return None + try: + return int(parts[0]), int(parts[1]) + except ValueError: + return None + + class TopologyExperimental: def __init__(self, topology_api: TopologyAPI, logger: logging.Logger, get_device_method): """Experimental layer for the TopologyApp.""" diff --git a/src/videoipath_automation_tool/apps/videoipath_app.py b/src/videoipath_automation_tool/apps/videoipath_app.py index 5081dcf..8ddf651 100644 --- a/src/videoipath_automation_tool/apps/videoipath_app.py +++ b/src/videoipath_automation_tool/apps/videoipath_app.py @@ -1,11 +1,13 @@ import logging from typing import Literal, Optional +from videoipath_automation_tool.apps.inspect.app import InspectApp from videoipath_automation_tool.apps.inventory import InventoryApp from videoipath_automation_tool.apps.inventory.model.drivers import AVAILABLE_SCHEMA_VERSIONS, SELECTED_SCHEMA_VERSION from videoipath_automation_tool.apps.preferences.preferences_app import PreferencesApp from videoipath_automation_tool.apps.profile.profile_app import ProfileApp from videoipath_automation_tool.apps.security.security_app import SecurityApp +from videoipath_automation_tool.apps.topology.errors import TopologyUnsupportedError from videoipath_automation_tool.apps.topology.topology_app import TopologyApp from videoipath_automation_tool.connector.vip_connector import VideoIPathConnector from videoipath_automation_tool.settings import Settings @@ -221,15 +223,21 @@ def __init__( self._preferences = None self._profile = None self._security = None + self._inspect = None self._logger.info("VideoIPath Automation Tool initialized.") # --- For Development Environment, load the APIs directly and map them to the VideoIPathApp for easier access --- if environment == "DEV": self._inventory_api = self.inventory._inventory_api - self._topology_api = self.topology._topology_api + try: + self._topology_api = self.topology._topology_api + except TopologyUnsupportedError as exc: + self._logger.warning(str(exc)) + self._topology_api = None self._preferences_api = self.preferences._preferences_api self._profile_api = self.profile._profile_api + self._inspect_api = self.inspect._inspect_api # --- Getters to enable lazy loading --- @property @@ -267,6 +275,13 @@ def security(self): self._security = SecurityApp(vip_connector=self._videoipath_connector, logger=self._logger) return self._security + @property + def inspect(self): + if self._inspect is None: + self._logger.debug("InspectApp first called. Initialize InspectApp.") + self._inspect = InspectApp(vip_connector=self._videoipath_connector, logger=self._logger) + return self._inspect + # --- Basic Methods --- def _determine_fallback_driver_schema_version(self) -> Optional[str]: """ diff --git a/src/videoipath_automation_tool/connector/models/response_rest_v2.py b/src/videoipath_automation_tool/connector/models/response_rest_v2.py index 3a1dbf2..54b14a3 100644 --- a/src/videoipath_automation_tool/connector/models/response_rest_v2.py +++ b/src/videoipath_automation_tool/connector/models/response_rest_v2.py @@ -69,6 +69,9 @@ class ResponseV2Patch(ResponseV2): # --- POST --- class ResponseV2Post(ResponseV2): - """REST API v2 POST Response""" + """REST API v2 POST Response. - data: dict + ``data`` may be ``null`` for some actions (e.g. ``assignTag`` / ``unassignTag``). + """ + + data: dict | None = None diff --git a/src/videoipath_automation_tool/connector/vip_rest_connector.py b/src/videoipath_automation_tool/connector/vip_rest_connector.py index 0269d7c..52a1905 100644 --- a/src/videoipath_automation_tool/connector/vip_rest_connector.py +++ b/src/videoipath_automation_tool/connector/vip_rest_connector.py @@ -13,7 +13,11 @@ class VideoIPathRestConnector(VideoIPathBaseConnector): }, "PATCH": {"PREFIXES": {"/rest/v2/data/config/"}, "EXACT_MATCHES": set()}, "POST": { - "PREFIXES": {"/rest/v2/actions/status/collector/"}, + "PREFIXES": { + "/rest/v2/actions/status/collector/", + "/rest/v2/actions/status/network/", + "/rest/v2/actions/status/tags/", + }, "EXACT_MATCHES": {"/rest/v2/actions/status/pathman/validateTopologyUpdate"}, }, } @@ -24,6 +28,7 @@ def get( auth_check: bool = True, node_check: bool = True, url_validation: bool = True, + allow_projection: bool = False, version: Literal["v2"] = "v2", ) -> ResponseV2Get: """ @@ -37,6 +42,11 @@ def get( auth_check (bool, optional): If `True`, verifies authentication status in the response (default: `True`). node_check (bool, optional): If `True`, ensures that all expected nodes are present in the response data (default: `True`). url_validation (bool, optional): If `True`, validates the URL path (default: `True`). + allow_projection (bool, optional): If `True`, permits scoped projection paths that contain + `/.../` up-navigation segments (used by the Inspect collector queries). When `True`, + `node_check` is implicitly skipped because projected responses do not contain the full + node structure. Defaults to `False` (the `/...` wildcard is rejected, preserving the + behaviour of all existing callers). version (Literal["v2"], optional): The API version to use (default: "v2"). Returns: @@ -56,10 +66,14 @@ def get( if url_validation: self._validate_url(url_path, "GET") - if "/..." in url_path: + if not allow_projection and "/..." in url_path: error_message = "Wildcard '/...' is not allowed in URL path." raise ValueError(error_message) + if allow_projection: + # Projected responses only carry the selected sub-tree, so the full-node check cannot pass. + node_check = False + response = self._execute_request( method="GET", url=self._build_url(url_path), diff --git a/src/videoipath_automation_tool/validators/virtual_device_id.py b/src/videoipath_automation_tool/validators/virtual_device_id.py index 64fba13..92702b4 100644 --- a/src/videoipath_automation_tool/validators/virtual_device_id.py +++ b/src/videoipath_automation_tool/validators/virtual_device_id.py @@ -1,5 +1,12 @@ import re +_VIRTUAL_DEVICE_ID_PATTERN = re.compile(r"virtual\.(0|[1-9]\d*)") + + +def is_virtual_device_id(device_id: str) -> bool: + """Return whether ``device_id`` matches the ``virtual.`` topology id form.""" + return isinstance(device_id, str) and _VIRTUAL_DEVICE_ID_PATTERN.fullmatch(device_id) is not None + def validate_virtual_device_id(virtual_device_id: str) -> str: """ @@ -17,13 +24,7 @@ def validate_virtual_device_id(virtual_device_id: str) -> str: if not isinstance(virtual_device_id, str): raise ValueError(f"Each virtual device ID must be a string. Invalid virtual device ID: {virtual_device_id}") - pattern = r"virtual\.(0|[1-9]\d*)" - # Regular expression pattern explanation: - # virtual\.(0|[1-9]\d*) - The virtual device ID starts with the word 'virtual.' followed by a number: - # - '0' or - # - a positive integer (1-9) followed by zero or more digits between 0 and 9. - - if not re.fullmatch(pattern, virtual_device_id): + if not is_virtual_device_id(virtual_device_id): raise ValueError( f"Invalid virtual device ID syntax: '{virtual_device_id}'. The expected format is: virtual.." ) diff --git a/src/vipat_cli_scripts/project_env.py b/src/vipat_cli_scripts/project_env.py new file mode 100644 index 0000000..474f0fd --- /dev/null +++ b/src/vipat_cli_scripts/project_env.py @@ -0,0 +1,36 @@ +"""Load project-root ``.env`` for e2e and local development.""" + +from __future__ import annotations + +import os +from pathlib import Path + +from dotenv import load_dotenv + +_PROJECT_ROOT = Path(__file__).resolve().parents[2] +_ENV_FILE = _PROJECT_ROOT / ".env" + +_LEGACY_ENV_ALIASES = { + "VIDEOIPATH_SERVER_ADDRESS": "VIPAT_VIDEOIPATH_SERVER_ADDRESS", + "VIDEOIPATH_USERNAME": "VIPAT_VIDEOIPATH_USERNAME", + "VIDEOIPATH_PASSWORD": "VIPAT_VIDEOIPATH_PASSWORD", + "VIDEOIPATH_USE_HTTPS": "VIPAT_USE_HTTPS", + "VIDEOIPATH_VERIFY_SSL": "VIPAT_VERIFY_SSL_CERT", + "VIDEOIPATH_VERIFY_SSL_CERT": "VIPAT_VERIFY_SSL_CERT", +} + + +def load_project_env() -> None: + """Load ``.env`` from the project root and normalize legacy variable names.""" + if _ENV_FILE.is_file(): + load_dotenv(_ENV_FILE, override=True) + for legacy, canonical in _LEGACY_ENV_ALIASES.items(): + legacy_value = os.environ.get(legacy) + if legacy_value and not os.environ.get(canonical): + os.environ[canonical] = legacy_value + + +def prepare_e2e_env() -> None: + """Load ``.env`` and enable the e2e gate for explicit e2e test runs.""" + load_project_env() + os.environ["VIPAT_E2E_ENABLED"] = "1" diff --git a/src/vipat_cli_scripts/test_runner.py b/src/vipat_cli_scripts/test_runner.py new file mode 100644 index 0000000..9a290da --- /dev/null +++ b/src/vipat_cli_scripts/test_runner.py @@ -0,0 +1,33 @@ +"""Pytest entry points for unit, e2e, and combined test suites.""" + +from __future__ import annotations + +import sys + +import pytest + +from vipat_cli_scripts.project_env import prepare_e2e_env + +_UNIT_ARGS = ["-m", "not e2e", "--ignore=tests/e2e"] +_E2E_ARGS = ["-m", "e2e", "tests/e2e", "--no-cov"] + + +def _run(args: list[str], *, extra: list[str] | None = None) -> int: + return pytest.main([*args, *(extra if extra is not None else sys.argv[1:])]) + + +def run_unit() -> None: + raise SystemExit(_run(_UNIT_ARGS)) + + +def run_e2e() -> None: + prepare_e2e_env() + raise SystemExit(_run(_E2E_ARGS)) + + +def run() -> None: + rc = _run(_UNIT_ARGS, extra=[]) + if rc != 0: + raise SystemExit(rc) + prepare_e2e_env() + raise SystemExit(_run(_E2E_ARGS, extra=[])) diff --git a/tests/.env.test b/tests/.env.test deleted file mode 100644 index 9985962..0000000 --- a/tests/.env.test +++ /dev/null @@ -1,7 +0,0 @@ -VIPAT_ENVIRONMENT=DEV -VIPAT_VIDEOIPATH_SERVER_ADDRESS=vip.company.com -VIPAT_VIDEOIPATH_USERNAME=user_with_api_access -VIPAT_VIDEOIPATH_PASSWORD=veryStrongPassword -VIPAT_USE_HTTPS=true -VIPAT_VERIFY_SSL_CERT=false -VIPAT_LOG_LEVEL=DEBUG \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..a0bcd3c --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,23 @@ +"""Shared pytest configuration for offline unit tests.""" + +from __future__ import annotations + +import pytest + +UNIT_TEST_ENV = { + "VIPAT_ENVIRONMENT": "DEV", + "VIPAT_VIDEOIPATH_SERVER_ADDRESS": "vip-server.example", + "VIPAT_VIDEOIPATH_USERNAME": "test-user", + "VIPAT_VIDEOIPATH_PASSWORD": "test-password", + "VIPAT_USE_HTTPS": "true", + "VIPAT_VERIFY_SSL_CERT": "false", + "VIPAT_LOG_LEVEL": "DEBUG", +} + + +@pytest.fixture(autouse=True) +def _unit_test_env(request: pytest.FixtureRequest, monkeypatch: pytest.MonkeyPatch) -> None: + if request.node.get_closest_marker("e2e"): + return + for key, value in UNIT_TEST_ENV.items(): + monkeypatch.setenv(key, value) diff --git a/tests/e2e/__init__.py b/tests/e2e/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/e2e/apps/__init__.py b/tests/e2e/apps/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/e2e/apps/test_inspect.py b/tests/e2e/apps/test_inspect.py new file mode 100644 index 0000000..9fcf22d --- /dev/null +++ b/tests/e2e/apps/test_inspect.py @@ -0,0 +1,319 @@ +"""Focused Inspect app suite: read/write capabilities against a live instance. + +Skipped when the live VideoIPath major year is less than 2025 (see e2e conftest). + +Every test builds its **own** minimal topology (1–3 mock devices) via the ``topology_builder`` +fixture — unique labels/addresses per test. Assertions are scoped to the test's own device ids so +other suites on a shared instance never interfere. ``E2E-`` artifacts persist until the next e2e +session-start sweep. + +Run with:: + + poetry run test-e2e +""" + +from __future__ import annotations + +import pytest + +from videoipath_automation_tool.apps.inspect import VirtualDeviceSpec +from videoipath_automation_tool.apps.inspect.errors import InspectCommitConflictError, InspectCommitError +from videoipath_automation_tool.apps.inspect.model.common import InspectSeverity +from videoipath_automation_tool.apps.videoipath_app import VideoIPathApp + +from ..helpers import ( + E2E_TAG, + MODULE_TEST_TAG_ID, + TEST_TAG_ID, + FetchSpy, + TopologyBuilder, + create_module_test_tag, + create_test_tag, + edges_between, + router_ports, + unique_label, +) + +pytestmark = pytest.mark.e2e + + +def test_status_severity_enums_and_device_alarms(app: VideoIPathApp, topology_builder: TopologyBuilder) -> None: + """Live severities parse to InspectSeverity; mock-driver alarms correlate onto their device.""" + (device_id,) = topology_builder.add_devices([("STATUS-A", 2)]) + app.inspect.refresh() + device = app.inspect.get_device(device_id) + assert device is not None + + known = set(InspectSeverity) + if device.status is not None: + if device.status.severity is not None: + assert device.status.severity in known, f"unmapped severity: {device.status.severity!r}" + if device.status.sa is not None: + assert device.status.sa in known, f"unmapped sa: {device.status.sa!r}" + if device.sync_severity is not None: + assert device.sync_severity in known, f"unmapped sync_severity: {device.sync_severity!r}" + + for edge in app.inspect.edges[:20]: + status = edge.status + if status is None: + continue + for value in (status.alarm, status.ptp, status.maintenance, status.bandwidth): + if value is None: + continue + assert value in known, f"unmapped edge status value: {value!r}" + + # Newly onboarded mocks may not have raised their driver notice yet; correlate against any + # device that currently carries the Mock alarm (verified shape on this server). + mock_carrier = None + for candidate in app.inspect.devices: + matches = [alarm for alarm in candidate.alarms if alarm.message == "Mock driver in use"] + if matches: + mock_carrier = (candidate, matches[0]) + break + if mock_carrier is None: + pytest.skip("No active 'Mock driver in use' alarm on this server right now.") + carrier, alarm = mock_carrier + assert alarm.severity is InspectSeverity.NOTICE + assert carrier.status_message == "Mock driver in use" + + +def test_skeleton_read_no_hydration(app: VideoIPathApp, topology_builder: TopologyBuilder) -> None: + id_a, id_b = topology_builder.add_devices([("SKEL-A", 2), ("SKEL-B", 2)]) + topology_builder.link(id_a, id_b) + app.inspect.refresh() + with FetchSpy(app.inspect._inspect_api) as spy: + assert len(edges_between(app, id_a, id_b)) == 2 + assert spy.count == 0 + + +def test_lazy_hydration(app: VideoIPathApp, topology_builder: TopologyBuilder) -> None: + id_a, id_b = topology_builder.add_devices([("HYD-A", 2), ("HYD-B", 2)]) + app.inspect.refresh() + assert not app.inspect.is_device_hydrated(id_a) + with FetchSpy(app.inspect._inspect_api) as spy: + ports = app.inspect.get_device(id_a).ports + assert spy.count == 1 + _ = app.inspect.get_device(id_a).ports + assert spy.count == 1 + assert len(ports) > 0 + assert app.inspect.is_device_hydrated(id_a) + assert not app.inspect.is_device_hydrated(id_b) + + +def test_connectivity_graph(app: VideoIPathApp, topology_builder: TopologyBuilder) -> None: + hub, id_b, id_c = topology_builder.add_devices([("HUB-A", 2), ("HUB-B", 2), ("HUB-C", 2)]) + topology_builder.link(hub, id_b) + topology_builder.link(hub, id_c) + app.inspect.refresh() + linked = {device.label for device in app.inspect.get_device(hub).linked_devices} + assert linked == {topology_builder.labels[id_b], topology_builder.labels[id_c]} + + +def test_edge_pair_refresh(app: VideoIPathApp, topology_builder: TopologyBuilder) -> None: + id_a, id_b = topology_builder.add_devices([("EDGE-A", 2), ("EDGE-B", 2)]) + topology_builder.link(id_a, id_b) + app.inspect.refresh() + edge_id = edges_between(app, id_a, id_b)[0].id + result = app.inspect.update_edge(edge_id, weight=7) + assert result.ok + assert any(edge.id == edge_id for edge in app.inspect.edges) + + +def test_transaction_atomicity(app: VideoIPathApp, topology_builder: TopologyBuilder) -> None: + id_a, id_b = topology_builder.add_devices([("ATOM-A", 2), ("ATOM-B", 2)]) + topology_builder.link(id_a, id_b) + app.inspect.refresh() + edge_id = edges_between(app, id_a, id_b)[0].id + with pytest.raises(InspectCommitError): + with app.inspect.transaction() as tx: + tx.update_edge(edge_id, weight=13) + tx.remove("does-not-exist::also-not-real") + tx.commit() + app.inspect.refresh() + assert any(edge.id == edge_id for edge in app.inspect.edges) + + +def test_conflict_detection(app: VideoIPathApp, topology_builder: TopologyBuilder) -> None: + id_a, id_b = topology_builder.add_devices([("CONF-A", 2), ("CONF-B", 2)]) + topology_builder.link(id_a, id_b) + app.inspect.refresh() + edge_id = edges_between(app, id_a, id_b)[0].id + tx = app.inspect.transaction() + tx.update_edge(edge_id, weight=21) + other = VideoIPathApp() + other.inspect.update_edge(edge_id, weight=9) + with pytest.raises(InspectCommitConflictError) as exc: + tx.commit() + assert any(conflict.entity_id == edge_id for conflict in exc.value.conflicts) + tx.rebase() + tx.commit() + + +def test_assign_tag_to_port(app: VideoIPathApp, topology_builder: TopologyBuilder) -> None: + (device_id,) = topology_builder.add_devices([("TAG-A", 2)]) + create_test_tag(app) + app.inspect.refresh() + device = app.inspect.get_device(device_id) + assert device is not None + _out, in_vertex = router_ports(device)[0] + result = app.inspect.update_vertex(in_vertex, tags=[TEST_TAG_ID]) + assert result.ok + app.inspect.refresh() + port = next( + port + for port in app.inspect.get_device(device_id).ports + if port.vertex_in is not None and port.vertex_in.id == in_vertex + ) + assert TEST_TAG_ID in port.tags + + +def test_assign_and_unassign_module_tag(app: VideoIPathApp, topology_builder: TopologyBuilder) -> None: + (device_id,) = topology_builder.add_devices([("MOD-TAG-A", 2)]) + create_module_test_tag(app) + app.inspect.refresh() + device = app.inspect.get_device(device_id) + assert device is not None + assert device.modules + module = device.modules[0] + module_id = module.id + + module.tags = [MODULE_TEST_TAG_ID] + result = app.inspect.update(module) + assert result.ok + + app.inspect.refresh() + module = app.inspect.get_device(device_id).get_module(module_id) + assert module is not None + assert MODULE_TEST_TAG_ID in module.tags + + module.tags = [] + result = app.inspect.update(module) + assert result.ok + + app.inspect.refresh() + module = app.inspect.get_device(device_id).get_module(module_id) + assert module is not None + assert MODULE_TEST_TAG_ID not in module.tags + + +def test_update_vertex_fields(app: VideoIPathApp, topology_builder: TopologyBuilder) -> None: + (device_id,) = topology_builder.add_devices([("VTX-A", 2)]) + app.inspect.refresh() + device = app.inspect.get_device(device_id) + assert device is not None + _out, vertex_id = router_ports(device)[0] + alert_filter = "0:*:e2e-alarm:*" + result = app.inspect.update_vertex( + vertex_id, + label="E2E Router In", + description="E2E vertex description", + use_as_endpoint=True, + active=True, + sips_mode="SIPSAuto", + control_props={"configPriority": "high", "onlyInitial": True}, + extra_alert_filters=[alert_filter], + custom={"e2e-param": "e2e-value"}, + park_port=7, + ) + assert result.ok + + app.inspect.refresh() + port = next( + port + for port in app.inspect.get_device(device_id).ports + if port.vertex_in is not None and port.vertex_in.id == vertex_id + ) + vertex = port.vertex_in + assert vertex is not None + assert vertex.label == "E2E Router In" + assert vertex.description == "E2E vertex description" + assert vertex.use_as_endpoint is True + assert vertex.active is True + assert vertex.sips_mode == "SIPSAuto" + assert vertex.control_props is not None + assert vertex.control_props.configPriority == "high" + assert vertex.control_props.onlyInitial is True + assert alert_filter in vertex.extra_alert_filters + assert vertex.custom.get("e2e-param") == "e2e-value" + assert vertex.park_port == 7 + assert vertex.vertex_kind == "router" + assert vertex.type_fields is not None and vertex.type_fields.type == "router" + + +def test_device_placement(app: VideoIPathApp, topology_builder: TopologyBuilder) -> None: + (device_id,) = topology_builder.add_devices([("PLACE-A", 2)]) + x, y = topology_builder.origin[0] + 150, topology_builder.origin[1] + 150 + app.inspect.place_device(device_id, x, y) + app.inspect.refresh() + coords = app.inspect.get_device(device_id).coordinates + assert coords is not None and coords["x"] == x and coords["y"] == y + + +def test_disconnect_reconnect_cycle(app: VideoIPathApp, topology_builder: TopologyBuilder) -> None: + id_a, id_b = topology_builder.add_devices([("CYC-A", 2), ("CYC-B", 2)]) + topology_builder.link(id_a, id_b) + app.inspect.refresh() + directed = [tuple(edge.id.split("::", 1)) for edge in edges_between(app, id_a, id_b)] + assert directed + for from_vertex, to_vertex in directed: + app.inspect.disconnect(from_vertex, to_vertex, bidirectional=False) + app.inspect.refresh() + assert not edges_between(app, id_a, id_b) + for from_vertex, to_vertex in directed: + app.inspect.connect(from_vertex, to_vertex, bidirectional=False) + app.inspect.refresh() + assert len(edges_between(app, id_a, id_b)) == len(directed) + + +def test_full_vs_skeleton_equivalence(app: VideoIPathApp, topology_builder: TopologyBuilder) -> None: + id_a, id_b, id_c = topology_builder.add_devices([("EQ-A", 2), ("EQ-B", 2), ("EQ-C", 2)]) + topology_builder.link(id_a, id_b) + topology_builder.link(id_b, id_c) + + def graph_view() -> dict[str, tuple[str | None, frozenset[str | None]]]: + return { + device_id: ( + app.inspect.get_device(device_id).label, + frozenset(device.label for device in app.inspect.get_device(device_id).linked_devices), + ) + for device_id in (id_a, id_b, id_c) + } + + try: + app.inspect.refresh(load="skeleton") + app.inspect.preload([id_a, id_b, id_c]) + skeleton = graph_view() + app.inspect.refresh(load="full") + full = graph_view() + assert skeleton == full + finally: + app.inspect.refresh(load="skeleton") + + +def test_create_virtual_device(app: VideoIPathApp, e2e_map_origins) -> None: + templates = app.inspect.list_port_templates() + if not templates: + pytest.skip("No port templates available on this server.") + by_id = {template.id: template for template in templates} + # Prefer a known pair from the docs example; otherwise pick any two templates. + if "ip_in" in by_id and "ip_out" in by_id: + spec = VirtualDeviceSpec.from_ports(("ip_in", 1), ("ip_out", 1)) + else: + first = templates[0] + second = templates[1] if len(templates) > 1 else templates[0] + spec = VirtualDeviceSpec.from_ports((first.id, 1), (second.id, 1)) + + device = app.inspect.create_virtual_device(spec) + assert device.is_virtual is True + label = unique_label("VIRTUAL") + device.label = label + device.tags = [E2E_TAG, "virtual"] + app.inspect.update(device) + x, y = next(e2e_map_origins) + app.inspect.place_device(device.id, x=x, y=y) + app.inspect.refresh() + loaded = app.inspect.get_device(device.id) + assert loaded is not None + assert loaded.label == label + assert loaded.coordinates is not None + assert loaded.coordinates["x"] == x and loaded.coordinates["y"] == y diff --git a/tests/e2e/apps/test_inventory.py b/tests/e2e/apps/test_inventory.py new file mode 100644 index 0000000..163f16c --- /dev/null +++ b/tests/e2e/apps/test_inventory.py @@ -0,0 +1,95 @@ +"""Focused Inventory app suite: create → get → diff → update → clone → enable/disable. + +Ordered ``@pytest.mark.incremental`` steps mirror ``docs/examples/02_inventory/``. Devices use the +mock driver and the ``E2E-`` namespace; they persist until the next e2e session-start sweep. + +Run with:: + + poetry run test-e2e +""" + +from __future__ import annotations + +from typing import Iterator + +import pytest +from pydantic import BaseModel + +from videoipath_automation_tool.apps.videoipath_app import VideoIPathApp + +from ..helpers import MOCK_DRIVER, create_mock_device, unique_label + +pytestmark = pytest.mark.e2e + + +class InventoryState(BaseModel): + device_ids: list[str] = [] + label: str | None = None + address: str | None = None + + +@pytest.fixture(scope="class") +def state() -> InventoryState: + return InventoryState() + + +@pytest.mark.incremental +class TestInventoryLifecycle: + def test_create_and_add(self, app: VideoIPathApp, state: InventoryState, e2e_addresses: Iterator[str]) -> None: + state.label = unique_label("INV") + state.address = next(e2e_addresses) + device_id = create_mock_device(app, label=state.label, address=state.address, ports=2) + state.device_ids.append(device_id) + assert device_id + + def test_get_by_label_and_id(self, app: VideoIPathApp, state: InventoryState) -> None: + assert state.label is not None + device_id = state.device_ids[0] + found = app.inventory.find_device_id_by_label(state.label, label_search_mode="user_defined_label_only") + assert found == device_id + by_id = app.inventory.get_device(device_id=device_id, config_only=True, custom_settings_type=MOCK_DRIVER) + by_label = app.inventory.get_device( + label=state.label, label_search_mode="user_defined_label_only", config_only=True + ) + assert by_id.configuration.label == state.label + assert by_label.configuration.device_id == device_id + + def test_diff_unchanged(self, app: VideoIPathApp, state: InventoryState) -> None: + device_id = state.device_ids[0] + reference = app.inventory.get_device(device_id=device_id, config_only=True, custom_settings_type=MOCK_DRIVER) + staged = app.inventory.get_device(device_id=device_id, config_only=True, custom_settings_type=MOCK_DRIVER) + diff = app.inventory.diff_device_configuration(reference_device=reference, staged_device=staged) + assert not diff.configuration_diff.added + assert not diff.configuration_diff.changed + assert not diff.configuration_diff.removed + + def test_update_description(self, app: VideoIPathApp, state: InventoryState) -> None: + device_id = state.device_ids[0] + reference = app.inventory.get_device(device_id=device_id, config_only=True, custom_settings_type=MOCK_DRIVER) + staged = app.inventory.get_device(device_id=device_id, config_only=True, custom_settings_type=MOCK_DRIVER) + staged.configuration.description = "E2E inventory lifecycle device" + diff = app.inventory.diff_device_configuration(reference_device=reference, staged_device=staged) + assert diff.configuration_diff.changed + app.inventory.update_device(device=staged) + updated = app.inventory.get_device(device_id=device_id, config_only=True) + assert updated.configuration.description == "E2E inventory lifecycle device" + + def test_dump_parse_clone(self, app: VideoIPathApp, state: InventoryState, e2e_addresses: Iterator[str]) -> None: + device_id = state.device_ids[0] + device = app.inventory.get_device(device_id=device_id, config_only=True, custom_settings_type=MOCK_DRIVER) + dump = app.inventory.dump_configuration(device) + clone = app.inventory.parse_configuration(dump) + clone.configuration.label = unique_label("INV-CLONE") + clone.configuration.address = next(e2e_addresses) + clone.remove_device_id() + cloned = app.inventory.add_device(device=clone, address_check=False) + state.device_ids.append(cloned.configuration.device_id) + assert cloned.configuration.device_id != device_id + assert cloned.configuration.label == clone.configuration.label + + def test_disable_and_enable(self, app: VideoIPathApp, state: InventoryState) -> None: + device_id = state.device_ids[0] + disabled = app.inventory.disable_device(device_id) + assert disabled.configuration.active is False + enabled = app.inventory.enable_device(device_id) + assert enabled.configuration.active is True diff --git a/tests/e2e/apps/test_preferences.py b/tests/e2e/apps/test_preferences.py new file mode 100644 index 0000000..edf1cf4 --- /dev/null +++ b/tests/e2e/apps/test_preferences.py @@ -0,0 +1,46 @@ +"""Focused Preferences app suite: system info reads + multicast pool lifecycle. + +Mirrors ``docs/examples/05_administration/03_multicast_pools.py``. The multicast pool uses a unique +``E2E-`` name and is left in place; the next e2e session-start sweep removes it. + +Run with:: + + poetry run test-e2e +""" + +from __future__ import annotations + +import pytest + +from videoipath_automation_tool.apps.videoipath_app import VideoIPathApp + +from ..helpers import unique_name + +pytestmark = pytest.mark.e2e + + +def test_read_system_network_info(app: VideoIPathApp) -> None: + network = app.preferences.system_configuration.network + hostname = network.get_hostname() + assert isinstance(hostname, str) and hostname + interfaces = network.get_all_interfaces() + assert isinstance(interfaces, list) + dns_servers = network.get_dns_servers() + assert isinstance(dns_servers, list) + + +def test_multicast_pool_lifecycle(app: VideoIPathApp) -> None: + pools = app.preferences.system_configuration.allocation_pools + existing = pools.get_multicast_ranges() + assert isinstance(existing.available_ranges, list) + + pool_name = unique_name("pool") + staged = pools.create_multicast_range(name=pool_name, start_ip="239.99.0.0", end_ip="239.99.0.255") + pools.add_multicast_range(staged) + assert pool_name in pools.get_multicast_ranges().available_ranges + + pool = pools.get_multicast_range_by_name(pool_name) + pool.add_ip_range(start_ip="239.99.1.0", end_ip="239.99.1.255") + pools.update_multicast_range(pool) + refreshed = pools.get_multicast_range_by_name(pool_name) + assert len(refreshed.ranges) == 2 diff --git a/tests/e2e/apps/test_profile.py b/tests/e2e/apps/test_profile.py new file mode 100644 index 0000000..36b9b87 --- /dev/null +++ b/tests/e2e/apps/test_profile.py @@ -0,0 +1,41 @@ +"""Focused Profile app suite: create and clone an ``E2E-`` profile. + +Mirrors ``docs/examples/05_administration/02_profiles.py``. Profiles are left in place; the next e2e +session-start sweep removes ``E2E-`` profiles. + +Run with:: + + poetry run test-e2e +""" + +from __future__ import annotations + +import pytest + +from videoipath_automation_tool.apps.videoipath_app import VideoIPathApp + +from ..helpers import unique_name + +pytestmark = pytest.mark.e2e + + +def test_profile_create_and_clone(app: VideoIPathApp) -> None: + names_before = app.profile.list_profile_names() or [] + assert isinstance(names_before, list) + + profile_name = unique_name("profile") + created = app.profile.create_profile(name=profile_name) + created = app.profile.add_profile(created) + assert created.name == profile_name + names = app.profile.list_profile_names() or [] + assert profile_name in names + + fetched = app.profile.get_profile_by_name(profile_name) + assert fetched is not None + source = fetched[0] if isinstance(fetched, list) else fetched + + clone = app.profile.clone_profile(source) + clone = app.profile.add_profile(clone) + assert clone.name.endswith("(clone)") + clone_names = app.profile.list_profile_names() or [] + assert clone.name in clone_names diff --git a/tests/e2e/apps/test_security.py b/tests/e2e/apps/test_security.py new file mode 100644 index 0000000..2991540 --- /dev/null +++ b/tests/e2e/apps/test_security.py @@ -0,0 +1,42 @@ +"""Focused Security app suite: domain create + device membership assign/clear. + +Mirrors ``docs/examples/05_administration/01_security_domains_and_memberships.py``. Uses a +``topology_builder`` device so memberships attach to a real inventory id. ``E2E-`` domains and +devices persist until the next e2e session-start sweep. + +Run with:: + + poetry run test-e2e +""" + +from __future__ import annotations + +import pytest + +from videoipath_automation_tool.apps.videoipath_app import VideoIPathApp + +from ..helpers import TopologyBuilder, unique_name + +pytestmark = pytest.mark.e2e + + +def test_domain_and_membership_lifecycle(app: VideoIPathApp, topology_builder: TopologyBuilder) -> None: + (device_id,) = topology_builder.add_devices([("SEC-A", 2)]) + domain_name = unique_name("domain") + domain = app.security.domains.create_domain(name=domain_name, description="E2E security domain") + assert domain.name == domain_name + assert domain_name in app.security.domains.list_domain_names() + + memberships = app.security.resources.get_device_memberships(device_id=device_id) + memberships.domains = app.security.resources.convert_domain_names_to_ids([domain_name]) + app.security.resources.update_memberships(memberships=memberships) + + memberships = app.security.resources.get_device_memberships(device_id=device_id) + names = set(app.security.resources.convert_domain_ids_to_names(memberships.domains)) + assert domain_name in names + + memberships = app.security.resources.get_device_memberships(device_id=device_id) + memberships.domains = [] + app.security.resources.update_memberships(memberships=memberships) + memberships = app.security.resources.get_device_memberships(device_id=device_id) + assert memberships.domains == [] diff --git a/tests/e2e/apps/test_topology.py b/tests/e2e/apps/test_topology.py new file mode 100644 index 0000000..bc5f8c0 --- /dev/null +++ b/tests/e2e/apps/test_topology.py @@ -0,0 +1,66 @@ +"""Focused Topology app suite (legacy path), skipped when VideoIPath major > 2025. + +Builds a device via Inspect (``topology_builder``), then exercises the classic Topology API: +``get_device``, ``find_device_id_by_label``, and a label / position round-trip. TopologyApp is +deprecated on 2025.x and unsupported above 2025 — prefer Inspect elsewhere. The e2e conftest +skips this module when the live server major year is greater than 2025. + +Run with:: + + poetry run test-e2e +""" + +from __future__ import annotations + +import pytest + +from videoipath_automation_tool.apps.topology.errors import TopologyUnsupportedError +from videoipath_automation_tool.apps.topology.topology_app import TopologyApp +from videoipath_automation_tool.apps.videoipath_app import VideoIPathApp + +from ..helpers import TopologyBuilder + +pytestmark = pytest.mark.e2e + + +@pytest.fixture +def topology(app: VideoIPathApp) -> TopologyApp: + try: + return app.topology + except TopologyUnsupportedError as exc: + pytest.skip(str(exc)) + + +def test_get_device_and_find_by_label( + app: VideoIPathApp, topology: TopologyApp, topology_builder: TopologyBuilder +) -> None: + (device_id,) = topology_builder.add_devices([("TOPO-A", 2)]) + label = topology_builder.labels[device_id] + device = topology.get_device(device_id) + assert device.configuration.base_device.id == device_id + found = topology.find_device_id_by_label(label, label_search_mode="user_defined_label_only") + assert found == device_id + + +def test_update_label_and_position( + app: VideoIPathApp, topology: TopologyApp, topology_builder: TopologyBuilder +) -> None: + (device_id,) = topology_builder.add_devices([("TOPO-B", 2)]) + device = topology.get_device(device_id) + new_label = topology_builder.labels[device_id] + "-MOVED" + x, y = topology_builder.origin[0] + 150, topology_builder.origin[1] + 150 + device.configuration.label = new_label + device.configuration.position_x = x + device.configuration.position_y = y + updated = topology.update_device(device) + assert updated.configuration.label == new_label + assert updated.configuration.position_x == x + assert updated.configuration.position_y == y + + app.inspect.refresh() + inspect_device = app.inspect.get_device(device_id) + assert inspect_device is not None + assert inspect_device.label == new_label + assert inspect_device.coordinates is not None + assert inspect_device.coordinates["x"] == x + assert inspect_device.coordinates["y"] == y diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py new file mode 100644 index 0000000..ca345d9 --- /dev/null +++ b/tests/e2e/conftest.py @@ -0,0 +1,140 @@ +"""Gating and shared fixtures for the developer-run live-server E2E suite. + +These tests are excluded by default (``-m "not e2e"`` in ``pyproject.toml``) and only run when: + * you invoke an e2e entry point (``poetry run test-e2e``, ``poetry run test``, or VS Code **E2E Tests**), **and** + * connection vars are set in the project root ``.env`` (copy from ``.env.template``). + +Version gates (by VideoIPath major year): + * Topology e2e (``apps/test_topology.py``) is skipped when major > 2025. + * Inspect e2e (``apps/test_inspect.py`` and ``workflows/``) is skipped when major < 2025. + +E2e entry points load ``.env`` and enable the suite automatically. E2e runs never collect coverage +(``--no-cov``). + +Layout of the suite: + * ``workflows/`` — general, ordered "build the scenario step by step" suites: the generic + network-builder (one suite per :mod:`networks` architecture) and the cross-app onboarding pipeline. + * ``apps/`` — focused per-app suites (inventory, inspect, topology, preferences, profile, security). + +Everything the suite writes is namespaced (``E2E-`` label prefix + ``vipat-e2e`` tag) so a shared +local instance is safe. Cleanup is a single session-start sweep that removes every ``E2E-`` +artifact left from a prior run; suites intentionally leave their topologies (including the +network-builder architectures) in VideoIPath for manual inspection after the run. +""" + +from __future__ import annotations + +import os +from itertools import count +from pathlib import Path +from typing import Iterator, Optional + +import pytest + +from videoipath_automation_tool.apps.inspect.app.app import _parse_version +from videoipath_automation_tool.apps.videoipath_app import VideoIPathApp +from vipat_cli_scripts.project_env import load_project_env + +from .helpers import TopologyBuilder, sweep_e2e_namespace + +load_project_env() + +_STEP_FAILED_KEY = pytest.StashKey[str]() + +# TopologyApp is unsupported above this major year; InspectApp is the replacement. +_TOPOLOGY_MAX_MAJOR = 2025 +# Inspect e2e requires this major year or newer. +_INSPECT_MIN_MAJOR = 2025 + + +def _e2e_enabled() -> bool: + return os.environ.get("VIPAT_E2E_ENABLED", "").strip() == "1" + + +def _server_major(app: VideoIPathApp) -> Optional[int]: + parsed = _parse_version(app._videoipath_connector.videoipath_version) + return parsed[0] if parsed is not None else None + + +@pytest.fixture(scope="session") +def app() -> VideoIPathApp: + """A live ``VideoIPathApp`` built from the project ``.env``; skips unless E2E is enabled.""" + load_project_env() + if not _e2e_enabled(): + pytest.skip("E2E disabled (use poetry run test-e2e, poetry run test, or the VS Code E2E launch config).") + return VideoIPathApp() + + +@pytest.fixture(autouse=True) +def _gate_topology_and_inspect_e2e(request: pytest.FixtureRequest, app: VideoIPathApp) -> None: + """Skip Topology/Inspect suites when the live server major year is out of range.""" + path = Path(str(request.path)) + major = _server_major(app) + if major is None: + return + + is_topology = path.name == "test_topology.py" + is_inspect = path.name == "test_inspect.py" or "workflows" in path.parts + version = app._videoipath_connector.videoipath_version + + if is_topology and major > _TOPOLOGY_MAX_MAJOR: + pytest.skip( + f"Topology e2e skipped on VideoIPath {version} (major > {_TOPOLOGY_MAX_MAJOR}). " + "Use InspectApp (app.inspect) instead." + ) + if is_inspect and major < _INSPECT_MIN_MAJOR: + pytest.skip(f"Inspect e2e skipped on VideoIPath {version} (major < {_INSPECT_MIN_MAJOR}).") + + +@pytest.fixture(scope="session", autouse=True) +def e2e_sweep(app: VideoIPathApp) -> None: + """Session-start sweep: remove every ``E2E-`` artifact left over from a prior run.""" + sweep_e2e_namespace(app) + + +@pytest.fixture(scope="session") +def e2e_addresses() -> Iterator[str]: + """Session-wide device address allocator (private ``10.99.0.0/16`` range), so addresses never collide.""" + return (f"10.99.{i // 256}.{i % 256}" for i in count(1)) + + +@pytest.fixture(scope="session") +def e2e_map_origins() -> Iterator[tuple[int, int]]: + """Session-wide map-origin allocator so per-test TopologyBuilder instances never stack. + + Laid out in a grid well clear of the workflow network-builder region (y >= 6000). + Each slot is large enough for a few devices spaced 300 apart horizontally. + """ + cols, slot_w, slot_h, base_x, base_y = 8, 1200, 800, 0, 2000 + return ((base_x + (i % cols) * slot_w, base_y + (i // cols) * slot_h) for i in count()) + + +@pytest.fixture +def topology_builder( + app: VideoIPathApp, e2e_addresses: Iterator[str], e2e_map_origins: Iterator[tuple[int, int]] +) -> TopologyBuilder: + """A per-test topology factory with a unique map origin (session sweep cleans ``E2E-`` artifacts).""" + x, y = next(e2e_map_origins) + return TopologyBuilder(app, e2e_addresses, x=x, y=y) + + +# --- Sequential suite support (``@pytest.mark.incremental``) --- +# Later steps of an ordered suite are skipped (not failed) once an earlier step fails. With the +# default ``-x`` in ``addopts`` the run stops at the first failure anyway; these hooks make the +# behavior sensible without it too (e.g. ``--maxfail=0``). Each test class has its own failure stash, +# so ordered suites (including the per-network builder subclasses) are isolated from one another. + + +def pytest_runtest_makereport(item: pytest.Item, call: pytest.CallInfo) -> None: + if "incremental" not in item.keywords or item.parent is None: + return + if call.excinfo is not None and not call.excinfo.errisinstance(pytest.skip.Exception): + item.parent.stash.setdefault(_STEP_FAILED_KEY, item.name) + + +def pytest_runtest_setup(item: pytest.Item) -> None: + if "incremental" not in item.keywords or item.parent is None: + return + failed = item.parent.stash.get(_STEP_FAILED_KEY, None) + if failed is not None: + pytest.skip(f"previous step in this suite failed ({failed})") diff --git a/tests/e2e/helpers.py b/tests/e2e/helpers.py new file mode 100644 index 0000000..ba3c4d4 --- /dev/null +++ b/tests/e2e/helpers.py @@ -0,0 +1,475 @@ +"""Shared helpers for the developer-run live-server E2E suite. + +Everything the suite writes is namespaced (``E2E-`` label prefix + ``vipat-e2e`` tag) so a shared +local instance stays safe. Cleanup is a single session-start :func:`sweep_e2e_namespace` that +removes **every** ``E2E-`` artifact left from a prior run (devices and their edges in both the +inventory and the Inspect graph, plus ``E2E-`` profiles, security domains, multicast pools, and e2e +catalog tags). Suites leave their topologies in place — including the network-builder architectures — +for manual inspection after the run. + +Devices are always virtual (mock-driver); no real hardware is involved. The build path is the real +user flow: **inventory** (create + add device) → **inspect** (add to topology graph, label + tag, +connect ports). +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Iterable, Iterator +from uuid import uuid4 + +import requests + +if TYPE_CHECKING: + from videoipath_automation_tool.apps.inspect.domain.device import InspectDevice + from videoipath_automation_tool.apps.inspect.domain.edge import InspectEdge + from videoipath_automation_tool.apps.videoipath_app import VideoIPathApp + + from .networks import Network + +E2E_PREFIX = "E2E-" +E2E_TAG = "vipat-e2e" +MOCK_DRIVER = "com.nevion.mock-0.1.0" + +# Catalog tags created via simple API requests for the Inspect tag tests. Tag references are +# ``~~``-joined ids under the default Format tree (e.g. Format~~Video~~E2E-VIDEO-TAG). +TEST_TAG_PATH = ("Format", "Video") +TEST_TAG_NAME = "E2E-VIDEO-TAG" +TEST_TAG_ID = "~~".join((*TEST_TAG_PATH, TEST_TAG_NAME)) + +MODULE_TEST_TAG_PATH = ("Format", "Video") +MODULE_TEST_TAG_NAME = "E2E-MODULE-TAG" +MODULE_TEST_TAG_ID = "~~".join((*MODULE_TEST_TAG_PATH, MODULE_TEST_TAG_NAME)) + + +def unique_label(base: str) -> str: + """A per-test unique device label, always under the ``E2E-`` prefix so the sweep catches orphans.""" + return f"{E2E_PREFIX}T-{uuid4().hex[:6].upper()}-{base}" + + +def unique_name(base: str) -> str: + """A per-test unique ``E2E-`` name for profiles / domains / pools so the sweep catches orphans.""" + return f"{E2E_PREFIX}{base}-{uuid4().hex[:6].upper()}" + + +# --- Device build path (inventory -> inspect) ------------------------------------------------------ + + +def create_mock_device(app: "VideoIPathApp", *, label: str, address: str, ports: int) -> str: + """Create a virtual (mock-driver) device in the inventory and return its device id. + + Mirrors the inventory example: create a device from a driver, set its typed ``custom_settings``, + and add it. The mock driver exposes one router module with ``ports`` router ports. + """ + device = app.inventory.create_device(driver=MOCK_DRIVER) + device.configuration.label = label + device.configuration.address = address + settings = device.configuration.custom_settings + settings.num_router_modules = 1 + settings.num_router_ports = ports + settings.num_codec_modules = 0 + online = app.inventory.add_device(device=device, address_check=False) + return online.configuration.device_id + + +def router_ports(device: "InspectDevice") -> list[tuple[str, str]]: + """Ordered ``(out-vertex-id, in-vertex-id)`` pairs for a mock router device, one per port slot. + + A mock router device exposes separate "Router Out N" and "Router In N" ports; this pairs them by + slot so a caller can wire the out side of one device to the in side of another. + """ + outs: list[str] = [] + ins: list[str] = [] + for port in device.ports: + label = port.label or "" + vertex = port.vertex_out or port.vertex_in + if vertex is None: + continue + if "Router Out" in label: + outs.append(vertex.id) + elif "Router In" in label: + ins.append(vertex.id) + outs.sort(key=_vertex_slot) + ins.sort(key=_vertex_slot) + return list(zip(outs, ins)) + + +class PortCursor: + """Hands out a device's router ports one slot at a time (the next free out/in vertex pair).""" + + def __init__(self, ports: list[tuple[str, str]]) -> None: + self._ports = ports + self._next = 0 + + def next_port(self) -> tuple[str, str]: + """Return the next unused ``(out-vertex-id, in-vertex-id)`` pair.""" + if self._next >= len(self._ports): + raise LookupError("No free router port left on this device.") + pair = self._ports[self._next] + self._next += 1 + return pair + + +def discover_port_cursors(app: "VideoIPathApp", device_ids: Iterable[str]) -> dict[str, PortCursor]: + """A next-free-port cursor per device: refresh the view, hydrate in one batch, read the ports.""" + ids = list(device_ids) + app.inspect.refresh() + app.inspect.preload(ids) + cursors: dict[str, PortCursor] = {} + for device_id in ids: + device = app.inspect.get_device(device_id) + if device is None: + raise LookupError(f"Device '{device_id}' not found in the inspect topology.") + cursors[device_id] = PortCursor(router_ports(device)) + return cursors + + +def edges_between(app: "VideoIPathApp", id_a: str, id_b: str) -> list["InspectEdge"]: + """All directed edges between two devices (either direction).""" + return [ + edge + for edge in app.inspect.edges + if edge.from_device and edge.to_device and {edge.from_device.id, edge.to_device.id} == {id_a, id_b} + ] + + +class NetworkBuild: + """Builds a :class:`Network` into a live topology step by step (the engine behind the builder suite). + + Each phase of the real user journey is one readable call, and a little state is kept between + phases: inventory create → onboard into the Inspect topology (at ``base position + offset``) → + label & tag → connect the links. Device labels are ``E2E--`` so they are unique + per network and caught by the session sweep. + """ + + def __init__( + self, + app: "VideoIPathApp", + network: "Network", + offset: tuple[int, int], + addresses: Iterator[str], + ) -> None: + self._app = app + self.network = network + self._offset = offset + self._addresses = addresses + self.device_ids: dict[str, str] = {} # spec name -> VideoIPath device id + + def label_of(self, name: str) -> str: + return f"{E2E_PREFIX}{self.network.name}-{name}" + + def create_devices(self) -> None: + for spec in self.network.devices: + self.device_ids[spec.name] = create_mock_device( + self._app, label=self.label_of(spec.name), address=next(self._addresses), ports=spec.ports + ) + + def add_to_topology(self) -> None: + dx, dy = self._offset + placements = [(self.device_ids[s.name], s.x + dx, s.y + dy) for s in self.network.devices] + self._app.inspect.add_devices_to_topology(placements) + + def label_and_tag(self) -> None: + with self._app.inspect.transaction() as tx: + for spec in self.network.devices: + tx.update_device(self.device_ids[spec.name], label=self.label_of(spec.name), tags=[E2E_TAG]) + tx.commit() + + def connect_links(self) -> None: + cursors = discover_port_cursors(self._app, self.device_ids.values()) + with self._app.inspect.transaction() as tx: + for link in self.network.links: + id_a = self.device_ids[self.network.devices[link.a].name] + id_b = self.device_ids[self.network.devices[link.b].name] + a_out, a_in = cursors[id_a].next_port() + b_out, b_in = cursors[id_b].next_port() + # A physical link is two directed edges: out of A -> in of B, and out of B -> in of A. + tx.connect(a_out, b_in, bidirectional=False) + tx.connect(b_out, a_in, bidirectional=False) + tx.commit() + + +class TopologyBuilder: + """Per-test factory for a small mock topology; mirrors the user flow and tracks created ids. + + ``add_devices`` onboards mock devices (inventory → Inspect topology → label + e2e tag); ``link`` + connects the next free router port of two devices bidirectionally (two directed edges). Devices + persist until the next e2e session-start sweep. + + Pass a unique ``(x, y)`` origin per test (via the session ``e2e_map_origins`` fixture) so + builders never stack on the same map coordinates. + """ + + def __init__(self, app: "VideoIPathApp", addresses: Iterator[str], *, x: int, y: int) -> None: + self._app = app + self._addresses = addresses + self.origin = (x, y) + self._x = x + self._y = y + self.device_ids: list[str] = [] + self.labels: dict[str, str] = {} # device id -> unique E2E label + self._cursors: dict[str, PortCursor] = {} + + def add_devices(self, specs: list[tuple[str, int]]) -> list[str]: + """Create mock devices from ``(base_label, ports)`` specs and add them to the topology graph. + + Labels are made unique per test via :func:`unique_label`; returns the new device ids. + """ + created: list[str] = [] + for base_label, ports in specs: + label = unique_label(base_label) + device_id = create_mock_device(self._app, label=label, address=next(self._addresses), ports=ports) + self.labels[device_id] = label + created.append(device_id) + offset = len(self.device_ids) + self._app.inspect.add_devices_to_topology( + [(device_id, self._x + (offset + i) * 300, self._y) for i, device_id in enumerate(created)] + ) + with self._app.inspect.transaction() as tx: + for device_id in created: + tx.update_device(device_id, label=self.labels[device_id], tags=[E2E_TAG]) + tx.commit() + self.device_ids.extend(created) + self._cursors.clear() # ports changed; rediscover before the next link + return created + + def link(self, id_a: str, id_b: str) -> None: + """Connect the next free port pair of two devices bidirectionally (two directed edges).""" + if not self._cursors: + self._cursors = discover_port_cursors(self._app, self.device_ids) + a_out, a_in = self._cursors[id_a].next_port() + b_out, b_in = self._cursors[id_b].next_port() + with self._app.inspect.transaction() as tx: + tx.connect(a_out, b_in, bidirectional=False) + tx.connect(b_out, a_in, bidirectional=False) + tx.commit() + + +# --- Cleanup --------------------------------------------------------------------------------------- + + +def remove_devices(app: "VideoIPathApp", device_ids: set[str]) -> None: + """Remove the given devices — edges first, then the topology node, then the inventory entry. + + A device lives in two places — the inventory and the Inspect topology graph — and removing it + from one does not remove it from the other. Used by the session-start sweep; best-effort so + cleanup errors never abort the suite. + """ + if not device_ids: + return + app.inspect.refresh() + edge_ids = [ + edge.id + for edge in app.inspect.edges + if (edge.from_device and edge.from_device.id in device_ids) + or (edge.to_device and edge.to_device.id in device_ids) + ] + if edge_ids: + with app.inspect.transaction() as tx: + for edge_id in edge_ids: + tx.remove(edge_id) + tx.commit(check_conflicts=False) + topology_ids = {d.id for d in app.inspect.devices} & device_ids + for device_id in topology_ids: + try: + app.inspect.remove_device_from_topology(device_id) + except Exception: # best-effort cleanup + pass + for device_id in device_ids: + try: + app.inventory.remove_device(device_id=device_id, check_remove=False) + except Exception: # best-effort cleanup + pass + + +def sweep_e2e_namespace(app: "VideoIPathApp") -> None: + """Remove every ``E2E-`` artifact so a run starts from a clean namespace (best-effort). + + Covers devices (in both the inventory and the topology graph, catching orphans from an aborted + run as well as an intentionally persisted build), plus ``E2E-`` profiles, security domains, + multicast pools, and the e2e catalog tags. + """ + delete_test_tag(app) + delete_module_test_tag(app) + + inventory_labels = app.inventory._inventory_api.fetch_devices_user_defined_labels_as_dict() + inventory_ids = {i for i, label in inventory_labels.items() if (label or "").startswith(E2E_PREFIX)} + app.inspect.refresh() + topology_ids = {d.id for d in app.inspect.devices if (d.label or "").startswith(E2E_PREFIX)} + remove_devices(app, inventory_ids | topology_ids) + + _sweep_profiles(app) + _sweep_domains(app) + _sweep_multicast_pools(app) + + +def _sweep_profiles(app: "VideoIPathApp") -> None: + try: + profiles = app.profile.get_profiles() or [] + for profile in profiles: + if (profile.name or "").startswith(E2E_PREFIX): + app.profile.remove_profile(profile=profile) + except Exception: # best-effort cleanup + pass + + +def _sweep_domains(app: "VideoIPathApp") -> None: + try: + for domain in app.security.domains.get_all_domains(): + if (domain.name or "").startswith(E2E_PREFIX): + app.security.domains.remove_domain(domain) + except Exception: # best-effort cleanup + pass + + +def _sweep_multicast_pools(app: "VideoIPathApp") -> None: + try: + pools = app.preferences.system_configuration.allocation_pools.get_multicast_ranges() + e2e_pools = [name for name in pools.available_ranges if name.startswith(E2E_PREFIX)] + if e2e_pools: + app.preferences.system_configuration.allocation_pools.remove_multicast_range(e2e_pools) + except Exception: # best-effort cleanup + pass + + +class FetchSpy: + """Wraps ``get_device_detail`` to count per-device hydration fetches.""" + + def __init__(self, api: Any) -> None: + self._api = api + self._orig = api.get_device_detail + self.count = 0 + + def __enter__(self) -> "FetchSpy": + def counting(device_id: str) -> Any: + self.count += 1 + return self._orig(device_id) + + self._api.get_device_detail = counting + return self + + def __exit__(self, *exc: object) -> None: + self._api.get_device_detail = self._orig + + +# --- Test tag catalog (simple API requests) -------------------------------------------------------- + + +def create_catalog_tag(app: "VideoIPathApp", *, path: tuple[str, ...], name: str) -> str: + """Create a catalog tag under ``path`` (idempotent). Returns the ``~~``-joined id. + + ``path`` is ``(root_tree_id, *intermediate_node_names)``, e.g. ``(\"Format\", \"Video\")``. + """ + if not path: + raise ValueError("path must include at least the root tag tree id") + root_id, *intermediates = path + cat = _tag_category(app, root_id) + if cat is None: + raise RuntimeError(f"Tag category '{root_id}' not found on the server.") + + children = dict(cat.get("children") or {}) + parent_children = children + for segment in intermediates: + if segment not in parent_children: + raise RuntimeError(f"Tag path segment '{segment}' not found under '{root_id}'.") + node = dict(parent_children[segment]) + node_children = dict(node.get("children") or {}) + node["children"] = node_children + parent_children[segment] = node + parent_children = node_children + + parent_children[name] = {"_id": name, "exclusive": False, "children": {}, "color": ""} + body = { + "actions": [ + { + "_action": "update", + "_id": root_id, + "_rev": cat["_rev"], + "children": children, + "type": cat.get("type", "format"), + "exclusive": cat.get("exclusive", False), + "formatTagLinks": cat.get("formatTagLinks", {}), + "locationTypes": cat.get("locationTypes", []), + } + ] + } + _raw_request(app, "patch", "/rest/v2/data/config/tags/tagTrees", body) + return "~~".join((*path, name)) + + +def catalog_tag_exists(app: "VideoIPathApp", *, path: tuple[str, ...], name: str) -> bool: + if not path: + return False + root_id, *intermediates = path + cat = _tag_category(app, root_id) + if cat is None: + return False + node_children: dict[str, Any] = cat.get("children") or {} + for segment in intermediates: + if segment not in node_children: + return False + node_children = node_children[segment].get("children") or {} + return name in node_children + + +def delete_catalog_tag(app: "VideoIPathApp", tag_id: str) -> None: + """Force-delete a catalog tag (removes it and any resource bindings) if it exists.""" + parts = tag_id.split("~~") + if len(parts) < 2: + return + path = tuple(parts[:-1]) + name = parts[-1] + if not catalog_tag_exists(app, path=path, name=name): + return + _raw_request( + app, + "post", + "/rest/v2/actions/status/tags/forceDeleteTag", + {"header": {"id": 0}, "data": {"tagId": tag_id}}, + ) + + +def create_test_tag(app: "VideoIPathApp") -> None: + """Create the E2E port/vertex test video tag in the catalog (idempotent).""" + create_catalog_tag(app, path=TEST_TAG_PATH, name=TEST_TAG_NAME) + + +def delete_test_tag(app: "VideoIPathApp") -> None: + """Force-delete the port/vertex test tag (removes it and any port bindings) if it exists.""" + delete_catalog_tag(app, TEST_TAG_ID) + + +def create_module_test_tag(app: "VideoIPathApp") -> None: + """Create the E2E module test tag in the catalog (idempotent).""" + create_catalog_tag(app, path=MODULE_TEST_TAG_PATH, name=MODULE_TEST_TAG_NAME) + + +def delete_module_test_tag(app: "VideoIPathApp") -> None: + """Force-delete the module test tag (removes it and any module bindings) if it exists.""" + delete_catalog_tag(app, MODULE_TEST_TAG_ID) + + +# --- Internal -------------------------------------------------------------------------------------- + + +def _vertex_slot(vertex_id: str) -> int: + """Sort key: the trailing integer of a mock router vertex id (``device59.11.7`` -> 7).""" + return int(vertex_id.rsplit(".", 1)[-1]) + + +def _raw_request(app: "VideoIPathApp", method: str, path: str, body: dict[str, Any]) -> requests.Response: + """A minimal authenticated REST call (for tag-catalog management, which the package's connector + allow-list intentionally does not cover).""" + rc = app._videoipath_connector.rest + response = getattr(requests, method)( + rc._build_url(path), json=body, auth=(rc._username, rc._password), verify=rc.verify_ssl_cert + ) + response.raise_for_status() + return response + + +def _tag_category(app: "VideoIPathApp", category: str) -> dict[str, Any] | None: + trees = app._videoipath_connector.rest.get("/rest/v2/data/config/tags/tagTrees/**") + for item in trees.data["config"]["tags"]["tagTrees"].get("_items", []): + if item.get("_id") == category: + return item + return None diff --git a/tests/e2e/networks.py b/tests/e2e/networks.py new file mode 100644 index 0000000..f263ad8 --- /dev/null +++ b/tests/e2e/networks.py @@ -0,0 +1,91 @@ +"""Declarative network definitions for the generic e2e network-builder suite. + +A :class:`Network` is a small, readable description of an architecture — named devices with a grid +position, plus the links between them. The builder suite in ``workflows/test_build_networks.py`` +turns any network into a live VideoIPath topology: it creates the devices in the inventory, adds +them to the Inspect topology at ``base position + offset``, labels and tags them, then connects the +links. + +Define a new architecture by adding a ``Network`` here (via :func:`build_network`) and a three-line +``Test*`` subclass in the builder suite — each network then builds as its own ordered test suite at +its own map offset, so several networks can coexist on a shared instance without colliding. + +Built networks stay in VideoIPath after the run. The next e2e session starts with a sweep that +removes every ``E2E-`` artifact before rebuilding. +""" + +from __future__ import annotations + +from collections import defaultdict +from dataclasses import dataclass + + +@dataclass(frozen=True) +class DeviceSpec: + """A device in a network: a unique name, its router-port count, and a relative grid position.""" + + name: str + ports: int + x: int + y: int + + +@dataclass(frozen=True) +class LinkSpec: + """A bidirectional link between two devices, referenced by their index in ``Network.devices``. + + Repeat a pair to model parallel links between the same two devices. + """ + + a: int + b: int + + +@dataclass(frozen=True) +class Network: + """A named architecture: the devices to build and the links to connect between them.""" + + name: str + devices: list[DeviceSpec] + links: list[LinkSpec] + + def index_of(self, name: str) -> int: + for i, device in enumerate(self.devices): + if device.name == name: + return i + raise KeyError(f"Device '{name}' is not part of network '{self.name}'.") + + def neighbours(self) -> dict[str, set[str]]: + """Undirected neighbour-name set per device (derived from the links).""" + adjacency: dict[str, set[str]] = {device.name: set() for device in self.devices} + for link in self.links: + a, b = self.devices[link.a].name, self.devices[link.b].name + adjacency[a].add(b) + adjacency[b].add(a) + return adjacency + + def parallel_count(self, link: LinkSpec) -> int: + """How many links connect the same device pair as ``link`` (1 unless there are parallels).""" + return sum(1 for other in self.links if {other.a, other.b} == {link.a, link.b}) + + +def build_network( + name: str, + *, + devices: list[tuple[str, int, int]], + links: list[tuple[str, str]], +) -> Network: + """Build a :class:`Network` from readable ``(name, x, y)`` devices and ``(name_a, name_b)`` links. + + Each device's router-port count is sized automatically to its link degree (repeat a link pair to + add a parallel link, which also grows the port count), so definitions stay declarative and + self-consistent. + """ + index = {device_name: i for i, (device_name, _, _) in enumerate(devices)} + degree: dict[str, int] = defaultdict(int) + for a, b in links: + degree[a] += 1 + degree[b] += 1 + device_specs = [DeviceSpec(name=n, ports=max(1, degree[n]), x=x, y=y) for n, x, y in devices] + link_specs = [LinkSpec(a=index[a], b=index[b]) for a, b in links] + return Network(name=name, devices=device_specs, links=link_specs) diff --git a/tests/e2e/workflows/__init__.py b/tests/e2e/workflows/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/e2e/workflows/test_build_networks.py b/tests/e2e/workflows/test_build_networks.py new file mode 100644 index 0000000..199f575 --- /dev/null +++ b/tests/e2e/workflows/test_build_networks.py @@ -0,0 +1,233 @@ +"""Generic ordered network-builder suite: one Test* subclass per architecture. + +Each subclass pairs a :class:`~tests.e2e.networks.Network` with a map ``offset``. The base +:class:`NetworkBuildSuite` turns that into a live VideoIPath topology step by step: + + connect → create inventory devices → add to topology → label & tag → connect links → verify + +Define a new architecture by adding a ``Network`` in ``tests/e2e/networks.py`` and a three-line +``Test*`` subclass here. Each network builds as its own independent suite at its own map region. + +Built networks are left in VideoIPath for manual inspection. The next e2e run's session-start sweep +removes every ``E2E-`` artifact before rebuilding. + +Run with:: + + poetry run test-e2e +""" + +from __future__ import annotations + +from typing import Iterator + +import pytest + +from videoipath_automation_tool.apps.videoipath_app import VideoIPathApp + +from ..helpers import E2E_TAG, NetworkBuild, edges_between +from ..networks import Network, build_network + +pytestmark = pytest.mark.e2e + + +# --- Concrete networks ----------------------------------------------------------------------------- +# Each is intentionally small and easy to picture; every ``Test*`` subclass in the builder suite +# pairs one of these with a distinct map offset. + +LINE = build_network( + "line", + devices=[("line-a", 0, 0), ("line-b", 300, 0), ("line-c", 600, 0)], + links=[("line-a", "line-b"), ("line-b", "line-c")], +) + +RING = build_network( + "ring", + devices=[("ring-1", 0, 0), ("ring-2", 300, 0), ("ring-3", 300, 300), ("ring-4", 0, 300)], + links=[("ring-1", "ring-2"), ("ring-2", "ring-3"), ("ring-3", "ring-4"), ("ring-4", "ring-1")], +) + +STAR = build_network( + "star", + devices=[("hub", 300, 300), ("spoke-1", 0, 0), ("spoke-2", 600, 0), ("spoke-3", 0, 600), ("spoke-4", 600, 600)], + links=[("hub", "spoke-1"), ("hub", "spoke-2"), ("hub", "spoke-3"), ("hub", "spoke-4")], +) + +# 2-tier spine-leaf (Clos): every leaf meshed to every spine; endpoints attach to leaf pairs +# (dual-homed on the first two pairs, single-homed on the third). +SPINE_LEAF_2_TIER = build_network( + "spine-leaf-2tier", + devices=[ + ("spine-1", 500, 0), + ("spine-2", 1100, 0), + ("leaf-1", 0, 400), + ("leaf-2", 300, 400), + ("leaf-3", 600, 400), + ("leaf-4", 900, 400), + ("leaf-5", 1200, 400), + ("leaf-6", 1500, 400), + ("endpoint-1", 150, 800), + ("endpoint-2", 750, 800), + ("endpoint-3", 1200, 800), + ("endpoint-4", 1500, 800), + ], + links=[ + # Full mesh: leaf ↔ spine + ("leaf-1", "spine-1"), + ("leaf-1", "spine-2"), + ("leaf-2", "spine-1"), + ("leaf-2", "spine-2"), + ("leaf-3", "spine-1"), + ("leaf-3", "spine-2"), + ("leaf-4", "spine-1"), + ("leaf-4", "spine-2"), + ("leaf-5", "spine-1"), + ("leaf-5", "spine-2"), + ("leaf-6", "spine-1"), + ("leaf-6", "spine-2"), + # Endpoints + ("endpoint-1", "leaf-1"), + ("endpoint-1", "leaf-2"), + ("endpoint-2", "leaf-3"), + ("endpoint-2", "leaf-4"), + ("endpoint-3", "leaf-5"), + ("endpoint-4", "leaf-6"), + ], +) + +# Traditional 3-tier: core ↔ aggregation (full mesh) + overlapping aggregation↔access pairs + dual-homed endpoints. +SPINE_LEAF_3_TIER = build_network( + "spine-leaf-3tier", + devices=[ + ("core-1", 450, 0), + ("core-2", 1050, 0), + ("agg-1", 0, 400), + ("agg-2", 500, 400), + ("agg-3", 1000, 400), + ("agg-4", 1500, 400), + ("access-1", 0, 800), + ("access-2", 300, 800), + ("access-3", 600, 800), + ("access-4", 900, 800), + ("access-5", 1200, 800), + ("access-6", 1500, 800), + ("endpoint-1", 150, 1200), + ("endpoint-2", 750, 1200), + ("endpoint-3", 1350, 1200), + ], + links=[ + ("core-1", "core-2"), + # Full mesh: core ↔ aggregation + ("core-1", "agg-1"), + ("core-1", "agg-2"), + ("core-1", "agg-3"), + ("core-1", "agg-4"), + ("core-2", "agg-1"), + ("core-2", "agg-2"), + ("core-2", "agg-3"), + ("core-2", "agg-4"), + # Overlapping access pairs → aggregation pairs + ("access-1", "agg-1"), + ("access-1", "agg-2"), + ("access-2", "agg-1"), + ("access-2", "agg-2"), + ("access-3", "agg-2"), + ("access-3", "agg-3"), + ("access-4", "agg-2"), + ("access-4", "agg-3"), + ("access-5", "agg-3"), + ("access-5", "agg-4"), + ("access-6", "agg-3"), + ("access-6", "agg-4"), + # Dual-homed endpoints + ("endpoint-1", "access-1"), + ("endpoint-1", "access-2"), + ("endpoint-2", "access-3"), + ("endpoint-2", "access-4"), + ("endpoint-3", "access-5"), + ("endpoint-3", "access-6"), + ], +) + + +@pytest.fixture(scope="class") +def build(request: pytest.FixtureRequest, app: VideoIPathApp, e2e_addresses: Iterator[str]) -> Iterator[NetworkBuild]: + """Drive a :class:`NetworkBuild` for the requesting suite (no teardown — networks persist).""" + network: Network = request.cls.network + offset: tuple[int, int] = request.cls.offset + yield NetworkBuild(app, network, offset, e2e_addresses) + + +@pytest.mark.incremental +class NetworkBuildSuite: + """Base suite — not collected (no ``Test`` prefix). Subclasses set ``network`` and ``offset``.""" + + network: Network + offset: tuple[int, int] + + def test_connect(self, app: VideoIPathApp) -> None: + app.check_connection() + assert app.get_server_version() + + def test_create_inventory_devices(self, build: NetworkBuild) -> None: + build.create_devices() + assert len(build.device_ids) == len(build.network.devices) + + def test_add_to_topology(self, app: VideoIPathApp, build: NetworkBuild) -> None: + build.add_to_topology() + topology_ids = {device.id for device in app.inspect.devices} + assert set(build.device_ids.values()) <= topology_ids + + def test_label_and_tag(self, app: VideoIPathApp, build: NetworkBuild) -> None: + build.label_and_tag() + for spec in build.network.devices: + device = app.inspect.get_device(build.device_ids[spec.name]) + assert device is not None + assert device.label == build.label_of(spec.name) + assert E2E_TAG in device.tags + + def test_connect_links(self, app: VideoIPathApp, build: NetworkBuild) -> None: + build.connect_links() + for link in build.network.links: + id_a = build.device_ids[build.network.devices[link.a].name] + id_b = build.device_ids[build.network.devices[link.b].name] + pair_edges = edges_between(app, id_a, id_b) + assert len(pair_edges) == build.network.parallel_count(link) * 2 + + def test_verify_connectivity(self, app: VideoIPathApp, build: NetworkBuild) -> None: + app.inspect.refresh() + adjacency = build.network.neighbours() + for spec in build.network.devices: + device_id = build.device_ids[spec.name] + label = build.label_of(spec.name) + assert ( + app.inventory.find_device_id_by_label(label, label_search_mode="user_defined_label_only") == device_id + ) + device = app.inspect.get_device(device_id) + assert device is not None + expected = {build.label_of(neighbour) for neighbour in adjacency[spec.name]} + assert {linked.label for linked in device.linked_devices} == expected + + +class TestLineNetwork(NetworkBuildSuite): + network = LINE + offset = (0, 6000) + + +class TestRingNetwork(NetworkBuildSuite): + network = RING + offset = (2000, 6000) + + +class TestStarNetwork(NetworkBuildSuite): + network = STAR + offset = (0, 7000) + + +class TestSpineLeaf2TierNetwork(NetworkBuildSuite): + network = SPINE_LEAF_2_TIER + offset = (0, 8000) + + +class TestSpineLeaf3TierNetwork(NetworkBuildSuite): + network = SPINE_LEAF_3_TIER + offset = (2500, 8000) diff --git a/tests/e2e/workflows/test_onboarding_pipeline.py b/tests/e2e/workflows/test_onboarding_pipeline.py new file mode 100644 index 0000000..6035823 --- /dev/null +++ b/tests/e2e/workflows/test_onboarding_pipeline.py @@ -0,0 +1,122 @@ +"""Cross-app onboarding pipeline: inventory → Inspect topology → edges → security domains. + +Mirrors ``docs/examples/06_workflows/01_full_onboarding_pipeline.py`` on a small 2-leaf / 1-spine +network, as an ordered ``@pytest.mark.incremental`` suite. Reachability polling is omitted because +mock devices are not reachable. The built topology is left in VideoIPath; the next e2e session's +sweep removes ``E2E-`` artifacts. + +Run with:: + + poetry run test-e2e +""" + +from __future__ import annotations + +from typing import Iterator + +import pytest +from pydantic import BaseModel + +from videoipath_automation_tool.apps.videoipath_app import VideoIPathApp + +from ..helpers import E2E_TAG, create_mock_device, discover_port_cursors, unique_name +from ..networks import build_network + +pytestmark = pytest.mark.e2e + +PIPELINE = build_network( + "onboard", + devices=[("leaf-1", 0, 0), ("leaf-2", 600, 0), ("spine-1", 300, 400)], + links=[("leaf-1", "spine-1"), ("leaf-2", "spine-1")], +) +# Below the network-builder map regions (spine-leaf 3-tier reaches y≈9200 at offset (2500, 8000)). +OFFSET = (0, 10000) + +SITE_TAG = "site-a" + + +class PipelineState(BaseModel): + device_ids: dict[str, str] = {} + domain_name: str | None = None + + +@pytest.fixture(scope="class") +def state() -> PipelineState: + return PipelineState() + + +@pytest.mark.incremental +class TestOnboardingPipeline: + def test_connect(self, app: VideoIPathApp) -> None: + app.check_connection() + assert app.get_server_version() + + def test_create_inventory_devices( + self, app: VideoIPathApp, state: PipelineState, e2e_addresses: Iterator[str] + ) -> None: + for spec in PIPELINE.devices: + label = f"E2E-{PIPELINE.name}-{spec.name}" + state.device_ids[spec.name] = create_mock_device( + app, label=label, address=next(e2e_addresses), ports=spec.ports + ) + assert len(state.device_ids) == len(PIPELINE.devices) + + def test_add_to_topology(self, app: VideoIPathApp, state: PipelineState) -> None: + dx, dy = OFFSET + app.inspect.add_devices_to_topology( + [(state.device_ids[spec.name], spec.x + dx, spec.y + dy) for spec in PIPELINE.devices] + ) + topology_ids = {device.id for device in app.inspect.devices} + assert set(state.device_ids.values()) <= topology_ids + + def test_configure_devices(self, app: VideoIPathApp, state: PipelineState) -> None: + with app.inspect.transaction() as tx: + for spec in PIPELINE.devices: + device = app.inspect.get_device(state.device_ids[spec.name]) + assert device is not None + device.label = f"E2E-{PIPELINE.name}-{spec.name}" + device.description = f"Onboarding pipeline {spec.name}" + device.tags = [E2E_TAG, SITE_TAG] + tx.update(device) + tx.commit() + for spec in PIPELINE.devices: + device = app.inspect.get_device(state.device_ids[spec.name]) + assert device is not None + assert device.label == f"E2E-{PIPELINE.name}-{spec.name}" + assert SITE_TAG in device.tags + + def test_connect_links(self, app: VideoIPathApp, state: PipelineState) -> None: + cursors = discover_port_cursors(app, state.device_ids.values()) + with app.inspect.transaction() as tx: + for link in PIPELINE.links: + id_a = state.device_ids[PIPELINE.devices[link.a].name] + id_b = state.device_ids[PIPELINE.devices[link.b].name] + a_out, a_in = cursors[id_a].next_port() + b_out, b_in = cursors[id_b].next_port() + tx.connect(a_out, b_in, bidirectional=False) + tx.connect(b_out, a_in, bidirectional=False) + tx.commit() + + def test_assign_security_domains(self, app: VideoIPathApp, state: PipelineState) -> None: + state.domain_name = unique_name("domain") + app.security.domains.create_domain(name=state.domain_name, description="E2E onboarding pipeline domain") + for device_id in state.device_ids.values(): + memberships = app.security.resources.get_device_memberships(device_id=device_id) + memberships.domains = app.security.resources.convert_domain_names_to_ids([state.domain_name]) + app.security.resources.update_memberships(memberships=memberships) + + def test_verify_pipeline(self, app: VideoIPathApp, state: PipelineState) -> None: + app.inspect.refresh() + adjacency = PIPELINE.neighbours() + assert state.domain_name is not None + for spec in PIPELINE.devices: + device_id = state.device_ids[spec.name] + label = f"E2E-{PIPELINE.name}-{spec.name}" + device = app.inspect.get_device(device_id) + assert device is not None + assert device.label == label + expected = {f"E2E-{PIPELINE.name}-{neighbour}" for neighbour in adjacency[spec.name]} + assert {linked.label for linked in device.linked_devices} == expected + memberships = app.security.resources.get_device_memberships(device_id=device_id) + names = set(app.security.resources.convert_domain_ids_to_names(memberships.domains)) + assert state.domain_name in names diff --git a/tests/inspect/__init__.py b/tests/inspect/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/inspect/conftest.py b/tests/inspect/conftest.py new file mode 100644 index 0000000..5cfd95b --- /dev/null +++ b/tests/inspect/conftest.py @@ -0,0 +1,27 @@ +"""Shared fixtures for offline Inspect tests.""" + +from __future__ import annotations + +import json +from collections.abc import Callable +from pathlib import Path +from typing import Any + +import pytest + +FIXTURE_DIR = Path(__file__).parent / "fixtures" / "2025.4.9" + + +def load_fixture(name: str) -> dict[str, Any]: + with open(FIXTURE_DIR / name) as handle: + return json.load(handle) + + +@pytest.fixture +def fixtures_dir() -> Path: + return FIXTURE_DIR + + +@pytest.fixture +def load() -> Callable[[str], dict[str, Any]]: + return load_fixture diff --git a/tests/inspect/fixtures/2025.4.9/action_schema_collector.json b/tests/inspect/fixtures/2025.4.9/action_schema_collector.json new file mode 100644 index 0000000..335c4ee --- /dev/null +++ b/tests/inspect/fixtures/2025.4.9/action_schema_collector.json @@ -0,0 +1,97 @@ +{ + "updateTopology": { + "actions": { + "status": { + "collector": { + "updateTopology": { + "desc": "", + "label": "UpdateTopology" + } + } + } + }, + "header": { + "auth": true, + "caption": "Operation Successful", + "code": "OK", + "errorCodes": [], + "errorDetails": [], + "id": "", + "msg": [], + "ok": true, + "user": "api-user" + } + }, + "validateTopology": { + "actions": { + "status": { + "collector": {} + } + }, + "header": { + "auth": true, + "caption": "Operation Successful", + "code": "OK", + "errorCodes": [], + "errorDetails": [], + "id": "", + "msg": [], + "ok": true, + "user": "api-user" + } + }, + "exportTopology": { + "actions": { + "status": { + "collector": {} + } + }, + "header": { + "auth": true, + "caption": "Operation Successful", + "code": "OK", + "errorCodes": [], + "errorDetails": [], + "id": "", + "msg": [], + "ok": true, + "user": "api-user" + } + }, + "importTopology": { + "actions": { + "status": { + "collector": {} + } + }, + "header": { + "auth": true, + "caption": "Operation Successful", + "code": "OK", + "errorCodes": [], + "errorDetails": [], + "id": "", + "msg": [], + "ok": true, + "user": "api-user" + } + }, + "discardTopology": { + "actions": { + "status": { + "collector": {} + } + }, + "header": { + "auth": true, + "caption": "Operation Successful", + "code": "OK", + "errorCodes": [], + "errorDetails": [], + "id": "", + "msg": [], + "ok": true, + "user": "api-user" + } + } +} diff --git a/tests/inspect/fixtures/2025.4.9/alarms_current.json b/tests/inspect/fixtures/2025.4.9/alarms_current.json new file mode 100644 index 0000000..67fa733 --- /dev/null +++ b/tests/inspect/fixtures/2025.4.9/alarms_current.json @@ -0,0 +1,135 @@ +{ + "data": { + "status": { + "alarms": { + "current": { + "_items": [ + { + "_id": "1:device-a.dev:Mock", + "_vid": "_:1:device-a.dev:Mock", + "acked": false, + "desc": { + "alertId": { + "desc": "", + "label": "" + }, + "pointId": [ + { + "desc": "Type: mock\nIP: 10.0.0.1", + "label": "Mock device 'device-a' [10.0.0.1]" + }, + { + "desc": "", + "label": "" + } + ] + }, + "hidden": false, + "history": [], + "id": { + "alertId": "Mock", + "component": 1, + "pointId": ["device-a", "dev"] + }, + "info": { + "details": "Mock driver in use", + "evtType": 1, + "headSeverity": 2, + "sa": 2, + "severity": 2, + "time": 1700000000000 + } + }, + { + "_id": "1:device-a.dev.module-1:PortAlarm", + "_vid": "_:1:device-a.dev.module-1:PortAlarm", + "acked": true, + "desc": { + "alertId": { + "desc": "", + "label": "Port Alarm" + }, + "pointId": [ + { + "desc": "", + "label": "device-a" + }, + { + "desc": "", + "label": "module-1" + } + ] + }, + "hidden": false, + "history": [], + "id": { + "alertId": "PortAlarm", + "component": 1, + "pointId": ["device-a", "dev", "module-1"] + }, + "info": { + "details": "Loss of protection", + "evtType": 1, + "headSeverity": 5, + "sa": 1, + "severity": 5, + "time": 1700000001000 + } + }, + { + "_id": "1:device-a.dev.module-1.port-out-1:Signal", + "_vid": "_:1:device-a.dev.module-1.port-out-1:Signal", + "acked": false, + "desc": { + "alertId": { + "desc": "", + "label": "Signal" + }, + "pointId": [ + { + "desc": "", + "label": "device-a" + }, + { + "desc": "", + "label": "module-1" + }, + { + "desc": "", + "label": "port-out-1" + } + ] + }, + "hidden": false, + "history": [], + "id": { + "alertId": "Signal", + "component": 1, + "pointId": ["device-a", "dev", "module-1", "port-out-1"] + }, + "info": { + "details": "Loss of disjunctivity", + "evtType": 1, + "headSeverity": 4, + "sa": 0, + "severity": 4, + "time": 1700000002000 + } + } + ] + } + } + } + }, + "header": { + "auth": true, + "caption": "OK", + "code": "OK", + "errorCodes": [], + "errorDetails": [], + "id": "alarms-fixture", + "msg": [], + "ok": true, + "user": "test-user" + } +} diff --git a/tests/inspect/fixtures/2025.4.9/device_hydration_modules_ports.json b/tests/inspect/fixtures/2025.4.9/device_hydration_modules_ports.json new file mode 100644 index 0000000..08de981 --- /dev/null +++ b/tests/inspect/fixtures/2025.4.9/device_hydration_modules_ports.json @@ -0,0 +1,118 @@ +{ + "data": { + "status": { + "collector": { + "inspect": { + "nodeStatus": { + "_items": [ + { + "_id": "device-a", + "_vid": "device-a", + "modules": { + "device-a.dev.0": { + "ports": { + "device-a.dev.module-1.port-out-1": { + "descriptor": { + "desc": "Example port description", + "label": "port-out-1 (out)" + }, + "label": "port-out-1", + "pid": "device-a.dev.module-1.port-out-1", + "vertexInfo": { + "type": "single", + "id": "device-a.module-1.port-out-1.out", + "label": "port-out-1 (out)", + "vertexType": "Out", + "fields": { + "isActive": true, + "isControlled": true, + "isEndpoint": false + } + } + }, + "device-a.dev.module-1.port-bidi-1": { + "descriptor": { + "label": "port-bidi-1" + }, + "label": "port-bidi-1", + "pid": "device-a.dev.module-1.port-bidi-1", + "vertexInfo": { + "type": "double", + "in": { + "type": "single", + "id": "device-a.module-1.port-bidi-1.in", + "label": "port-bidi-1 (in)", + "vertexType": "In", + "fields": { + "isActive": true, + "isControlled": false, + "isEndpoint": true + } + }, + "out": { + "type": "single", + "id": "device-a.module-1.port-bidi-1.out", + "label": "port-bidi-1 (out)", + "vertexType": "Out", + "fields": { + "isActive": true, + "isControlled": false, + "isEndpoint": true + } + } + } + } + } + }, + "device-a.dev.uuid-0001": { + "ports": { + "device-a.dev.uuid-0001.S00000001-0000-4000-8000-000000000001": { + "descriptor": { + "label": "Example Stream" + }, + "pid": "device-a.dev.uuid-0001.S00000001-0000-4000-8000-000000000001" + }, + "device-a.dev.uuid-0001.S00000002-0000-4000-8000-000000000002": { + "descriptor": { + "label": "Example Stream" + }, + "pid": "device-a.dev.uuid-0001.S00000002-0000-4000-8000-000000000002" + } + } + }, + "device-a.dev.uuid-0002": { + "ports": { + "device-a.dev.uuid-0002.S00000003-0000-4000-8000-000000000003": { + "descriptor": { + "label": "Example Pipeline" + }, + "pid": "device-a.dev.uuid-0002.S00000003-0000-4000-8000-000000000003" + }, + "device-a.dev.uuid-0002.S00000004-0000-4000-8000-000000000004": { + "descriptor": { + "label": "Example Pipeline" + }, + "pid": "device-a.dev.uuid-0002.S00000004-0000-4000-8000-000000000004" + } + } + } + } + } + ] + } + } + } + } + }, + "header": { + "auth": true, + "caption": "Operation Successful", + "code": "OK", + "errorCodes": [], + "errorDetails": [], + "id": "", + "msg": [], + "ok": true, + "user": "api-user" + } +} diff --git a/tests/inspect/fixtures/2025.4.9/edge_skeleton.json b/tests/inspect/fixtures/2025.4.9/edge_skeleton.json new file mode 100644 index 0000000..1f360d6 --- /dev/null +++ b/tests/inspect/fixtures/2025.4.9/edge_skeleton.json @@ -0,0 +1,2259 @@ +{ + "data": { + "status": { + "collector": { + "externalEdgesByDeviceKey": { + "_items": [ + { + "_id": "device-h::device-a", + "_vid": "device-h::device-a", + "primary": { + "data": { + "uuid-0003": { + "fromStatus": { + "context": { + "devicePid": "device-h", + "modulePid": "device-h.dev.0", + "portPid": "device-a.dev.module-1.port-out-1" + }, + "label": "port-out-1 (out)" + }, + "id": "uuid-0003", + "toStatus": { + "context": { + "devicePid": "device-a", + "modulePid": "device-a.dev.1", + "portPid": "device-a.dev.1.port-in-1" + }, + "label": "port-in-1" + } + } + }, + "devicePid": "device-h", + "label": "Example Device A" + }, + "secondary": { + "data": {}, + "devicePid": "device-a", + "label": "Example Device" + }, + "status": { + "alarm": 1, + "bandwidth": null, + "maintenance": null, + "ptp": 1 + } + }, + { + "_id": "device-i::device-m", + "_vid": "device-i::device-m", + "primary": { + "data": { + "device-i.1.3.out::device-m.1.Ethernet4_42.in": { + "fromStatus": { + "context": { + "devicePid": "device-i", + "modulePid": "device-i.dev.1", + "portPid": "device-i.dev.1.3" + }, + "label": "eth-0-3" + }, + "id": "device-i.1.3.out::device-m.1.Ethernet4_42.in", + "toStatus": { + "context": { + "devicePid": "device-m", + "modulePid": "device-m.dev.1", + "portPid": "device-m.dev.1.Ethernet4_42" + }, + "label": "port-in-1/42" + } + } + }, + "devicePid": "device-i", + "label": "Example Device" + }, + "secondary": { + "data": { + "device-m.1.Ethernet4_42.out::device-i.1.3.in": { + "fromStatus": { + "context": { + "devicePid": "device-m", + "modulePid": "device-m.dev.1", + "portPid": "device-m.dev.1.Ethernet4_42" + }, + "label": "port-in-1/42" + }, + "id": "device-m.1.Ethernet4_42.out::device-i.1.3.in", + "toStatus": { + "context": { + "devicePid": "device-i", + "modulePid": "device-i.dev.1", + "portPid": "device-i.dev.1.3" + }, + "label": "eth-0-3" + } + } + }, + "devicePid": "device-m", + "label": "Example Device" + }, + "status": { + "alarm": 1, + "bandwidth": 1, + "maintenance": null, + "ptp": 1 + } + }, + { + "_id": "device-i::device-p", + "_vid": "device-i::device-p", + "primary": { + "data": { + "uuid-0002": { + "fromStatus": { + "context": { + "devicePid": "device-i", + "modulePid": "device-i.dev.1", + "portPid": "device-i.dev.1.51" + }, + "label": "eth-0-51" + }, + "id": "uuid-0002", + "toStatus": { + "context": { + "devicePid": "device-p", + "modulePid": "device-p.dev.0", + "portPid": "device-p.dev.0.NMS-RED" + }, + "label": "NMS-RED" + } + } + }, + "devicePid": "device-i", + "label": "Example Device" + }, + "secondary": { + "data": { + "uuid-0005": { + "fromStatus": { + "context": { + "devicePid": "device-p", + "modulePid": "device-p.dev.0", + "portPid": "device-p.dev.0.NMS-RED" + }, + "label": "NMS-RED" + }, + "id": "uuid-0005", + "toStatus": { + "context": { + "devicePid": "device-i", + "modulePid": "device-i.dev.1", + "portPid": "device-i.dev.1.51" + }, + "label": "eth-0-51" + } + } + }, + "devicePid": "device-p", + "label": "Example Device" + }, + "status": { + "alarm": 1, + "bandwidth": 1, + "maintenance": null, + "ptp": 1 + } + }, + { + "_id": "device-i::virtual.0", + "_vid": "device-i::virtual.0", + "primary": { + "data": { + "uuid-0010": { + "fromStatus": { + "context": { + "devicePid": "device-i", + "modulePid": "device-i.dev.1", + "portPid": "device-i.dev.1.51" + }, + "label": "eth-0-51" + }, + "id": "uuid-0010", + "toStatus": { + "context": { + "devicePid": "virtual-0", + "modulePid": "virtual-0.virt.0", + "portPid": "virtual-0.virt.0.1" + }, + "label": "Ethernet 1" + } + } + }, + "devicePid": "device-i", + "label": "Example Device" + }, + "secondary": { + "data": { + "uuid-0006": { + "fromStatus": { + "context": { + "devicePid": "virtual-0", + "modulePid": "virtual-0.virt.0", + "portPid": "virtual-0.virt.0.3" + }, + "label": "Ethernet 1" + }, + "id": "uuid-0006", + "toStatus": { + "context": { + "devicePid": "device-i", + "modulePid": "device-i.dev.1", + "portPid": "device-i.dev.1.51" + }, + "label": "eth-0-51" + } + } + }, + "devicePid": "virtual-0", + "label": "Virtual Device 1" + }, + "status": { + "alarm": 1, + "bandwidth": null, + "maintenance": null, + "ptp": 1 + } + }, + { + "_id": "device-j::device-d", + "_vid": "device-j::device-d", + "primary": { + "data": { + "device-j.0.P1.out::device-b.dev.module-1.port-in-1.in": { + "fromStatus": { + "context": { + "devicePid": "device-j", + "modulePid": "device-j.dev.0", + "portPid": "device-j.dev.0.P1" + }, + "label": "P1 (out)" + }, + "id": "device-j.0.P1.out::device-b.dev.module-1.port-in-1.in", + "toStatus": { + "context": { + "devicePid": "device-d", + "modulePid": "device-d.dev.1", + "portPid": "device-d.dev.1.port-in-1" + }, + "label": "port-in-1" + } + } + }, + "devicePid": "device-j", + "label": "Example Device" + }, + "secondary": { + "data": {}, + "devicePid": "device-d", + "label": "Example Device" + }, + "status": { + "alarm": 1, + "bandwidth": null, + "maintenance": null, + "ptp": 1 + } + }, + { + "_id": "device-a::device-j", + "_vid": "device-a::device-j", + "primary": { + "data": {}, + "devicePid": "device-a", + "label": "Example Device" + }, + "secondary": { + "data": { + "device-j.0.P2.out::device-b.dev.module-1.port-in-1.in": { + "fromStatus": { + "context": { + "devicePid": "device-j", + "modulePid": "device-j.dev.0", + "portPid": "device-j.dev.0.P2" + }, + "label": "P2 (out)" + }, + "id": "device-j.0.P2.out::device-b.dev.module-1.port-in-1.in", + "toStatus": { + "context": { + "devicePid": "device-a", + "modulePid": "device-a.dev.1", + "portPid": "device-a.dev.1.port-in-1" + }, + "label": "port-in-1" + } + } + }, + "devicePid": "device-j", + "label": "Example Device" + }, + "status": { + "alarm": 1, + "bandwidth": null, + "maintenance": null, + "ptp": 1 + } + }, + { + "_id": "device-a::device-k", + "_vid": "device-a::device-k", + "primary": { + "data": { + "device-b.dev.module-1.port-in-1.out::device-k.0.P2.in": { + "fromStatus": { + "context": { + "devicePid": "device-a", + "modulePid": "device-a.dev.1", + "portPid": "device-a.dev.1.port-in-1" + }, + "label": "port-in-1" + }, + "id": "device-b.dev.module-1.port-in-1.out::device-k.0.P2.in", + "toStatus": { + "context": { + "devicePid": "device-k", + "modulePid": "device-k.dev.0", + "portPid": "device-k.dev.0.P2" + }, + "label": "P2 (in)" + } + } + }, + "devicePid": "device-a", + "label": "Example Device" + }, + "secondary": { + "data": {}, + "devicePid": "device-k", + "label": "Example Device" + }, + "status": { + "alarm": 1, + "bandwidth": null, + "maintenance": null, + "ptp": 1 + } + }, + { + "_id": "device-a::device-l", + "_vid": "device-a::device-l", + "primary": { + "data": { + "device-a.1.Ethernet54_1.out::device-l.4.Ethernet3_37_1.in": { + "fromStatus": { + "context": { + "devicePid": "device-a", + "modulePid": "device-a.dev.1", + "portPid": "device-a.dev.1.Ethernet54_1" + }, + "label": "port-in-1/1" + }, + "id": "device-a.1.Ethernet54_1.out::device-l.4.Ethernet3_37_1.in", + "toStatus": { + "context": { + "devicePid": "device-l", + "modulePid": "device-l.dev.4", + "portPid": "device-l.dev.4.Ethernet3_37_1" + }, + "label": "port-in-1/37/1" + } + }, + "device-a.1.Ethernet56_1.out::device-l.4.Ethernet3_39_1.in": { + "fromStatus": { + "context": { + "devicePid": "device-a", + "modulePid": "device-a.dev.1", + "portPid": "device-a.dev.1.Ethernet56_1" + }, + "label": "port-in-1/1" + }, + "id": "device-a.1.Ethernet56_1.out::device-l.4.Ethernet3_39_1.in", + "toStatus": { + "context": { + "devicePid": "device-l", + "modulePid": "device-l.dev.4", + "portPid": "device-l.dev.4.Ethernet3_39_1" + }, + "label": "port-in-1/39/1" + } + } + }, + "devicePid": "device-a", + "label": "Example Device" + }, + "secondary": { + "data": { + "device-l.4.Ethernet3_37_1.out::device-a.1.Ethernet54_1.in": { + "fromStatus": { + "context": { + "devicePid": "device-l", + "modulePid": "device-l.dev.4", + "portPid": "device-l.dev.4.Ethernet3_37_1" + }, + "label": "port-in-1/37/1" + }, + "id": "device-l.4.Ethernet3_37_1.out::device-a.1.Ethernet54_1.in", + "toStatus": { + "context": { + "devicePid": "device-a", + "modulePid": "device-a.dev.1", + "portPid": "device-a.dev.1.Ethernet54_1" + }, + "label": "port-in-1/1" + } + }, + "device-l.4.Ethernet3_39_1.out::device-a.1.Ethernet56_1.in": { + "fromStatus": { + "context": { + "devicePid": "device-l", + "modulePid": "device-l.dev.4", + "portPid": "device-l.dev.4.Ethernet3_39_1" + }, + "label": "port-in-1/39/1" + }, + "id": "device-l.4.Ethernet3_39_1.out::device-a.1.Ethernet56_1.in", + "toStatus": { + "context": { + "devicePid": "device-a", + "modulePid": "device-a.dev.1", + "portPid": "device-a.dev.1.Ethernet56_1" + }, + "label": "port-in-1/1" + } + } + }, + "devicePid": "device-l", + "label": "Example Device" + }, + "status": { + "alarm": 1, + "bandwidth": 1, + "maintenance": null, + "ptp": 1 + } + }, + { + "_id": "device-a::device-r", + "_vid": "device-a::device-r", + "primary": { + "data": { + "uuid-0004": { + "fromStatus": { + "context": { + "devicePid": "device-a", + "modulePid": "device-a.dev.1", + "portPid": "device-a.dev.1.port-in-1" + }, + "label": "port-in-1" + }, + "id": "uuid-0004", + "toStatus": { + "context": { + "devicePid": "device-r", + "modulePid": "device-r.dev.0", + "portPid": "device-r.dev.0.P2" + }, + "label": "P2 (in)" + } + } + }, + "devicePid": "device-a", + "label": "Example Device" + }, + "secondary": { + "data": {}, + "devicePid": "device-r", + "label": "Example Device" + }, + "status": { + "alarm": 1, + "bandwidth": null, + "maintenance": null, + "ptp": 1 + } + }, + { + "_id": "device-a::device-s", + "_vid": "device-a::device-s", + "primary": { + "data": { + "device-b.dev.module-1.port-in-1.out::device-s.0.P2.in": { + "fromStatus": { + "context": { + "devicePid": "device-a", + "modulePid": "device-a.dev.1", + "portPid": "device-a.dev.1.port-in-1" + }, + "label": "port-in-1" + }, + "id": "device-b.dev.module-1.port-in-1.out::device-s.0.P2.in", + "toStatus": { + "context": { + "devicePid": "device-s", + "modulePid": "device-s.dev.0", + "portPid": "device-s.dev.0.P2" + }, + "label": "P2 (in)" + } + } + }, + "devicePid": "device-a", + "label": "Example Device" + }, + "secondary": { + "data": {}, + "devicePid": "device-s", + "label": "Example Device" + }, + "status": { + "alarm": 1, + "bandwidth": 1, + "maintenance": null, + "ptp": 1 + } + }, + { + "_id": "device-a::device-t", + "_vid": "device-a::device-t", + "primary": { + "data": { + "device-b.dev.module-1.port-in-1.out::device-t.0.mmc1.in": { + "fromStatus": { + "context": { + "devicePid": "device-a", + "modulePid": "device-a.dev.1", + "portPid": "device-a.dev.1.port-in-1" + }, + "label": "port-in-1" + }, + "id": "device-b.dev.module-1.port-in-1.out::device-t.0.mmc1.in", + "toStatus": { + "context": { + "devicePid": "device-t", + "modulePid": "device-t.dev.0", + "portPid": "device-t.dev.0.mmc1" + }, + "label": "mmc1 (in)" + } + } + }, + "devicePid": "device-a", + "label": "Example Device" + }, + "secondary": { + "data": {}, + "devicePid": "device-t", + "label": "Example Device" + }, + "status": { + "alarm": 1, + "bandwidth": 1, + "maintenance": null, + "ptp": 1 + } + }, + { + "_id": "device-a::device-u", + "_vid": "device-a::device-u", + "primary": { + "data": { + "device-b.dev.module-1.port-in-1.out::device-u.3.10006.in": { + "fromStatus": { + "context": { + "devicePid": "device-a", + "modulePid": "device-a.dev.1", + "portPid": "device-a.dev.1.port-in-1" + }, + "label": "port-in-1" + }, + "id": "device-b.dev.module-1.port-in-1.out::device-u.3.10006.in", + "toStatus": { + "context": { + "devicePid": "device-u", + "modulePid": "device-u.dev.3", + "portPid": "device-u.dev.3.10006" + }, + "label": "Ethernet 3.4" + } + } + }, + "devicePid": "device-a", + "label": "Example Device" + }, + "secondary": { + "data": { + "device-u.3.10006.out::device-b.dev.module-1.port-in-1.in": { + "fromStatus": { + "context": { + "devicePid": "device-u", + "modulePid": "device-u.dev.3", + "portPid": "device-u.dev.3.10006" + }, + "label": "Ethernet 3.4" + }, + "id": "device-u.3.10006.out::device-b.dev.module-1.port-in-1.in", + "toStatus": { + "context": { + "devicePid": "device-a", + "modulePid": "device-a.dev.1", + "portPid": "device-a.dev.1.port-in-1" + }, + "label": "port-in-1" + } + } + }, + "devicePid": "device-u", + "label": "Example Device" + }, + "status": { + "alarm": 1, + "bandwidth": 1, + "maintenance": null, + "ptp": 1 + } + }, + { + "_id": "device-a::device-f", + "_vid": "device-a::device-f", + "primary": { + "data": { + "device-b.dev.module-1.port-in-1.out::device-f.1.10006.in": { + "fromStatus": { + "context": { + "devicePid": "device-a", + "modulePid": "device-a.dev.1", + "portPid": "device-a.dev.1.port-in-1" + }, + "label": "port-in-1" + }, + "id": "device-b.dev.module-1.port-in-1.out::device-f.1.10006.in", + "toStatus": { + "context": { + "devicePid": "device-f", + "modulePid": "device-f.dev.1", + "portPid": "device-f.dev.1.10006" + }, + "label": "Ethernet 1.4" + } + } + }, + "devicePid": "device-a", + "label": "Example Device" + }, + "secondary": { + "data": { + "device-f.1.10006.out::device-b.dev.module-1.port-in-1.in": { + "fromStatus": { + "context": { + "devicePid": "device-f", + "modulePid": "device-f.dev.1", + "portPid": "device-f.dev.1.10006" + }, + "label": "Ethernet 1.4" + }, + "id": "device-f.1.10006.out::device-b.dev.module-1.port-in-1.in", + "toStatus": { + "context": { + "devicePid": "device-a", + "modulePid": "device-a.dev.1", + "portPid": "device-a.dev.1.port-in-1" + }, + "label": "port-in-1" + } + } + }, + "devicePid": "device-f", + "label": "Example Device" + }, + "status": { + "alarm": 1, + "bandwidth": 1, + "maintenance": null, + "ptp": 1 + } + }, + { + "_id": "device-a::device-g", + "_vid": "device-a::device-g", + "primary": { + "data": { + "device-b.dev.module-1.port-in-1.out::device-g.6.10006.in": { + "fromStatus": { + "context": { + "devicePid": "device-a", + "modulePid": "device-a.dev.1", + "portPid": "device-a.dev.1.port-in-1" + }, + "label": "port-in-1" + }, + "id": "device-b.dev.module-1.port-in-1.out::device-g.6.10006.in", + "toStatus": { + "context": { + "devicePid": "device-g", + "modulePid": "device-g.dev.6", + "portPid": "device-g.dev.6.10006" + }, + "label": "Ethernet 6.4" + } + } + }, + "devicePid": "device-a", + "label": "Example Device" + }, + "secondary": { + "data": { + "device-g.6.10006.out::device-b.dev.module-1.port-in-1.in": { + "fromStatus": { + "context": { + "devicePid": "device-g", + "modulePid": "device-g.dev.6", + "portPid": "device-g.dev.6.10006" + }, + "label": "Ethernet 6.4" + }, + "id": "device-g.6.10006.out::device-b.dev.module-1.port-in-1.in", + "toStatus": { + "context": { + "devicePid": "device-a", + "modulePid": "device-a.dev.1", + "portPid": "device-a.dev.1.port-in-1" + }, + "label": "port-in-1" + } + } + }, + "devicePid": "device-g", + "label": "Example Device" + }, + "status": { + "alarm": 1, + "bandwidth": 1, + "maintenance": null, + "ptp": 1 + } + }, + { + "_id": "device-k::device-d", + "_vid": "device-k::device-d", + "primary": { + "data": {}, + "devicePid": "device-k", + "label": "Example Device" + }, + "secondary": { + "data": { + "device-b.dev.module-1.port-in-1.out::device-k.0.P1.in": { + "fromStatus": { + "context": { + "devicePid": "device-d", + "modulePid": "device-d.dev.1", + "portPid": "device-d.dev.1.port-in-1" + }, + "label": "port-in-1" + }, + "id": "device-b.dev.module-1.port-in-1.out::device-k.0.P1.in", + "toStatus": { + "context": { + "devicePid": "device-k", + "modulePid": "device-k.dev.0", + "portPid": "device-k.dev.0.P1" + }, + "label": "P1 (in)" + } + } + }, + "devicePid": "device-d", + "label": "Example Device" + }, + "status": { + "alarm": 1, + "bandwidth": null, + "maintenance": null, + "ptp": 1 + } + }, + { + "_id": "device-l::device-n", + "_vid": "device-l::device-n", + "primary": { + "data": { + "device-l.4.Ethernet3_47_1.out::device-n.1.49.in": { + "fromStatus": { + "context": { + "devicePid": "device-l", + "modulePid": "device-l.dev.4", + "portPid": "device-l.dev.4.Ethernet3_47_1" + }, + "label": "port-in-1/47/1" + }, + "id": "device-l.4.Ethernet3_47_1.out::device-n.1.49.in", + "toStatus": { + "context": { + "devicePid": "device-n", + "modulePid": "device-n.dev.1", + "portPid": "device-n.dev.1.49" + }, + "label": "eth-0-49" + } + } + }, + "devicePid": "device-l", + "label": "Example Device" + }, + "secondary": { + "data": { + "device-n.1.49.out::device-l.4.Ethernet3_47_1.in": { + "fromStatus": { + "context": { + "devicePid": "device-n", + "modulePid": "device-n.dev.1", + "portPid": "device-n.dev.1.49" + }, + "label": "eth-0-49" + }, + "id": "device-n.1.49.out::device-l.4.Ethernet3_47_1.in", + "toStatus": { + "context": { + "devicePid": "device-l", + "modulePid": "device-l.dev.4", + "portPid": "device-l.dev.4.Ethernet3_47_1" + }, + "label": "port-in-1/47/1" + } + } + }, + "devicePid": "device-n", + "label": "Example Device" + }, + "status": { + "alarm": 1, + "bandwidth": 1, + "maintenance": null, + "ptp": 1 + } + }, + { + "_id": "device-l::device-o", + "_vid": "device-l::device-o", + "primary": { + "data": { + "device-l.4.Ethernet3_1_1.out::device-o.0.p9p1.in": { + "fromStatus": { + "context": { + "devicePid": "device-l", + "modulePid": "device-l.dev.4", + "portPid": "device-l.dev.4.Ethernet3_1_1" + }, + "label": "port-in-1/1/1" + }, + "id": "device-l.4.Ethernet3_1_1.out::device-o.0.p9p1.in", + "toStatus": { + "context": { + "devicePid": "device-o", + "modulePid": "device-o.dev.0", + "portPid": "device-o.dev.0.p9p1" + }, + "label": "p9p1" + } + } + }, + "devicePid": "device-l", + "label": "Example Device" + }, + "secondary": { + "data": { + "device-o.0.p9p1.out::device-l.4.Ethernet3_1_1.in": { + "fromStatus": { + "context": { + "devicePid": "device-o", + "modulePid": "device-o.dev.0", + "portPid": "device-o.dev.0.p9p1" + }, + "label": "p9p1" + }, + "id": "device-o.0.p9p1.out::device-l.4.Ethernet3_1_1.in", + "toStatus": { + "context": { + "devicePid": "device-l", + "modulePid": "device-l.dev.4", + "portPid": "device-l.dev.4.Ethernet3_1_1" + }, + "label": "port-in-1/1/1" + } + } + }, + "devicePid": "device-o", + "label": "Example Device" + }, + "status": { + "alarm": 1, + "bandwidth": 1, + "maintenance": null, + "ptp": 1 + } + }, + { + "_id": "device-l::device-v", + "_vid": "device-l::device-v", + "primary": { + "data": { + "device-l.4.Ethernet3_7_1.out::device-v.0.data1B.in": { + "fromStatus": { + "context": { + "devicePid": "device-l", + "modulePid": "device-l.dev.4", + "portPid": "device-l.dev.4.Ethernet3_7_1" + }, + "label": "port-in-1/7/1" + }, + "id": "device-l.4.Ethernet3_7_1.out::device-v.0.data1B.in", + "toStatus": { + "context": { + "devicePid": "device-v", + "modulePid": "device-v.dev.0", + "portPid": "device-v.dev.0.data1B" + }, + "label": "data1B" + } + } + }, + "devicePid": "device-l", + "label": "Example Device" + }, + "secondary": { + "data": { + "device-v.0.data1B.out::device-l.4.Ethernet3_7_1.in": { + "fromStatus": { + "context": { + "devicePid": "device-v", + "modulePid": "device-v.dev.0", + "portPid": "device-v.dev.0.data1B" + }, + "label": "data1B" + }, + "id": "device-v.0.data1B.out::device-l.4.Ethernet3_7_1.in", + "toStatus": { + "context": { + "devicePid": "device-l", + "modulePid": "device-l.dev.4", + "portPid": "device-l.dev.4.Ethernet3_7_1" + }, + "label": "port-in-1/7/1" + } + } + }, + "devicePid": "device-v", + "label": "Example Device" + }, + "status": { + "alarm": 1, + "bandwidth": null, + "maintenance": null, + "ptp": 1 + } + }, + { + "_id": "device-l::device-y", + "_vid": "device-l::device-y", + "primary": { + "data": { + "device-l.1.Ethernet4_1.out::device-y.0.port-102.in": { + "fromStatus": { + "context": { + "devicePid": "device-l", + "modulePid": "device-l.dev.1", + "portPid": "device-l.dev.1.Ethernet4_1" + }, + "label": "port-in-1/1" + }, + "id": "device-l.1.Ethernet4_1.out::device-y.0.port-102.in", + "toStatus": { + "context": { + "devicePid": "device-y", + "modulePid": "device-y.dev.0", + "portPid": "device-y.dev.0.port-102" + }, + "label": "port-102" + } + } + }, + "devicePid": "device-l", + "label": "Example Device" + }, + "secondary": { + "data": { + "device-y.0.port-102.out::device-l.1.Ethernet4_1.in": { + "fromStatus": { + "context": { + "devicePid": "device-y", + "modulePid": "device-y.dev.0", + "portPid": "device-y.dev.0.port-102" + }, + "label": "port-102" + }, + "id": "device-y.0.port-102.out::device-l.1.Ethernet4_1.in", + "toStatus": { + "context": { + "devicePid": "device-l", + "modulePid": "device-l.dev.1", + "portPid": "device-l.dev.1.Ethernet4_1" + }, + "label": "port-in-1/1" + } + } + }, + "devicePid": "device-y", + "label": "Example Device" + }, + "status": { + "alarm": 1, + "bandwidth": null, + "maintenance": null, + "ptp": 1 + } + }, + { + "_id": "device-l::device-e", + "_vid": "device-l::device-e", + "primary": { + "data": { + "device-l.4.Ethernet3_2_1.out::device-e.1000.1.in": { + "fromStatus": { + "context": { + "devicePid": "device-l", + "modulePid": "device-l.dev.4", + "portPid": "device-l.dev.4.Ethernet3_2_1" + }, + "label": "port-in-1/2/1" + }, + "id": "device-l.4.Ethernet3_2_1.out::device-e.1000.1.in", + "toStatus": { + "context": { + "devicePid": "device-e", + "modulePid": "device-e.dev.1000", + "portPid": "device-e.dev.1000.1" + }, + "label": "IP WAN 1" + } + } + }, + "devicePid": "device-l", + "label": "Example Device" + }, + "secondary": { + "data": { + "device-e.1000.1.out::device-l.4.Ethernet3_2_1.in": { + "fromStatus": { + "context": { + "devicePid": "device-e", + "modulePid": "device-e.dev.1000", + "portPid": "device-e.dev.1000.1" + }, + "label": "IP WAN 1" + }, + "id": "device-e.1000.1.out::device-l.4.Ethernet3_2_1.in", + "toStatus": { + "context": { + "devicePid": "device-l", + "modulePid": "device-l.dev.4", + "portPid": "device-l.dev.4.Ethernet3_2_1" + }, + "label": "port-in-1/2/1" + } + } + }, + "devicePid": "device-e", + "label": "Example Device" + }, + "status": { + "alarm": 1, + "bandwidth": 1, + "maintenance": null, + "ptp": 1 + } + }, + { + "_id": "device-m::device-o", + "_vid": "device-m::device-o", + "primary": { + "data": { + "device-m.4.Ethernet3_1_1.out::device-o.0.p9p2.in": { + "fromStatus": { + "context": { + "devicePid": "device-m", + "modulePid": "device-m.dev.4", + "portPid": "device-m.dev.4.Ethernet3_1_1" + }, + "label": "port-in-1/1/1" + }, + "id": "device-m.4.Ethernet3_1_1.out::device-o.0.p9p2.in", + "toStatus": { + "context": { + "devicePid": "device-o", + "modulePid": "device-o.dev.0", + "portPid": "device-o.dev.0.p9p2" + }, + "label": "p9p2" + } + } + }, + "devicePid": "device-m", + "label": "Example Device" + }, + "secondary": { + "data": { + "device-o.0.p9p2.out::device-m.4.Ethernet3_1_1.in": { + "fromStatus": { + "context": { + "devicePid": "device-o", + "modulePid": "device-o.dev.0", + "portPid": "device-o.dev.0.p9p2" + }, + "label": "p9p2" + }, + "id": "device-o.0.p9p2.out::device-m.4.Ethernet3_1_1.in", + "toStatus": { + "context": { + "devicePid": "device-m", + "modulePid": "device-m.dev.4", + "portPid": "device-m.dev.4.Ethernet3_1_1" + }, + "label": "port-in-1/1/1" + } + } + }, + "devicePid": "device-o", + "label": "Example Device" + }, + "status": { + "alarm": 1, + "bandwidth": 1, + "maintenance": null, + "ptp": 1 + } + }, + { + "_id": "device-m::device-c", + "_vid": "device-m::device-c", + "primary": { + "data": { + "device-m.1.Ethernet4_43.out::device-b.dev.module-1.port-in-1.in": { + "fromStatus": { + "context": { + "devicePid": "device-m", + "modulePid": "device-m.dev.1", + "portPid": "device-m.dev.1.Ethernet4_43" + }, + "label": "port-in-1/43" + }, + "id": "device-m.1.Ethernet4_43.out::device-b.dev.module-1.port-in-1.in", + "toStatus": { + "context": { + "devicePid": "device-c", + "modulePid": "device-c.dev.1", + "portPid": "device-c.dev.1.port-in-1" + }, + "label": "port-in-1" + } + } + }, + "devicePid": "device-m", + "label": "Example Device" + }, + "secondary": { + "data": { + "device-b.dev.module-1.port-in-1.out::device-m.1.Ethernet4_43.in": { + "fromStatus": { + "context": { + "devicePid": "device-c", + "modulePid": "device-c.dev.1", + "portPid": "device-c.dev.1.port-in-1" + }, + "label": "port-in-1" + }, + "id": "device-b.dev.module-1.port-in-1.out::device-m.1.Ethernet4_43.in", + "toStatus": { + "context": { + "devicePid": "device-m", + "modulePid": "device-m.dev.1", + "portPid": "device-m.dev.1.Ethernet4_43" + }, + "label": "port-in-1/43" + } + } + }, + "devicePid": "device-c", + "label": "Example Device" + }, + "status": { + "alarm": 1, + "bandwidth": 1, + "maintenance": null, + "ptp": 1 + } + }, + { + "_id": "device-m::device-v", + "_vid": "device-m::device-v", + "primary": { + "data": { + "device-m.4.Ethernet3_7_1.out::device-v.0.data1A.in": { + "fromStatus": { + "context": { + "devicePid": "device-m", + "modulePid": "device-m.dev.4", + "portPid": "device-m.dev.4.Ethernet3_7_1" + }, + "label": "port-in-1/7/1" + }, + "id": "device-m.4.Ethernet3_7_1.out::device-v.0.data1A.in", + "toStatus": { + "context": { + "devicePid": "device-v", + "modulePid": "device-v.dev.0", + "portPid": "device-v.dev.0.data1A" + }, + "label": "data1A" + } + } + }, + "devicePid": "device-m", + "label": "Example Device" + }, + "secondary": { + "data": { + "device-v.0.data1A.out::device-m.4.Ethernet3_7_1.in": { + "fromStatus": { + "context": { + "devicePid": "device-v", + "modulePid": "device-v.dev.0", + "portPid": "device-v.dev.0.data1A" + }, + "label": "data1A" + }, + "id": "device-v.0.data1A.out::device-m.4.Ethernet3_7_1.in", + "toStatus": { + "context": { + "devicePid": "device-m", + "modulePid": "device-m.dev.4", + "portPid": "device-m.dev.4.Ethernet3_7_1" + }, + "label": "port-in-1/7/1" + } + } + }, + "devicePid": "device-v", + "label": "Example Device" + }, + "status": { + "alarm": 1, + "bandwidth": null, + "maintenance": null, + "ptp": 1 + } + }, + { + "_id": "device-m::device-y", + "_vid": "device-m::device-y", + "primary": { + "data": { + "device-m.1.Ethernet4_1.out::device-y.0.port-110.in": { + "fromStatus": { + "context": { + "devicePid": "device-m", + "modulePid": "device-m.dev.1", + "portPid": "device-m.dev.1.Ethernet4_1" + }, + "label": "port-in-1/1" + }, + "id": "device-m.1.Ethernet4_1.out::device-y.0.port-110.in", + "toStatus": { + "context": { + "devicePid": "device-y", + "modulePid": "device-y.dev.0", + "portPid": "device-y.dev.0.port-110" + }, + "label": "port-110" + } + } + }, + "devicePid": "device-m", + "label": "Example Device" + }, + "secondary": { + "data": { + "device-y.0.port-110.out::device-m.1.Ethernet4_1.in": { + "fromStatus": { + "context": { + "devicePid": "device-y", + "modulePid": "device-y.dev.0", + "portPid": "device-y.dev.0.port-110" + }, + "label": "port-110" + }, + "id": "device-y.0.port-110.out::device-m.1.Ethernet4_1.in", + "toStatus": { + "context": { + "devicePid": "device-m", + "modulePid": "device-m.dev.1", + "portPid": "device-m.dev.1.Ethernet4_1" + }, + "label": "port-in-1/1" + } + } + }, + "devicePid": "device-y", + "label": "Example Device" + }, + "status": { + "alarm": 1, + "bandwidth": null, + "maintenance": null, + "ptp": 1 + } + }, + { + "_id": "device-m::device-d", + "_vid": "device-m::device-d", + "primary": { + "data": { + "device-m.4.Ethernet3_37_1.out::device-d.1.Ethernet53_1.in": { + "fromStatus": { + "context": { + "devicePid": "device-m", + "modulePid": "device-m.dev.4", + "portPid": "device-m.dev.4.Ethernet3_37_1" + }, + "label": "port-in-1/37/1" + }, + "id": "device-m.4.Ethernet3_37_1.out::device-d.1.Ethernet53_1.in", + "toStatus": { + "context": { + "devicePid": "device-d", + "modulePid": "device-d.dev.1", + "portPid": "device-d.dev.1.Ethernet53_1" + }, + "label": "port-in-1/1" + } + }, + "device-m.4.Ethernet3_39_1.out::device-d.1.Ethernet55_1.in": { + "fromStatus": { + "context": { + "devicePid": "device-m", + "modulePid": "device-m.dev.4", + "portPid": "device-m.dev.4.Ethernet3_39_1" + }, + "label": "port-in-1/39/1" + }, + "id": "device-m.4.Ethernet3_39_1.out::device-d.1.Ethernet55_1.in", + "toStatus": { + "context": { + "devicePid": "device-d", + "modulePid": "device-d.dev.1", + "portPid": "device-d.dev.1.Ethernet55_1" + }, + "label": "port-in-1/1" + } + } + }, + "devicePid": "device-m", + "label": "Example Device" + }, + "secondary": { + "data": { + "device-d.1.Ethernet53_1.out::device-m.4.Ethernet3_37_1.in": { + "fromStatus": { + "context": { + "devicePid": "device-d", + "modulePid": "device-d.dev.1", + "portPid": "device-d.dev.1.Ethernet53_1" + }, + "label": "port-in-1/1" + }, + "id": "device-d.1.Ethernet53_1.out::device-m.4.Ethernet3_37_1.in", + "toStatus": { + "context": { + "devicePid": "device-m", + "modulePid": "device-m.dev.4", + "portPid": "device-m.dev.4.Ethernet3_37_1" + }, + "label": "port-in-1/37/1" + } + }, + "device-d.1.Ethernet55_1.out::device-m.4.Ethernet3_39_1.in": { + "fromStatus": { + "context": { + "devicePid": "device-d", + "modulePid": "device-d.dev.1", + "portPid": "device-d.dev.1.Ethernet55_1" + }, + "label": "port-in-1/1" + }, + "id": "device-d.1.Ethernet55_1.out::device-m.4.Ethernet3_39_1.in", + "toStatus": { + "context": { + "devicePid": "device-m", + "modulePid": "device-m.dev.4", + "portPid": "device-m.dev.4.Ethernet3_39_1" + }, + "label": "port-in-1/39/1" + } + } + }, + "devicePid": "device-d", + "label": "Example Device" + }, + "status": { + "alarm": 1, + "bandwidth": 1, + "maintenance": null, + "ptp": 1 + } + }, + { + "_id": "device-m::device-e", + "_vid": "device-m::device-e", + "primary": { + "data": { + "device-m.4.Ethernet3_2_1.out::device-e.1000.1.in": { + "fromStatus": { + "context": { + "devicePid": "device-m", + "modulePid": "device-m.dev.4", + "portPid": "device-m.dev.4.Ethernet3_2_1" + }, + "label": "port-in-1/2/1" + }, + "id": "device-m.4.Ethernet3_2_1.out::device-e.1000.1.in", + "toStatus": { + "context": { + "devicePid": "device-e", + "modulePid": "device-e.dev.1000", + "portPid": "device-e.dev.1000.1" + }, + "label": "IP WAN 1" + } + } + }, + "devicePid": "device-m", + "label": "Example Device" + }, + "secondary": { + "data": { + "device-e.1000.1.out::device-m.4.Ethernet3_2_1.in": { + "fromStatus": { + "context": { + "devicePid": "device-e", + "modulePid": "device-e.dev.1000", + "portPid": "device-e.dev.1000.1" + }, + "label": "IP WAN 1" + }, + "id": "device-e.1000.1.out::device-m.4.Ethernet3_2_1.in", + "toStatus": { + "context": { + "devicePid": "device-m", + "modulePid": "device-m.dev.4", + "portPid": "device-m.dev.4.Ethernet3_2_1" + }, + "label": "port-in-1/2/1" + } + } + }, + "devicePid": "device-e", + "label": "Example Device" + }, + "status": { + "alarm": 1, + "bandwidth": 1, + "maintenance": null, + "ptp": 1 + } + }, + { + "_id": "device-n::device-p", + "_vid": "device-n::device-p", + "primary": { + "data": { + "uuid-0001": { + "fromStatus": { + "context": { + "devicePid": "device-n", + "modulePid": "device-n.dev.1", + "portPid": "device-n.dev.1.51" + }, + "label": "eth-0-51" + }, + "id": "uuid-0001", + "toStatus": { + "context": { + "devicePid": "device-p", + "modulePid": "device-p.dev.0", + "portPid": "device-p.dev.0.NMS-BLUE" + }, + "label": "NMS-BLUE" + } + } + }, + "devicePid": "device-n", + "label": "Example Device" + }, + "secondary": { + "data": { + "uuid-0008": { + "fromStatus": { + "context": { + "devicePid": "device-p", + "modulePid": "device-p.dev.0", + "portPid": "device-p.dev.0.NMS-BLUE" + }, + "label": "NMS-BLUE" + }, + "id": "uuid-0008", + "toStatus": { + "context": { + "devicePid": "device-n", + "modulePid": "device-n.dev.1", + "portPid": "device-n.dev.1.51" + }, + "label": "eth-0-51" + } + } + }, + "devicePid": "device-p", + "label": "Example Device" + }, + "status": { + "alarm": 1, + "bandwidth": 1, + "maintenance": null, + "ptp": 1 + } + }, + { + "_id": "device-n::virtual.0", + "_vid": "device-n::virtual.0", + "primary": { + "data": { + "uuid-0007": { + "fromStatus": { + "context": { + "devicePid": "device-n", + "modulePid": "device-n.dev.1", + "portPid": "device-n.dev.1.51" + }, + "label": "eth-0-51" + }, + "id": "uuid-0007", + "toStatus": { + "context": { + "devicePid": "virtual-0", + "modulePid": "virtual-0.virt.0", + "portPid": "virtual-0.virt.0.2" + }, + "label": "Ethernet 2" + } + } + }, + "devicePid": "device-n", + "label": "Example Device" + }, + "secondary": { + "data": { + "uuid-0009": { + "fromStatus": { + "context": { + "devicePid": "virtual-0", + "modulePid": "virtual-0.virt.0", + "portPid": "virtual-0.virt.0.4" + }, + "label": "Ethernet 2" + }, + "id": "uuid-0009", + "toStatus": { + "context": { + "devicePid": "device-n", + "modulePid": "device-n.dev.1", + "portPid": "device-n.dev.1.51" + }, + "label": "eth-0-51" + } + } + }, + "devicePid": "virtual-0", + "label": "Virtual Device 1" + }, + "status": { + "alarm": 1, + "bandwidth": null, + "maintenance": null, + "ptp": 1 + } + }, + { + "_id": "device-q::device-c", + "_vid": "device-q::device-c", + "primary": { + "data": { + "device-q.0.Intel(R) Ethernet Controller (3) I225-LM #2.out::device-b.dev.module-1.port-in-1.in": { + "fromStatus": { + "context": { + "devicePid": "device-q", + "modulePid": "device-q.dev.0", + "portPid": "device-q.dev.0.Intel(R)-Ethernet-Controller-(3)-I225-LM-#2" + }, + "label": "Intel(R) Ethernet Controller (3) I225-LM #2 (out)" + }, + "id": "device-q.0.Intel(R) Ethernet Controller (3) I225-LM #2.out::device-b.dev.module-1.port-in-1.in", + "toStatus": { + "context": { + "devicePid": "device-c", + "modulePid": "device-c.dev.1", + "portPid": "device-c.dev.1.port-in-1" + }, + "label": "port-in-1" + } + } + }, + "devicePid": "device-q", + "label": "Example Device" + }, + "secondary": { + "data": {}, + "devicePid": "device-c", + "label": "Example Device" + }, + "status": { + "alarm": 1, + "bandwidth": null, + "maintenance": null, + "ptp": 1 + } + }, + { + "_id": "device-b::device-l", + "_vid": "device-b::device-l", + "primary": { + "data": { + "device-b.dev.module-1.port-in-1.out::device-l.1.Ethernet4_43.in": { + "fromStatus": { + "context": { + "devicePid": "device-b", + "modulePid": "device-b.dev.1", + "portPid": "device-b.dev.1.port-in-1" + }, + "label": "port-in-1" + }, + "id": "device-b.dev.module-1.port-in-1.out::device-l.1.Ethernet4_43.in", + "toStatus": { + "context": { + "devicePid": "device-l", + "modulePid": "device-l.dev.1", + "portPid": "device-l.dev.1.Ethernet4_43" + }, + "label": "port-in-1/43" + } + } + }, + "devicePid": "device-b", + "label": "Example Device" + }, + "secondary": { + "data": { + "device-l.1.Ethernet4_43.out::device-b.dev.module-1.port-in-1.in": { + "fromStatus": { + "context": { + "devicePid": "device-l", + "modulePid": "device-l.dev.1", + "portPid": "device-l.dev.1.Ethernet4_43" + }, + "label": "port-in-1/43" + }, + "id": "device-l.1.Ethernet4_43.out::device-b.dev.module-1.port-in-1.in", + "toStatus": { + "context": { + "devicePid": "device-b", + "modulePid": "device-b.dev.1", + "portPid": "device-b.dev.1.port-in-1" + }, + "label": "port-in-1" + } + } + }, + "devicePid": "device-l", + "label": "Example Device" + }, + "status": { + "alarm": 1, + "bandwidth": 1, + "maintenance": null, + "ptp": 1 + } + }, + { + "_id": "device-b::device-q", + "_vid": "device-b::device-q", + "primary": { + "data": {}, + "devicePid": "device-b", + "label": "Example Device" + }, + "secondary": { + "data": { + "device-q.0.Intel(R) Ethernet Controller (3) I225-LM.out::device-b.dev.module-1.port-in-1.in": { + "fromStatus": { + "context": { + "devicePid": "device-q", + "modulePid": "device-q.dev.0", + "portPid": "device-q.dev.0.Intel(R)-Ethernet-Controller-(3)-I225-LM" + }, + "label": "Intel(R) Ethernet Controller (3) I225-LM (out)" + }, + "id": "device-q.0.Intel(R) Ethernet Controller (3) I225-LM.out::device-b.dev.module-1.port-in-1.in", + "toStatus": { + "context": { + "devicePid": "device-b", + "modulePid": "device-b.dev.1", + "portPid": "device-b.dev.1.port-in-1" + }, + "label": "port-in-1" + } + } + }, + "devicePid": "device-q", + "label": "Example Device" + }, + "status": { + "alarm": 1, + "bandwidth": null, + "maintenance": null, + "ptp": 1 + } + }, + { + "_id": "device-b::device-w", + "_vid": "device-b::device-w", + "primary": { + "data": { + "device-b.dev.module-1.port-in-1.out::device-w.0.eth1.in": { + "fromStatus": { + "context": { + "devicePid": "device-b", + "modulePid": "device-b.dev.1", + "portPid": "device-b.dev.1.port-in-1" + }, + "label": "port-in-1" + }, + "id": "device-b.dev.module-1.port-in-1.out::device-w.0.eth1.in", + "toStatus": { + "context": { + "devicePid": "device-w", + "modulePid": "device-w.dev.0", + "portPid": "device-w.dev.0.eth1" + }, + "label": "eth1" + } + } + }, + "devicePid": "device-b", + "label": "Example Device" + }, + "secondary": { + "data": { + "device-w.0.eth1.out::device-b.dev.module-1.port-in-1.in": { + "fromStatus": { + "context": { + "devicePid": "device-w", + "modulePid": "device-w.dev.0", + "portPid": "device-w.dev.0.eth1" + }, + "label": "eth1" + }, + "id": "device-w.0.eth1.out::device-b.dev.module-1.port-in-1.in", + "toStatus": { + "context": { + "devicePid": "device-b", + "modulePid": "device-b.dev.1", + "portPid": "device-b.dev.1.port-in-1" + }, + "label": "port-in-1" + } + } + }, + "devicePid": "device-w", + "label": "Example Device" + }, + "status": { + "alarm": 1, + "bandwidth": null, + "maintenance": null, + "ptp": 1 + } + }, + { + "_id": "device-b::device-x", + "_vid": "device-b::device-x", + "primary": { + "data": { + "device-b.dev.module-1.port-in-1.out::device-x.0.eth1.in": { + "fromStatus": { + "context": { + "devicePid": "device-b", + "modulePid": "device-b.dev.1", + "portPid": "device-b.dev.1.port-in-1" + }, + "label": "port-in-1" + }, + "id": "device-b.dev.module-1.port-in-1.out::device-x.0.eth1.in", + "toStatus": { + "context": { + "devicePid": "device-x", + "modulePid": "device-x.dev.0", + "portPid": "device-x.dev.0.eth1" + }, + "label": "eth1" + } + } + }, + "devicePid": "device-b", + "label": "Example Device" + }, + "secondary": { + "data": { + "device-x.0.eth1.out::device-b.dev.module-1.port-in-1.in": { + "fromStatus": { + "context": { + "devicePid": "device-x", + "modulePid": "device-x.dev.0", + "portPid": "device-x.dev.0.eth1" + }, + "label": "eth1" + }, + "id": "device-x.0.eth1.out::device-b.dev.module-1.port-in-1.in", + "toStatus": { + "context": { + "devicePid": "device-b", + "modulePid": "device-b.dev.1", + "portPid": "device-b.dev.1.port-in-1" + }, + "label": "port-in-1" + } + } + }, + "devicePid": "device-x", + "label": "Example Device" + }, + "status": { + "alarm": 1, + "bandwidth": null, + "maintenance": null, + "ptp": 1 + } + }, + { + "_id": "device-s::device-d", + "_vid": "device-s::device-d", + "primary": { + "data": {}, + "devicePid": "device-s", + "label": "Example Device" + }, + "secondary": { + "data": { + "device-b.dev.module-1.port-in-1.out::device-s.0.P1.in": { + "fromStatus": { + "context": { + "devicePid": "device-d", + "modulePid": "device-d.dev.1", + "portPid": "device-d.dev.1.port-in-1" + }, + "label": "port-in-1" + }, + "id": "device-b.dev.module-1.port-in-1.out::device-s.0.P1.in", + "toStatus": { + "context": { + "devicePid": "device-s", + "modulePid": "device-s.dev.0", + "portPid": "device-s.dev.0.P1" + }, + "label": "P1 (in)" + } + } + }, + "devicePid": "device-d", + "label": "Example Device" + }, + "status": { + "alarm": 1, + "bandwidth": 1, + "maintenance": null, + "ptp": 1 + } + }, + { + "_id": "device-t::device-d", + "_vid": "device-t::device-d", + "primary": { + "data": {}, + "devicePid": "device-t", + "label": "Example Device" + }, + "secondary": { + "data": { + "device-b.dev.module-1.port-in-1.out::device-t.0.mmc0.in": { + "fromStatus": { + "context": { + "devicePid": "device-d", + "modulePid": "device-d.dev.1", + "portPid": "device-d.dev.1.port-in-1" + }, + "label": "port-in-1" + }, + "id": "device-b.dev.module-1.port-in-1.out::device-t.0.mmc0.in", + "toStatus": { + "context": { + "devicePid": "device-t", + "modulePid": "device-t.dev.0", + "portPid": "device-t.dev.0.mmc0" + }, + "label": "mmc0 (in)" + } + } + }, + "devicePid": "device-d", + "label": "Example Device" + }, + "status": { + "alarm": 1, + "bandwidth": 1, + "maintenance": null, + "ptp": 1 + } + }, + { + "_id": "device-u::device-d", + "_vid": "device-u::device-d", + "primary": { + "data": { + "device-u.3.10004.out::device-b.dev.module-1.port-in-1.in": { + "fromStatus": { + "context": { + "devicePid": "device-u", + "modulePid": "device-u.dev.3", + "portPid": "device-u.dev.3.10004" + }, + "label": "Ethernet 3.3" + }, + "id": "device-u.3.10004.out::device-b.dev.module-1.port-in-1.in", + "toStatus": { + "context": { + "devicePid": "device-d", + "modulePid": "device-d.dev.1", + "portPid": "device-d.dev.1.port-in-1" + }, + "label": "port-in-1" + } + } + }, + "devicePid": "device-u", + "label": "Example Device" + }, + "secondary": { + "data": { + "device-b.dev.module-1.port-in-1.out::device-u.3.10004.in": { + "fromStatus": { + "context": { + "devicePid": "device-d", + "modulePid": "device-d.dev.1", + "portPid": "device-d.dev.1.port-in-1" + }, + "label": "port-in-1" + }, + "id": "device-b.dev.module-1.port-in-1.out::device-u.3.10004.in", + "toStatus": { + "context": { + "devicePid": "device-u", + "modulePid": "device-u.dev.3", + "portPid": "device-u.dev.3.10004" + }, + "label": "Ethernet 3.3" + } + } + }, + "devicePid": "device-d", + "label": "Example Device" + }, + "status": { + "alarm": 1, + "bandwidth": 1, + "maintenance": null, + "ptp": 1 + } + }, + { + "_id": "device-c::device-w", + "_vid": "device-c::device-w", + "primary": { + "data": { + "device-b.dev.module-1.port-in-1.out::device-w.0.eth0.in": { + "fromStatus": { + "context": { + "devicePid": "device-c", + "modulePid": "device-c.dev.1", + "portPid": "device-c.dev.1.port-in-1" + }, + "label": "port-in-1" + }, + "id": "device-b.dev.module-1.port-in-1.out::device-w.0.eth0.in", + "toStatus": { + "context": { + "devicePid": "device-w", + "modulePid": "device-w.dev.0", + "portPid": "device-w.dev.0.eth0" + }, + "label": "eth0" + } + } + }, + "devicePid": "device-c", + "label": "Example Device" + }, + "secondary": { + "data": { + "device-w.0.eth0.out::device-b.dev.module-1.port-in-1.in": { + "fromStatus": { + "context": { + "devicePid": "device-w", + "modulePid": "device-w.dev.0", + "portPid": "device-w.dev.0.eth0" + }, + "label": "eth0" + }, + "id": "device-w.0.eth0.out::device-b.dev.module-1.port-in-1.in", + "toStatus": { + "context": { + "devicePid": "device-c", + "modulePid": "device-c.dev.1", + "portPid": "device-c.dev.1.port-in-1" + }, + "label": "port-in-1" + } + } + }, + "devicePid": "device-w", + "label": "Example Device" + }, + "status": { + "alarm": 1, + "bandwidth": null, + "maintenance": null, + "ptp": 1 + } + }, + { + "_id": "device-c::device-x", + "_vid": "device-c::device-x", + "primary": { + "data": { + "device-b.dev.module-1.port-in-1.out::device-x.0.eth0.in": { + "fromStatus": { + "context": { + "devicePid": "device-c", + "modulePid": "device-c.dev.1", + "portPid": "device-c.dev.1.port-in-1" + }, + "label": "port-in-1" + }, + "id": "device-b.dev.module-1.port-in-1.out::device-x.0.eth0.in", + "toStatus": { + "context": { + "devicePid": "device-x", + "modulePid": "device-x.dev.0", + "portPid": "device-x.dev.0.eth0" + }, + "label": "eth0" + } + } + }, + "devicePid": "device-c", + "label": "Example Device" + }, + "secondary": { + "data": { + "device-x.0.eth0.out::device-b.dev.module-1.port-in-1.in": { + "fromStatus": { + "context": { + "devicePid": "device-x", + "modulePid": "device-x.dev.0", + "portPid": "device-x.dev.0.eth0" + }, + "label": "eth0" + }, + "id": "device-x.0.eth0.out::device-b.dev.module-1.port-in-1.in", + "toStatus": { + "context": { + "devicePid": "device-c", + "modulePid": "device-c.dev.1", + "portPid": "device-c.dev.1.port-in-1" + }, + "label": "port-in-1" + } + } + }, + "devicePid": "device-x", + "label": "Example Device" + }, + "status": { + "alarm": 1, + "bandwidth": null, + "maintenance": null, + "ptp": 1 + } + }, + { + "_id": "device-d::device-f", + "_vid": "device-d::device-f", + "primary": { + "data": { + "device-b.dev.module-1.port-in-1.out::device-f.1.10004.in": { + "fromStatus": { + "context": { + "devicePid": "device-d", + "modulePid": "device-d.dev.1", + "portPid": "device-d.dev.1.port-in-1" + }, + "label": "port-in-1" + }, + "id": "device-b.dev.module-1.port-in-1.out::device-f.1.10004.in", + "toStatus": { + "context": { + "devicePid": "device-f", + "modulePid": "device-f.dev.1", + "portPid": "device-f.dev.1.10004" + }, + "label": "Ethernet 1.3" + } + } + }, + "devicePid": "device-d", + "label": "Example Device" + }, + "secondary": { + "data": { + "device-f.1.10004.out::device-b.dev.module-1.port-in-1.in": { + "fromStatus": { + "context": { + "devicePid": "device-f", + "modulePid": "device-f.dev.1", + "portPid": "device-f.dev.1.10004" + }, + "label": "Ethernet 1.3" + }, + "id": "device-f.1.10004.out::device-b.dev.module-1.port-in-1.in", + "toStatus": { + "context": { + "devicePid": "device-d", + "modulePid": "device-d.dev.1", + "portPid": "device-d.dev.1.port-in-1" + }, + "label": "port-in-1" + } + } + }, + "devicePid": "device-f", + "label": "Example Device" + }, + "status": { + "alarm": 1, + "bandwidth": 1, + "maintenance": null, + "ptp": 1 + } + }, + { + "_id": "device-d::device-g", + "_vid": "device-d::device-g", + "primary": { + "data": { + "device-b.dev.module-1.port-in-1.out::device-g.6.10004.in": { + "fromStatus": { + "context": { + "devicePid": "device-d", + "modulePid": "device-d.dev.1", + "portPid": "device-d.dev.1.port-in-1" + }, + "label": "port-in-1" + }, + "id": "device-b.dev.module-1.port-in-1.out::device-g.6.10004.in", + "toStatus": { + "context": { + "devicePid": "device-g", + "modulePid": "device-g.dev.6", + "portPid": "device-g.dev.6.10004" + }, + "label": "Ethernet 6.3" + } + } + }, + "devicePid": "device-d", + "label": "Example Device" + }, + "secondary": { + "data": { + "device-g.6.10004.out::device-b.dev.module-1.port-in-1.in": { + "fromStatus": { + "context": { + "devicePid": "device-g", + "modulePid": "device-g.dev.6", + "portPid": "device-g.dev.6.10004" + }, + "label": "Ethernet 6.3" + }, + "id": "device-g.6.10004.out::device-b.dev.module-1.port-in-1.in", + "toStatus": { + "context": { + "devicePid": "device-d", + "modulePid": "device-d.dev.1", + "portPid": "device-d.dev.1.port-in-1" + }, + "label": "port-in-1" + } + } + }, + "devicePid": "device-g", + "label": "Example Device" + }, + "status": { + "alarm": 1, + "bandwidth": 1, + "maintenance": null, + "ptp": 1 + } + } + ] + } + } + } + }, + "header": { + "auth": true, + "caption": "Operation Successful", + "code": "OK", + "errorCodes": [], + "errorDetails": [], + "id": "", + "msg": [], + "ok": true, + "user": "api-user" + } +} diff --git a/tests/inspect/fixtures/2025.4.9/inspect_paths_limit5.json b/tests/inspect/fixtures/2025.4.9/inspect_paths_limit5.json new file mode 100644 index 0000000..038b879 --- /dev/null +++ b/tests/inspect/fixtures/2025.4.9/inspect_paths_limit5.json @@ -0,0 +1,75 @@ +{ + "data": { + "status": { + "collector": { + "inspect": { + "paths": { + "_items": [ + { + "_id": "booking-1001::main", + "_vid": "_:booking-1001::main", + "serviceFields": { + "bid": "booking-1001", + "from": "topo:device-a.1.101S", + "isMain": true, + "to": "topo:device-b.uuid-0001.R00000001-0000-4000-8000-000000000001" + } + }, + { + "_id": "booking-1001::spare", + "_vid": "_:booking-1001::spare", + "serviceFields": { + "bid": "booking-1001", + "from": "topo:device-a.1.101S", + "isMain": false, + "to": "topo:device-b.uuid-0001.R00000001-0000-4000-8000-000000000001" + } + }, + { + "_id": "booking-1002::main", + "_vid": "_:booking-1002::main", + "serviceFields": { + "bid": "booking-1002", + "from": "topo:device-b.uuid-0001.S00000005-0000-4000-8000-000000000005", + "isMain": true, + "to": "topo:device-c.3.3000000" + } + }, + { + "_id": "booking-1002::spare", + "_vid": "_:booking-1002::spare", + "serviceFields": { + "bid": "booking-1002", + "from": "topo:device-b.uuid-0001.S00000005-0000-4000-8000-000000000005", + "isMain": false, + "to": "topo:device-c.3.3000000" + } + }, + { + "_id": "booking-1003::main", + "_vid": "_:booking-1003::main", + "serviceFields": { + "bid": "booking-1003", + "from": "topo:device-b.uuid-0001.S00000006-0000-4000-8000-000000000006", + "isMain": true, + "to": "topo:device-c.3.5000000" + } + } + ] + } + } + } + } + }, + "header": { + "auth": true, + "caption": "Operation Successful", + "code": "OK", + "errorCodes": [], + "errorDetails": [], + "id": "", + "msg": [], + "ok": true, + "user": "api-user" + } +} diff --git a/tests/inspect/fixtures/2025.4.9/lookup_inspect_codec_vertex_by_id.json b/tests/inspect/fixtures/2025.4.9/lookup_inspect_codec_vertex_by_id.json new file mode 100644 index 0000000..bbaacb6 --- /dev/null +++ b/tests/inspect/fixtures/2025.4.9/lookup_inspect_codec_vertex_by_id.json @@ -0,0 +1,66 @@ +{ + "data": { + "id": "device-a.module-1.port-out-1.out", + "isVirtual": false, + "vertexType": "In", + "customSchemas": {}, + "assignedTags": { "all": [], "inherited": {}, "inheritedConflict": false, "local": {} }, + "context": {}, + "fields": { + "active": true, + "controlProps": { "configPriority": "off", "onlyInitial": false }, + "custom": {}, + "desc": "", + "destinationMonitorLeader": false, + "extraAlertFilters": [], + "label": "codec-out-1", + "localAssignedTags": [], + "queueable": false, + "sipsMode": "NONE", + "tags": [], + "typeFields": { + "ipAddress": null, + "ipNetmask": null, + "parkPort": null, + "public": null, + "supportsCpipeCfg": null, + "supportsIgmpCfg": null, + "supportsMacForwardingCfg": null, + "supportsNsoCfg": null, + "supportsOpenflowCfg": null, + "supportsStaticIgmpCfg": null, + "supportsVlanCfg": null, + "supportsVplsCfg": null, + "type": "codec", + "vlanId": null, + "vrfId": null, + "generic": { + "bidirPartnerId": null, + "codecFormat": "Video", + "extraFormats": [], + "mainDstInfo": { "ip": "10.0.0.1", "mac": null, "port": 5000, "vlan": null }, + "mainSrcInfo": { "gateway": null, "ip": "10.0.0.2", "mac": null, "netmask": "255.255.255.0" }, + "multiplicity": 1, + "partnerConfig": null, + "public": false, + "serviceId": null, + "spareDstInfo": { "ip": null, "mac": null, "port": null, "vlan": null }, + "spareSrcInfo": { "gateway": null, "ip": null, "mac": null, "netmask": null } + }, + "specific": { "isIgmpSource": false, "sdpSupport": true, "type": "video" } + }, + "useAsEndpoint": false + } + }, + "header": { + "auth": true, + "caption": "Operation Successful", + "code": "OK", + "errorCodes": [], + "errorDetails": [], + "id": "0", + "msg": [], + "ok": true, + "user": "api-user" + } +} diff --git a/tests/inspect/fixtures/2025.4.9/lookup_inspect_edges_by_ids.json b/tests/inspect/fixtures/2025.4.9/lookup_inspect_edges_by_ids.json new file mode 100644 index 0000000..a536022 --- /dev/null +++ b/tests/inspect/fixtures/2025.4.9/lookup_inspect_edges_by_ids.json @@ -0,0 +1,49 @@ +{ + "data": { + "device-a.module-1.port-out-1.out::device-b.module-1.port-in-1.in": { + "edge": { + "active": true, + "bandwidth": -1.0, + "capacity": 65535, + "conflictPri": 0, + "descriptor": { + "desc": "", + "label": "" + }, + "excludeFormats": [], + "fDescriptor": { + "desc": "", + "label": "" + }, + "fromId": "device-a.module-1.port-out-1.out", + "includeFormats": [], + "redundancyMode": "Any", + "tags": [], + "toId": "device-b.module-1.port-in-1.in", + "weight": 1, + "weightFactors": { + "bandwidth": { + "weight": 0 + }, + "service": { + "max": 100, + "weight": 0 + } + } + }, + "fromDevice": "device-a", + "toDevice": "device-b" + } + }, + "header": { + "auth": true, + "caption": "Operation Successful", + "code": "OK", + "errorCodes": [], + "errorDetails": [], + "id": "0", + "msg": [], + "ok": true, + "user": "api-user" + } +} \ No newline at end of file diff --git a/tests/inspect/fixtures/2025.4.9/lookup_inspect_router_vertex_by_id.json b/tests/inspect/fixtures/2025.4.9/lookup_inspect_router_vertex_by_id.json new file mode 100644 index 0000000..b678595 --- /dev/null +++ b/tests/inspect/fixtures/2025.4.9/lookup_inspect_router_vertex_by_id.json @@ -0,0 +1,48 @@ +{ + "data": { + "assignedTags": { + "all": [], + "inherited": {}, + "inheritedConflict": false, + "local": {} + }, + "context": { + "devicePid": "device-a", + "modulePid": "device-a.dev.module-1", + "portPid": "device-a.dev.module-1.port-in-1" + }, + "customSchemas": {}, + "fields": { + "active": true, + "controlProps": null, + "custom": {}, + "desc": "", + "destinationMonitorLeader": false, + "extraAlertFilters": [], + "label": "Router In 11.1", + "localAssignedTags": [], + "queueable": false, + "sipsMode": "NONE", + "tags": [], + "typeFields": { + "parkPort": 42, + "type": "router" + }, + "useAsEndpoint": false + }, + "id": "device-a.module-1.port-in-1.in", + "isVirtual": false, + "vertexType": "In" + }, + "header": { + "auth": true, + "caption": "Operation Successful", + "code": "OK", + "errorCodes": [], + "errorDetails": [], + "id": "0", + "msg": [], + "ok": true, + "user": "api-user" + } +} diff --git a/tests/inspect/fixtures/2025.4.9/lookup_inspect_vertex_by_id.json b/tests/inspect/fixtures/2025.4.9/lookup_inspect_vertex_by_id.json new file mode 100644 index 0000000..5064343 --- /dev/null +++ b/tests/inspect/fixtures/2025.4.9/lookup_inspect_vertex_by_id.json @@ -0,0 +1,61 @@ +{ + "data": { + "assignedTags": { + "all": [], + "inherited": {}, + "inheritedConflict": false, + "local": {} + }, + "context": { + "devicePid": "device-a", + "modulePid": "device-a.dev.module-1", + "portPid": "device-a.dev.module-1.port-out-1" + }, + "customSchemas": {}, + "fields": { + "active": true, + "controlProps": null, + "custom": {}, + "desc": "", + "destinationMonitorLeader": false, + "extraAlertFilters": [], + "label": "Port A (out)", + "localAssignedTags": [], + "queueable": false, + "sipsMode": "NONE", + "tags": [], + "typeFields": { + "ipAddress": null, + "ipNetmask": null, + "parkPort": null, + "public": false, + "supportsCpipeCfg": false, + "supportsIgmpCfg": false, + "supportsMacForwardingCfg": false, + "supportsNsoCfg": false, + "supportsOpenflowCfg": false, + "supportsStaticIgmpCfg": false, + "supportsVlanCfg": false, + "supportsVplsCfg": false, + "type": "ip", + "vlanId": null, + "vrfId": null + }, + "useAsEndpoint": false + }, + "id": "device-a.module-1.port-out-1.out", + "isVirtual": false, + "vertexType": "Out" + }, + "header": { + "auth": true, + "caption": "Operation Successful", + "code": "OK", + "errorCodes": [], + "errorDetails": [], + "id": "0", + "msg": [], + "ok": true, + "user": "api-user" + } +} \ No newline at end of file diff --git a/tests/inspect/fixtures/2025.4.9/lookup_inspect_vertices_by_ids.json b/tests/inspect/fixtures/2025.4.9/lookup_inspect_vertices_by_ids.json new file mode 100644 index 0000000..47d1746 --- /dev/null +++ b/tests/inspect/fixtures/2025.4.9/lookup_inspect_vertices_by_ids.json @@ -0,0 +1,66 @@ +{ + "data": { + "device-a.module-1.port-out-1.out": { + "assignedTags": { + "all": [], + "inherited": {}, + "inheritedConflict": false, + "local": {} + }, + "context": { + "devicePid": "device-a", + "modulePid": "device-a.dev.module-1", + "portPid": "device-a.dev.module-1.port-out-1" + }, + "customSchemas": {}, + "fields": { + "active": true, + "controlProps": { + "configPriority": "off", + "onlyInitial": false + }, + "custom": {}, + "desc": "", + "destinationMonitorLeader": false, + "extraAlertFilters": [], + "label": "Port A (out)", + "localAssignedTags": [], + "queueable": false, + "sipsMode": "NONE", + "tags": [], + "typeFields": { + "ipAddress": null, + "ipNetmask": null, + "parkPort": null, + "public": false, + "supportsCpipeCfg": false, + "supportsIgmpCfg": false, + "supportsMacForwardingCfg": false, + "supportsNsoCfg": false, + "supportsOpenflowCfg": false, + "supportsStaticIgmpCfg": false, + "supportsVlanCfg": false, + "supportsVplsCfg": false, + "type": "ip", + "vlanId": null, + "vrfId": null + }, + "useAsEndpoint": false + }, + "id": "device-a.module-1.port-out-1.out", + "isVirtual": false, + "vertexType": "Out" + } + }, + "header": { + "auth": true, + "caption": "Operation Successful", + "code": "OK", + "errorCodes": [], + "errorDetails": [], + "id": "0", + "msg": [], + "ok": true, + "user": "api-user" + } +} diff --git a/tests/inspect/fixtures/2025.4.9/lookup_inspect_virtual_device.json b/tests/inspect/fixtures/2025.4.9/lookup_inspect_virtual_device.json new file mode 100644 index 0000000..18a23da --- /dev/null +++ b/tests/inspect/fixtures/2025.4.9/lookup_inspect_virtual_device.json @@ -0,0 +1,48 @@ +{ + "data": { + "assignedTags": { + "all": [], + "inherited": {}, + "inheritedConflict": false, + "local": {} + }, + "fields": { + "coordinates": null, + "descriptor": { + "desc": "", + "label": "Virtual Device 1" + }, + "iconSize": "medium", + "iconType": "default", + "localAssignedTags": [], + "sdpStrategy": "always", + "siteId": null, + "tags": [], + "virtualDeviceFields": { + "dynamic": [ + { + "moduleNumber": 0, + "vertices": [ + { + "count": 1, + "templateId": "generic_bidir" + } + ] + } + ], + "manual": [] + } + } + }, + "header": { + "auth": true, + "caption": "Operation Successful", + "code": "OK", + "errorCodes": [], + "errorDetails": [], + "id": "0", + "msg": [], + "ok": true, + "user": "api-user" + } +} diff --git a/tests/inspect/fixtures/2025.4.9/nodestatus_noid_collection.json b/tests/inspect/fixtures/2025.4.9/nodestatus_noid_collection.json new file mode 100644 index 0000000..5ad0c1a --- /dev/null +++ b/tests/inspect/fixtures/2025.4.9/nodestatus_noid_collection.json @@ -0,0 +1,265 @@ +{ + "data": { + "status": { + "collector": { + "inspect": { + "nodeStatus": { + "_items": [ + { + "_id": "device-h", + "_vid": "device-h", + "descriptor": { + "label": "Example Device A" + }, + "deviceId": "device-h" + }, + { + "_id": "device-i", + "_vid": "device-i", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-i" + }, + { + "_id": "device-j", + "_vid": "device-j", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-j" + }, + { + "_id": "device-a", + "_vid": "device-a", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-a" + }, + { + "_id": "device-k", + "_vid": "device-k", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-k" + }, + { + "_id": "device-b", + "_vid": "device-b", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-b" + }, + { + "_id": "device-l", + "_vid": "device-l", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-l" + }, + { + "_id": "device-m", + "_vid": "device-m", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-m" + }, + { + "_id": "device-n", + "_vid": "device-n", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-n" + }, + { + "_id": "device-o", + "_vid": "device-o", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-o" + }, + { + "_id": "device-p", + "_vid": "device-p", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-p" + }, + { + "_id": "device-q", + "_vid": "device-q", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-q" + }, + { + "_id": "device-r", + "_vid": "device-r", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-r" + }, + { + "_id": "device-s", + "_vid": "device-s", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-s" + }, + { + "_id": "device-c", + "_vid": "device-c", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-c" + }, + { + "_id": "device-t", + "_vid": "device-t", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-t" + }, + { + "_id": "device-u", + "_vid": "device-u", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-u" + }, + { + "_id": "device-v", + "_vid": "device-v", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-v" + }, + { + "_id": "device-w", + "_vid": "device-w", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-w" + }, + { + "_id": "device-x", + "_vid": "device-x", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-x" + }, + { + "_id": "device-y", + "_vid": "device-y", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-y" + }, + { + "_id": "device-z", + "_vid": "device-z", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-z" + }, + { + "_id": "device-1", + "_vid": "device-1", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-1" + }, + { + "_id": "device-2", + "_vid": "device-2", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-2" + }, + { + "_id": "device-d", + "_vid": "device-d", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-d" + }, + { + "_id": "device-e", + "_vid": "device-e", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-e" + }, + { + "_id": "device-f", + "_vid": "device-f", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-f" + }, + { + "_id": "device-g", + "_vid": "device-g", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-g" + }, + { + "_id": "virtual-0", + "_vid": "virtual-0", + "descriptor": { + "label": "Virtual Device 1" + }, + "deviceId": "virtual.0" + }, + { + "_id": "virtual-1", + "_vid": "virtual-1", + "descriptor": { + "label": "Virtual Device 1" + }, + "deviceId": "virtual.1" + } + ] + } + } + } + } + }, + "header": { + "auth": true, + "caption": "Operation Successful", + "code": "OK", + "errorCodes": [], + "errorDetails": [], + "id": "", + "msg": [], + "ok": true, + "user": "api-user" + } +} diff --git a/tests/inspect/fixtures/2025.4.9/skeleton_nodestatus_short.json b/tests/inspect/fixtures/2025.4.9/skeleton_nodestatus_short.json new file mode 100644 index 0000000..eed824f --- /dev/null +++ b/tests/inspect/fixtures/2025.4.9/skeleton_nodestatus_short.json @@ -0,0 +1,269 @@ +{ + "data": { + "status": { + "collector": { + "inspect": { + "nodeStatus": { + "_items": [ + { + "_id": "device-h", + "_vid": "device-h", + "descriptor": { + "desc": "Example device description", + "label": "Example Device A" + }, + "deviceId": "device-h", + "resourceId": "device:device-h" + }, + { + "_id": "device-i", + "_vid": "device-i", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-i", + "resourceId": "device:device-i" + }, + { + "_id": "device-a", + "_vid": "device-a", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-a", + "resourceId": "device:device-a" + }, + { + "_id": "device-j", + "_vid": "device-j", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-j", + "resourceId": "device:device-j" + }, + { + "_id": "device-b", + "_vid": "device-b", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-b", + "resourceId": "device:device-b" + }, + { + "_id": "device-k", + "_vid": "device-k", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-k", + "resourceId": "device:device-k" + }, + { + "_id": "device-l", + "_vid": "device-l", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-l", + "resourceId": "device:device-l" + }, + { + "_id": "device-m", + "_vid": "device-m", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-m", + "resourceId": "device:device-m" + }, + { + "_id": "device-n", + "_vid": "device-n", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-n", + "resourceId": "device:device-n" + }, + { + "_id": "device-o", + "_vid": "device-o", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-o", + "resourceId": "device:device-o" + }, + { + "_id": "device-p", + "_vid": "device-p", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-p", + "resourceId": "device:device-p" + }, + { + "_id": "device-q", + "_vid": "device-q", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-q", + "resourceId": "device:device-q" + }, + { + "_id": "device-c", + "_vid": "device-c", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-c", + "resourceId": "device:device-c" + }, + { + "_id": "device-r", + "_vid": "device-r", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-r", + "resourceId": "device:device-r" + }, + { + "_id": "device-s", + "_vid": "device-s", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-s", + "resourceId": "device:device-s" + }, + { + "_id": "device-t", + "_vid": "device-t", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-t", + "resourceId": "device:device-t" + }, + { + "_id": "device-u", + "_vid": "device-u", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-u", + "resourceId": "device:device-u" + }, + { + "_id": "device-v", + "_vid": "device-v", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-v", + "resourceId": "device:device-v" + }, + { + "_id": "device-w", + "_vid": "device-w", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-w", + "resourceId": "device:device-w" + }, + { + "_id": "device-x", + "_vid": "device-x", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-x", + "resourceId": "device:device-x" + }, + { + "_id": "device-y", + "_vid": "device-y", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-y", + "resourceId": "device:device-y" + }, + { + "_id": "device-d", + "_vid": "device-d", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-d", + "resourceId": "device:device-d" + }, + { + "_id": "device-e", + "_vid": "device-e", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-e", + "resourceId": "device:device-e" + }, + { + "_id": "device-f", + "_vid": "device-f", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-f", + "resourceId": "device:device-f" + }, + { + "_id": "device-g", + "_vid": "device-g", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-g", + "resourceId": "device:device-g" + }, + { + "_id": "virtual-0", + "_vid": "virtual-0", + "descriptor": { + "label": "Virtual Device 1" + }, + "deviceId": "virtual.0", + "resourceId": "device:virtual-0" + }, + { + "_id": "virtual-1", + "_vid": "virtual-1", + "descriptor": { + "label": "Virtual Device 1" + }, + "deviceId": "virtual.1", + "resourceId": "device:virtual-1" + } + ] + } + } + } + } + }, + "header": { + "auth": true, + "caption": "Operation Successful", + "code": "OK", + "errorCodes": [], + "errorDetails": [], + "id": "", + "msg": [], + "ok": true, + "user": "api-user" + } +} diff --git a/tests/inspect/fixtures/2025.4.9/update_topology_fail_booking.json b/tests/inspect/fixtures/2025.4.9/update_topology_fail_booking.json new file mode 100644 index 0000000..8fa64a7 --- /dev/null +++ b/tests/inspect/fixtures/2025.4.9/update_topology_fail_booking.json @@ -0,0 +1,49 @@ +{ + "data": { + "items": [], + "res": { + "msg": [ + "Validation failed" + ], + "ok": false + }, + "validation": { + "createIds": [], + "details": { + "booking-1001": { + "isCancel": false, + "isProduct": false, + "resolvable": false, + "rev": "2-2026-06-10T19:54:01.297948842Z[UTC]", + "status": -22, + "type": "generic" + }, + "booking-1002": { + "isCancel": false, + "isProduct": false, + "resolvable": false, + "rev": "2-2026-06-16T14:47:24.964022664Z[UTC]", + "status": -22, + "type": "generic" + } + }, + "result": { + "msg": [ + "A required edge was not found. (main); A required edge was not found. (redundant)" + ], + "ok": false + } + } + }, + "header": { + "auth": true, + "caption": "Operation Successful", + "code": "OK", + "errorCodes": [], + "errorDetails": [], + "id": "0", + "msg": [], + "ok": true, + "user": "api-user" + } +} diff --git a/tests/inspect/fixtures/2025.4.9/update_topology_fail_remove.json b/tests/inspect/fixtures/2025.4.9/update_topology_fail_remove.json new file mode 100644 index 0000000..9beb95e --- /dev/null +++ b/tests/inspect/fixtures/2025.4.9/update_topology_fail_remove.json @@ -0,0 +1,30 @@ +{ + "data": { + "items": [], + "res": { + "msg": [ + "Failed to update local edges: Cannot remove non-existent object with key nonexistent-edge-id-xyz!" + ], + "ok": false + }, + "validation": { + "createIds": [], + "details": {}, + "result": { + "msg": [], + "ok": true + } + } + }, + "header": { + "auth": true, + "caption": "Operation Successful", + "code": "OK", + "errorCodes": [], + "errorDetails": [], + "id": "0", + "msg": [], + "ok": true, + "user": "api-user" + } +} diff --git a/tests/inspect/fixtures/2025.4.9/update_topology_replace_devices.json b/tests/inspect/fixtures/2025.4.9/update_topology_replace_devices.json new file mode 100644 index 0000000..be47ae5 --- /dev/null +++ b/tests/inspect/fixtures/2025.4.9/update_topology_replace_devices.json @@ -0,0 +1,72 @@ +{ + "request": { + "header": { "id": 0 }, + "data": { + "replaceDevices": { + "device-a": { + "coordinates": { "x": 1600.0, "y": 9050.0 }, + "descriptor": { "desc": "", "label": "" }, + "iconSize": "medium", + "iconType": "default", + "localAssignedTags": [], + "sdpStrategy": "always", + "siteId": null, + "tags": [], + "virtualDeviceFields": null + } + }, + "replaceVertices": {}, + "replaceEdges": {}, + "replaceResourceTransforms": {}, + "addExternalEdges": [], + "remove": [], + "force": false + } + }, + "response": { + "data": { + "items": [ + { + "external": null, + "id": "device-a", + "idx": null, + "res": { "msg": [""], "ok": true } + } + ], + "res": { "msg": [], "ok": true }, + "validation": { + "createIds": [], + "details": {}, + "result": { "msg": [], "ok": true } + } + }, + "header": { "ok": true, "code": "OK" } + }, + "rejected_request_error": { + "note": "Sending the raw persisted baseDevice element (maps[], fDescriptor, type) instead of the edit form is rejected:", + "header": { + "auth": true, + "caption": "Invalid Request", + "code": "INVALID_REQUEST", + "errorDetails": [ + { + "msg": "Mandatory field 'localAssignedTags' not present in input", + "path": ["replaceDevices", "device-a", "localAssignedTags"], + "type": "conversionError" + }, + { + "msg": "Mandatory field 'coordinates' not present in input", + "path": ["replaceDevices", "device-a", "coordinates"], + "type": "conversionError" + } + ], + "id": "0", + "msg": [ + "Mandatory field 'localAssignedTags' not present in input", + "Mandatory field 'coordinates' not present in input" + ], + "ok": false, + "user": "api-user" + } + } +} diff --git a/tests/inspect/fixtures/2025.4.9/update_topology_replace_vertices.json b/tests/inspect/fixtures/2025.4.9/update_topology_replace_vertices.json new file mode 100644 index 0000000..bda0a35 --- /dev/null +++ b/tests/inspect/fixtures/2025.4.9/update_topology_replace_vertices.json @@ -0,0 +1,81 @@ +{ + "request": { + "header": { "id": 0 }, + "data": { + "replaceDevices": {}, + "replaceVertices": { + "device-a.module-1.port-out-1.out": { + "active": true, + "controlProps": null, + "custom": {}, + "desc": "", + "destinationMonitorLeader": false, + "extraAlertFilters": [], + "label": "", + "localAssignedTags": [], + "queueable": false, + "sipsMode": "NONE", + "tags": [], + "typeFields": { + "ipAddress": null, + "ipNetmask": null, + "parkPort": null, + "public": false, + "supportsCpipeCfg": false, + "supportsIgmpCfg": false, + "supportsMacForwardingCfg": false, + "supportsNsoCfg": false, + "supportsOpenflowCfg": false, + "supportsStaticIgmpCfg": false, + "supportsVlanCfg": false, + "supportsVplsCfg": false, + "type": "ip", + "vlanId": null, + "vrfId": null + }, + "useAsEndpoint": false + } + }, + "replaceEdges": {}, + "replaceResourceTransforms": {}, + "addExternalEdges": [], + "remove": [], + "force": false + } + }, + "response": { + "data": { + "items": [ + { + "external": null, + "id": "device-a.module-1.port-out-1.out", + "idx": null, + "res": { "msg": [""], "ok": true } + } + ], + "res": { "msg": [], "ok": true }, + "validation": { + "createIds": [], + "details": {}, + "result": { "msg": [], "ok": true } + } + }, + "header": { "ok": true, "code": "OK" } + }, + "unknown_id_validation_failure": { + "note": "Committing a vertex id that does not exist in the graph passes schema conversion but fails validation — replaceVertices is update-only:", + "data": { + "items": [], + "res": { "msg": ["Validation failed"], "ok": false }, + "validation": { + "createIds": [], + "details": {}, + "result": { + "msg": ["Vertex with id device-a.module-9.new-port.out was not found in graph"], + "ok": false + } + } + }, + "header": { "ok": true, "code": "OK" } + } +} diff --git a/tests/inspect/fixtures/2025.4.9/update_topology_success.json b/tests/inspect/fixtures/2025.4.9/update_topology_success.json new file mode 100644 index 0000000..30681d9 --- /dev/null +++ b/tests/inspect/fixtures/2025.4.9/update_topology_success.json @@ -0,0 +1,40 @@ +{ + "data": { + "items": [ + { + "external": null, + "id": "device-a.dev.module-1.port-out-1.out::device-b.dev.module-1.port-in-1.in", + "idx": 0, + "res": { + "msg": [ + "" + ], + "ok": true + } + } + ], + "res": { + "msg": [], + "ok": true + }, + "validation": { + "createIds": [], + "details": {}, + "result": { + "msg": [], + "ok": true + } + } + }, + "header": { + "auth": true, + "caption": "Operation Successful", + "code": "OK", + "errorCodes": [], + "errorDetails": [], + "id": "0", + "msg": [], + "ok": true, + "user": "api-user" + } +} diff --git a/tests/inspect/fixtures/2025.4.9/update_virtual_instances_create.json b/tests/inspect/fixtures/2025.4.9/update_virtual_instances_create.json new file mode 100644 index 0000000..45e8725 --- /dev/null +++ b/tests/inspect/fixtures/2025.4.9/update_virtual_instances_create.json @@ -0,0 +1,30 @@ +{ + "data": { + "addedDeviceLabels": { + "virtual.1": "Virtual Device 1" + }, + "res": { + "msg": [], + "ok": true + }, + "validation": { + "createIds": [], + "details": {}, + "result": { + "msg": [], + "ok": true + } + } + }, + "header": { + "auth": true, + "caption": "Operation Successful", + "code": "OK", + "errorCodes": [], + "errorDetails": [], + "id": "0", + "msg": [], + "ok": true, + "user": "api-user" + } +} diff --git a/tests/inspect/fixtures/2025.4.9/virtual_devices.json b/tests/inspect/fixtures/2025.4.9/virtual_devices.json new file mode 100644 index 0000000..61d5277 --- /dev/null +++ b/tests/inspect/fixtures/2025.4.9/virtual_devices.json @@ -0,0 +1,43 @@ +{ + "data": { + "status": { + "network": { + "virtualDevices": { + "_items": [ + { + "_id": "virtual.1", + "_vid": "virtual.1", + "modules": [ + { + "moduleNumber": 0, + "vertices": [ + { "count": 1, "templateId": "ip_in" }, + { "count": 1, "templateId": "ip_out" }, + { "count": 5, "templateId": "video_in" }, + { "count": 5, "templateId": "video_out" } + ] + } + ] + }, + { + "_id": "virtual.2", + "_vid": "virtual.2", + "modules": [] + } + ] + } + } + } + }, + "header": { + "auth": true, + "caption": "Operation Successful", + "code": "OK", + "errorCodes": [], + "errorDetails": [], + "id": "", + "msg": [], + "ok": true, + "user": "api-user" + } +} diff --git a/tests/inspect/fixtures/2025.4.9/virtual_templates.json b/tests/inspect/fixtures/2025.4.9/virtual_templates.json new file mode 100644 index 0000000..be20f1b --- /dev/null +++ b/tests/inspect/fixtures/2025.4.9/virtual_templates.json @@ -0,0 +1,72 @@ +{ + "data": { + "status": { + "network": { + "virtualTemplates": { + "_items": [ + { + "_id": "generic_bidir", + "_vid": "generic_bidir", + "label": "Generic bidir", + "vertex": { + "active": true, + "configPriority": "off", + "control": "off", + "custom": {}, + "descriptor": { "desc": "", "label": "" }, + "deviceId": "", + "extraAlertFilters": [], + "fDescriptor": { "desc": "", "label": "Virtual Vertex Template" }, + "gpid": { "component": 1, "pointId": [] }, + "imgUrl": "", + "isVirtual": true, + "maps": [], + "sipsMode": "NONE", + "tags": [], + "type": "genericVertex", + "useAsEndpoint": false, + "vertexType": "BiDirectional" + } + }, + { + "_id": "video_in", + "_vid": "video_in", + "label": "Video in", + "vertex": { + "active": true, + "codecFormat": "Video", + "configPriority": "off", + "control": "off", + "custom": {}, + "descriptor": { "desc": "", "label": "" }, + "deviceId": "", + "extraAlertFilters": [], + "fDescriptor": { "desc": "", "label": "Virtual Vertex Template" }, + "gpid": { "component": 1, "pointId": [] }, + "imgUrl": "", + "isVirtual": true, + "maps": [], + "sipsMode": "NONE", + "tags": [], + "type": "codecVertex", + "useAsEndpoint": false, + "vertexType": "In" + } + } + ] + } + } + } + }, + "header": { + "auth": true, + "caption": "Operation Successful", + "code": "OK", + "errorCodes": [], + "errorDetails": [], + "id": "", + "msg": [], + "ok": true, + "user": "api-user" + } +} diff --git a/tests/inspect/test_actions.py b/tests/inspect/test_actions.py new file mode 100644 index 0000000..71a5ccd --- /dev/null +++ b/tests/inspect/test_actions.py @@ -0,0 +1,211 @@ +"""Network-action mixin tests with a fake connector.""" + +from __future__ import annotations + +import logging +from types import SimpleNamespace +from typing import Any + +import pytest + +from videoipath_automation_tool.apps.inspect.app.actions import ConflictStrategy, InspectActionsMixin +from videoipath_automation_tool.apps.inspect.api import InspectAPI + +_ADD_DEVICES = "/rest/v2/actions/status/network/addDevices" +_SYNC_DEVICES = "/rest/v2/actions/status/network/syncDevices" + + +class FakeRest: + def __init__( + self, + post_data: dict[str, Any] | None = None, + *, + by_path: dict[str, dict[str, Any]] | None = None, + ) -> None: + self._post_data = post_data if post_data is not None else {"msg": [], "ok": True} + self._by_path = by_path or {} + self.post_calls: list[tuple[str, dict[str, Any]]] = [] + + def post(self, url_path: str, body: Any, **kwargs: Any) -> SimpleNamespace: + self.post_calls.append((url_path, body.model_dump(mode="json", by_alias=True))) + data = self._by_path.get(url_path, self._post_data) + return SimpleNamespace(data=data, header=_ok_header()) + + +def test_conflict_strategy_values() -> None: + assert int(ConflictStrategy.STRICT) == 0 + assert int(ConflictStrategy.INVALIDATE_SERVICES) == 1 + assert int(ConflictStrategy.CANCEL_SERVICES) == 2 + + +def test_add_devices_builds_placement_items() -> None: + app = _App() + assert app.add_devices_to_topology([("device12", 100, 200), "device13"], sync=False) is True + paths = [path for path, _ in app._inspect_api.vip_connector.rest.post_calls] + assert paths == [_ADD_DEVICES] + _, payload = app._inspect_api.vip_connector.rest.post_calls[0] + assert payload["data"] == [{"id": "device12", "x": 100, "y": 200}, {"id": "device13", "x": 0, "y": 0}] + + +def test_add_devices_syncs_by_default() -> None: + app = _App() + assert app.add_devices_to_topology([("device12", 100, 200), "device13"]) is True + rest = app._inspect_api.vip_connector.rest + assert [path for path, _ in rest.post_calls] == [_ADD_DEVICES, _SYNC_DEVICES] + assert rest.post_calls[0][1]["data"] == [ + {"id": "device12", "x": 100, "y": 200}, + {"id": "device13", "x": 0, "y": 0}, + ] + assert rest.post_calls[1][1]["data"] == { + "ids": ["device12", "device13"], + "addOnly": True, + "conflictStrategy": 0, + } + + +def test_add_devices_passes_sync_options() -> None: + app = _App() + assert ( + app.add_devices_to_topology( + ["device12"], + sync=True, + add_only=False, + conflict_strategy=ConflictStrategy.CANCEL_SERVICES, + ) + is True + ) + _, sync_payload = app._inspect_api.vip_connector.rest.post_calls[1] + assert sync_payload["data"] == { + "ids": ["device12"], + "addOnly": False, + "conflictStrategy": 2, + } + + +def test_add_devices_sync_false_skips_sync() -> None: + app = _App() + assert app.add_devices_to_topology(["device12"], sync=False) is True + paths = [path for path, _ in app._inspect_api.vip_connector.rest.post_calls] + assert paths == [_ADD_DEVICES] + + +def test_sync_devices_passes_strategy() -> None: + app = _App() + app.sync_devices(["device12"], add_only=True, conflict_strategy=ConflictStrategy.CANCEL_SERVICES) + _, payload = app._inspect_api.vip_connector.rest.post_calls[0] + assert payload["data"] == {"ids": ["device12"], "addOnly": True, "conflictStrategy": 2} + + +def test_sync_devices_reports_failure() -> None: + app = _App({"msg": ["No topology reported by the device"], "ok": False}) + assert app.sync_devices(["device12"]) is False + + +def test_empty_lists_rejected() -> None: + app = _App() + with pytest.raises(ValueError): + app.sync_devices([]) + with pytest.raises(ValueError): + app.get_sync_info([]) + + +# --- Post-action snapshot refresh --- + + +def test_add_devices_refreshes_snapshot_when_loaded() -> None: + snap = _RecordingSnapshot() + app = _App(snapshot=snap) + assert app.add_devices_to_topology([("device12", 1, 2), "device13"]) is True + assert snap.network_refresh_calls == [["device12", "device13"]] + + +def test_sync_devices_refreshes_snapshot_when_loaded() -> None: + snap = _RecordingSnapshot() + app = _App(snapshot=snap) + assert app.sync_devices(["device12", "device13"]) is True + assert snap.network_refresh_calls == [["device12", "device13"]] + + +def test_network_action_without_snapshot_does_not_build_one() -> None: + # _snapshot is None: a pure-action workflow must not trigger a topology read. + app = _App() + assert app.add_devices_to_topology(["device12"]) is True + + +def test_failed_action_does_not_refresh() -> None: + snap = _RecordingSnapshot() + app = _App({"msg": ["nope"], "ok": False}, snapshot=snap) + assert app.sync_devices(["device12"]) is False + assert snap.network_refresh_calls == [] + + +def test_add_devices_failure_skips_sync_and_refresh() -> None: + snap = _RecordingSnapshot() + app = _App( + by_path={_ADD_DEVICES: {"msg": ["add failed"], "ok": False}}, + snapshot=snap, + ) + assert app.add_devices_to_topology(["device12"]) is False + paths = [path for path, _ in app._inspect_api.vip_connector.rest.post_calls] + assert paths == [_ADD_DEVICES] + assert snap.network_refresh_calls == [] + + +def test_add_devices_sync_failure_refreshes_and_returns_false() -> None: + snap = _RecordingSnapshot() + app = _App( + by_path={ + _ADD_DEVICES: {"msg": [], "ok": True}, + _SYNC_DEVICES: {"msg": ["sync failed"], "ok": False}, + }, + snapshot=snap, + ) + assert app.add_devices_to_topology(["device12"]) is False + paths = [path for path, _ in app._inspect_api.vip_connector.rest.post_calls] + assert paths == [_ADD_DEVICES, _SYNC_DEVICES] + assert snap.network_refresh_calls == [["device12"]] + + +# --- Internal --- + + +def _ok_header() -> SimpleNamespace: + return SimpleNamespace( + model_dump=lambda mode="json": { + "auth": True, + "caption": "OK", + "code": "OK", + "errorCodes": [], + "errorDetails": [], + "id": "0", + "msg": [], + "ok": True, + "user": "api-user", + } + ) + + +class _RecordingSnapshot: + """Stand-in that records apply_network_refresh calls (see snapshot.apply_network_refresh).""" + + def __init__(self) -> None: + self.network_refresh_calls: list[list[str]] = [] + + def apply_network_refresh(self, device_ids: list[str]) -> None: + self.network_refresh_calls.append(list(device_ids)) + + +class _App(InspectActionsMixin): + def __init__( + self, + post_data: dict[str, Any] | None = None, + snapshot: _RecordingSnapshot | None = None, + *, + by_path: dict[str, dict[str, Any]] | None = None, + ) -> None: + self._logger = logging.getLogger("test") + self._inspect_api = InspectAPI( + SimpleNamespace(rest=FakeRest(post_data, by_path=by_path)), + self._logger, + ) + self._snapshot = snapshot diff --git a/tests/inspect/test_api.py b/tests/inspect/test_api.py new file mode 100644 index 0000000..87d971a --- /dev/null +++ b/tests/inspect/test_api.py @@ -0,0 +1,132 @@ +"""InspectAPI wiring tests with a fake REST connector: verifies each method hits the right +endpoint, passes allow_projection for scoped reads, and parses responses into DTOs.""" + +from __future__ import annotations + +from collections.abc import Callable +from types import SimpleNamespace +from typing import Any + +from videoipath_automation_tool.apps.inspect.api import InspectAPI +from videoipath_automation_tool.apps.inspect.model.update_topology import InspectApiUpdateTopologyData + + +class FakeRest: + def __init__( + self, + get_data: dict[str, Any] | None = None, + post_data: dict[str, Any] | None = None, + ) -> None: + self._get_data = get_data or {} + self._post_data = post_data or {} + self.get_calls: list[tuple[str, bool]] = [] + self.post_calls: list[tuple[str, Any]] = [] + + def get(self, url_path: str, allow_projection: bool = False, **kwargs: Any) -> SimpleNamespace: + self.get_calls.append((url_path, allow_projection)) + return SimpleNamespace(data=self._get_data, header=_ok_header()) + + def post(self, url_path: str, body: Any, **kwargs: Any) -> SimpleNamespace: + self.post_calls.append((url_path, body)) + return SimpleNamespace(data=self._post_data, header=_ok_header()) + + +def test_device_skeleton_uses_projection_and_parses(load: Callable[[str], dict[str, Any]]) -> None: + node_items = load("skeleton_nodestatus_short.json")["data"]["status"]["collector"]["inspect"]["nodeStatus"][ + "_items" + ] + conn, rest = _connector(get_data=_collector(node_items=node_items)) + api = InspectAPI(conn) + devices = api.get_device_skeleton() + assert len(devices) == len(node_items) + assert rest.get_calls[0][1] is True # allow_projection + + +def test_edge_skeleton_parses(load: Callable[[str], dict[str, Any]]) -> None: + edge_items = load("edge_skeleton.json")["data"]["status"]["collector"]["externalEdgesByDeviceKey"]["_items"] + conn, rest = _connector(get_data=_collector(edge_items=edge_items)) + api = InspectAPI(conn) + edges = api.get_edge_skeleton() + assert len(edges) == len(edge_items) + + +def test_device_detail_returns_none_when_absent() -> None: + conn, rest = _connector(get_data=_collector(node_items=[])) + api = InspectAPI(conn) + assert api.get_device_detail("deviceX") is None + + +def test_lookup_edges_hits_correct_endpoint(load: Callable[[str], dict[str, Any]]) -> None: + conn, rest = _connector(post_data=load("lookup_inspect_edges_by_ids.json")["data"]) + api = InspectAPI(conn) + resp = api.lookup_edges(["a::b"]) + assert rest.post_calls[0][0].endswith("/lookupInspectEdgesByIds") + assert resp.data + + +def test_update_topology_posts_delta() -> None: + conn, rest = _connector( + post_data={ + "items": [], + "res": {"msg": [], "ok": True}, + "validation": {"details": {}, "result": {"msg": [], "ok": True}}, + } + ) + api = InspectAPI(conn) + resp = api.update_topology(InspectApiUpdateTopologyData()) + assert rest.post_calls[0][0].endswith("/updateTopology") + assert resp.committed is True + + +def test_add_and_sync_devices_endpoints() -> None: + conn, rest = _connector(post_data={"msg": [], "ok": True}) + api = InspectAPI(conn) + api.add_devices([]) + api.sync_devices([], add_only=True, conflict_strategy=0) + assert rest.post_calls[0][0].endswith("/network/addDevices") + assert rest.post_calls[1][0].endswith("/network/syncDevices") + + +# --- Internal --- + + +def _ok_header() -> SimpleNamespace: + return SimpleNamespace( + model_dump=lambda mode="json": { + "auth": True, + "caption": "OK", + "code": "OK", + "errorCodes": [], + "errorDetails": [], + "id": "0", + "msg": [], + "ok": True, + "user": "api-user", + } + ) + + +def _connector( + get_data: dict[str, Any] | None = None, + post_data: dict[str, Any] | None = None, +) -> tuple[SimpleNamespace, FakeRest]: + rest = FakeRest(get_data=get_data, post_data=post_data) + return SimpleNamespace(rest=rest), rest + + +def _collector( + node_items: list[dict[str, Any]] | None = None, + edge_items: list[dict[str, Any]] | None = None, + path_items: list[dict[str, Any]] | None = None, +) -> dict[str, Any]: + return { + "status": { + "collector": { + "inspect": { + "nodeStatus": {"_items": node_items or []}, + "paths": {"_items": path_items or []}, + }, + "externalEdgesByDeviceKey": {"_items": edge_items or []}, + } + } + } diff --git a/tests/inspect/test_beta_warning.py b/tests/inspect/test_beta_warning.py new file mode 100644 index 0000000..1a802c3 --- /dev/null +++ b/tests/inspect/test_beta_warning.py @@ -0,0 +1,30 @@ +"""InspectApp beta status warning.""" + +from __future__ import annotations + +import logging +import warnings +from types import SimpleNamespace + +import pytest + +from videoipath_automation_tool.apps.inspect.app.app import InspectApp + + +def _fake_connector(version: str = "2025.4.9") -> SimpleNamespace: + return SimpleNamespace(videoipath_version=version) + + +def test_inspect_app_emits_beta_user_warning() -> None: + with pytest.warns(UserWarning, match="InspectApp is in beta") as record: + app = InspectApp(vip_connector=_fake_connector()) # type: ignore[arg-type] + assert app is not None + assert len(record) == 1 + + +def test_inspect_app_logs_beta_warning(caplog: pytest.LogCaptureFixture) -> None: + logger = logging.getLogger("test_inspect_beta_warning") + with caplog.at_level(logging.WARNING, logger=logger.name), warnings.catch_warnings(): + warnings.simplefilter("ignore", UserWarning) + InspectApp(vip_connector=_fake_connector(), logger=logger) # type: ignore[arg-type] + assert any("InspectApp is in beta" in message for message in caplog.messages) diff --git a/tests/inspect/test_domain_writes.py b/tests/inspect/test_domain_writes.py new file mode 100644 index 0000000..a909a8c --- /dev/null +++ b/tests/inspect/test_domain_writes.py @@ -0,0 +1,422 @@ +"""Writable domain objects + app.inspect.update() cascade (offline).""" + +from __future__ import annotations + +from types import SimpleNamespace +from typing import Any + +import pytest + +from videoipath_automation_tool.apps.inspect.app.write import InspectWriteMixin +from videoipath_automation_tool.apps.inspect.domain.edge import InspectEdge +from videoipath_automation_tool.apps.inspect.domain.vertex import InspectCodecVertex, InspectIpVertex, build_vertex +from videoipath_automation_tool.apps.inspect.model.actions import ( + InspectApiLookupEdgeResponseItem, + InspectApiLookupInspectDeviceResponse, + InspectApiLookupVertexResponseData, +) +from videoipath_automation_tool.apps.inspect.model.update_topology import InspectApiUpdateTopologyResponse +from videoipath_automation_tool.apps.inspect.snapshot import InspectSnapshot, _IndexedEdge, _STAGED_MISSING +from videoipath_automation_tool.apps.inspect.transaction import InspectTransaction + +from .conftest import load_fixture + +_OK_HEADER = { + "auth": True, + "caption": "OK", + "code": "OK", + "errorCodes": [], + "errorDetails": [], + "id": "0", + "msg": [], + "ok": True, + "user": "api-user", +} + +CODEC_ID = "device-a.module-1.port-out-1.out" +DEVICE_ID = "device-a" +EDGE_ID = "device-a.1.p1.out::device-b.1.p1.in" + + +def test_vertex_setter_stages_and_reads_own_writes() -> None: + snapshot = InspectSnapshot() + vertex = build_vertex(snapshot, "device-a.1.v1", kind="generic", port_factory_label="Port A") + assert vertex.factory_label == "Port A" + assert vertex.label is None + + vertex.label = "new-label" + vertex.use_as_endpoint = True + vertex.tags = ["Video~~T"] + + assert vertex.label == "new-label" + assert vertex.use_as_endpoint is True + assert vertex.tags == ["Video~~T"] + staged = snapshot.get_staged_edits("vertex", "device-a.1.v1") + assert staged["label"] == "new-label" + assert staged["useAsEndpoint"] is True + assert staged["localAssignedTags"] == ["Video~~T"] + + +def test_codec_vertex_nested_setters() -> None: + snapshot = InspectSnapshot() + fixture = load_fixture("lookup_inspect_codec_vertex_by_id.json") + snapshot._vertex_details[CODEC_ID] = InspectApiLookupVertexResponseData.model_validate(fixture["data"]) + vertex = build_vertex(snapshot, CODEC_ID, kind="codec", port_factory_label="TX #1") + assert isinstance(vertex, InspectCodecVertex) + + assert vertex.sdp_support is True + vertex.sdp_support = False + vertex.main_destination_port = 50311 + assert vertex.sdp_support is False + assert vertex.main_destination_port == 50311 + + staged = snapshot.get_staged_edits("vertex", CODEC_ID) + assert staged["typeFields.specific.sdpSupport"] is False + assert staged["typeFields.generic.mainDstInfo.port"] == 50311 + + +def test_ip_vertex_supports_static_igmp_alias() -> None: + snapshot = InspectSnapshot() + vertex = build_vertex(snapshot, "device-a.SwitchingCore", kind="ip") + assert isinstance(vertex, InspectIpVertex) + vertex.supports_static_igmp_config = True + assert vertex.supports_static_igmp is True + assert snapshot.get_staged_value("vertex", "device-a.SwitchingCore", "typeFields.supportsStaticIgmpCfg") is True + + +def test_control_setter_warns() -> None: + snapshot = InspectSnapshot() + vertex = build_vertex(snapshot, "device-a.1.v1", kind="codec") + with pytest.warns(UserWarning, match="best-effort"): + vertex.control = "full" + assert snapshot.get_staged_value("vertex", "device-a.1.v1", "control") == "full" + + +def test_device_setters_stage_descriptor_and_icon() -> None: + snapshot = _skeleton_snapshot_with_device(DEVICE_ID, label="Old") + device = snapshot.get_device(DEVICE_ID) + assert device is not None + device.label = "New Label" + device.icon_type = "gateway" + device.tags = ["#SITE"] + device.coordinates = {"x": 10.0, "y": 20.0} + + assert device.label == "New Label" + assert device.icon_type == "gateway" + assert device.tags == ["#SITE"] + assert device.coordinates == {"x": 10.0, "y": 20.0} + staged = snapshot.get_staged_edits("device", DEVICE_ID) + assert staged["descriptor.label"] == "New Label" + assert staged["iconType"] == "gateway" + assert staged["coordinates"] == {"x": 10.0, "y": 20.0} + + +def test_update_device_cascades_vertex_edits() -> None: + api = FakeAPI() + api.devices[DEVICE_ID] = _device_response() + codec_fixture = load_fixture("lookup_inspect_codec_vertex_by_id.json") + api.vertices[CODEC_ID] = InspectApiLookupVertexResponseData.model_validate(codec_fixture["data"]) + + snapshot = _skeleton_snapshot_with_device(DEVICE_ID) + snapshot._vertex_details[CODEC_ID] = api.vertices[CODEC_ID] + snapshot._fetcher = api # type: ignore[assignment] + + device = snapshot.get_device(DEVICE_ID) + assert device is not None + device.label = "Synced" + vertex = build_vertex(snapshot, CODEC_ID, kind="codec") + assert isinstance(vertex, InspectCodecVertex) + vertex.sdp_support = True + vertex.main_destination_port = 50311 + + app = _WriteApp(api, snapshot) + result = app.update(device) + assert result.ok + assert len(api.update_calls) == 1 + delta = api.update_calls[0] + assert DEVICE_ID in delta.replaceDevices + assert delta.replaceDevices[DEVICE_ID].descriptor.label == "Synced" + assert CODEC_ID in delta.replaceVertices + form = delta.replaceVertices[CODEC_ID] + assert ( + form.typeFields.specific["sdpSupport"] is True + or getattr(form.typeFields, "specific", {}).get("sdpSupport") is True + or _nested_get(form, "typeFields.specific.sdpSupport") is True + ) + # Pending edits cleared after commit. + assert snapshot.get_staged_edits("device", DEVICE_ID) == {} + assert snapshot.get_staged_edits("vertex", CODEC_ID) == {} + + +def test_transaction_update_stages_domain_edits() -> None: + api = FakeAPI() + api.devices[DEVICE_ID] = _device_response() + snapshot = _skeleton_snapshot_with_device(DEVICE_ID) + device = snapshot.get_device(DEVICE_ID) + assert device is not None + device.label = "Via Tx" + + app = _WriteApp(api, snapshot) + with app.transaction() as tx: + returned = tx.update(device) + assert returned is tx + tx.commit() + assert api.update_calls[0].replaceDevices[DEVICE_ID].descriptor.label == "Via Tx" + + +def test_transaction_codec_nested_intents_round_trip() -> None: + api = FakeAPI() + codec_fixture = load_fixture("lookup_inspect_codec_vertex_by_id.json") + api.vertices[CODEC_ID] = InspectApiLookupVertexResponseData.model_validate(codec_fixture["data"]) + with InspectTransaction(api) as tx: + tx.update_vertex(CODEC_ID, sdp_support=False, main_destination_port=50311) + tx.commit() + form = api.update_calls[0].replaceVertices[CODEC_ID] + specific = getattr(form.typeFields, "specific") + generic = getattr(form.typeFields, "generic") + if isinstance(specific, dict): + assert specific["sdpSupport"] is False + else: + assert specific.sdpSupport is False + if isinstance(generic, dict): + assert generic["mainDstInfo"]["port"] == 50311 + assert generic["mainDstInfo"]["ip"] == "10.0.0.1" # untouched leaf preserved + else: + assert generic.mainDstInfo["port"] == 50311 + + +def test_edge_setters_stage_weight_factors() -> None: + from videoipath_automation_tool.apps.inspect.model.collector import InspectApiExternalEdgeStatus + + snapshot = InspectSnapshot() + indexed = _IndexedEdge( + edge_id=EDGE_ID, + pair_id="device-a::device-b", + edge=InspectApiExternalEdgeStatus(id=EDGE_ID), + pair_status=None, + primary_device_id="device-a", + secondary_device_id="device-b", + from_device_id="device-a", + from_port_id="p1", + to_device_id="device-b", + to_port_id="p1", + ) + edge = InspectEdge(snapshot=snapshot, indexed=indexed) + edge.weight = 42 + edge.bandwidth_weight_factor = 3 + assert edge.weight == 42 + assert edge.bandwidth_weight_factor == 3 + staged = snapshot.get_staged_edits("edge", EDGE_ID) + assert staged["weight"] == 42 + assert staged["weightFactors.bandwidth.weight"] == 3 + + +MODULE_ID = "device-a.dev.0" + + +def test_module_tags_setter_stages_and_reads_own_writes() -> None: + snapshot = _snapshot_with_module(DEVICE_ID, MODULE_ID, local_tags=["Format~~A"]) + module = snapshot.get_module(DEVICE_ID, MODULE_ID) + assert module is not None + assert module.tags == ["Format~~A"] + + module.tags = ["Format~~B", "Format~~C"] + assert module.tags == ["Format~~B", "Format~~C"] + assert snapshot.get_staged_edits("module", MODULE_ID)["tags"] == ["Format~~B", "Format~~C"] + + +def test_update_module_diffs_assign_and_unassign() -> None: + api = FakeAPI() + snapshot = _snapshot_with_module(DEVICE_ID, MODULE_ID, local_tags=["Format~~Keep", "Format~~Old"]) + app = _WriteApp(api, snapshot) + + result = app.update_module(MODULE_ID, tags=["Format~~Keep", "Format~~New"]) + assert result.ok + assert result.response is None + assert api.update_calls == [] + assert api.assign_calls == [("Format~~New", ["device:device-a.dev.0"])] + assert api.unassign_calls == [("Format~~Old", ["device:device-a.dev.0"])] + + +def test_update_module_noop_when_tags_unchanged() -> None: + api = FakeAPI() + snapshot = _snapshot_with_module(DEVICE_ID, MODULE_ID, local_tags=["Format~~A"]) + app = _WriteApp(api, snapshot) + + result = app.update_module(MODULE_ID, tags=["Format~~A"]) + assert result.ok + assert api.assign_calls == [] + assert api.unassign_calls == [] + + +def test_update_module_via_domain_object() -> None: + api = FakeAPI() + snapshot = _snapshot_with_module(DEVICE_ID, MODULE_ID, local_tags=[]) + module = snapshot.get_module(DEVICE_ID, MODULE_ID) + assert module is not None + module.tags = ["Format~~V_720p60"] + + app = _WriteApp(api, snapshot) + result = app.update(module) + assert result.ok + assert api.assign_calls == [("Format~~V_720p60", ["device:device-a.dev.0"])] + assert snapshot.get_staged_edits("module", MODULE_ID) == {} + + +def test_update_device_cascades_module_tag_edits() -> None: + api = FakeAPI() + api.devices[DEVICE_ID] = _device_response() + snapshot = _snapshot_with_module(DEVICE_ID, MODULE_ID, local_tags=["Format~~Old"]) + device = snapshot.get_device(DEVICE_ID) + assert device is not None + device.label = "Synced" + module = snapshot.get_module(DEVICE_ID, MODULE_ID) + assert module is not None + module.tags = ["Format~~New"] + + app = _WriteApp(api, snapshot) + result = app.update(device) + assert result.ok + assert len(api.update_calls) == 1 + assert api.update_calls[0].replaceDevices[DEVICE_ID].descriptor.label == "Synced" + assert api.assign_calls == [("Format~~New", ["device:device-a.dev.0"])] + assert api.unassign_calls == [("Format~~Old", ["device:device-a.dev.0"])] + assert snapshot.get_staged_edits("module", MODULE_ID) == {} + + +# --- Internal --- + + +def _nested_get(obj: Any, path: str) -> Any: + cur = obj + for part in path.split("."): + if cur is None: + return _STAGED_MISSING + if isinstance(cur, dict): + cur = cur.get(part) + else: + cur = getattr(cur, part, None) + return cur + + +def _skeleton_snapshot_with_device(device_id: str, label: str = "Device A") -> InspectSnapshot: + from videoipath_automation_tool.apps.inspect.model.collector import InspectApiNodeStatusItem + + node = InspectApiNodeStatusItem.model_validate( + { + "_id": device_id, + "deviceId": device_id, + "label": label, + "desc": "", + "tags": [], + "meta": {"iconType": "default", "iconSize": "medium", "coordinates": {"x": 0, "y": 0}}, + "status": {"sa": 0, "severity": 0}, + } + ) + return InspectSnapshot(device_items=[node]) + + +def _snapshot_with_module( + device_id: str, + module_id: str, + *, + local_tags: list[str] | None = None, + all_tags: list[str] | None = None, +) -> InspectSnapshot: + from videoipath_automation_tool.apps.inspect.model.collector import InspectApiModuleStatus + from videoipath_automation_tool.apps.inspect.snapshot import HydrationLevel + + snapshot = _skeleton_snapshot_with_device(device_id) + local_tags = list(local_tags or []) + all_tags = list(all_tags) if all_tags is not None else list(local_tags) + status = InspectApiModuleStatus.model_validate( + { + "_id": module_id, + "pid": module_id, + "label": "Module 0", + "tagsInfo": { + "assigned": { + "all": all_tags, + "inherited": {}, + "inheritedConflict": False, + "local": {tag: {"label": tag, "path": []} for tag in local_tags}, + } + }, + } + ) + snapshot._modules_by_device_id[device_id] = {module_id: status} + record = snapshot._devices_by_id[device_id] + record.level = HydrationLevel.FULL + return snapshot + + +def _device_response(label: str = "Device A") -> InspectApiLookupInspectDeviceResponse: + return InspectApiLookupInspectDeviceResponse.model_validate( + { + "data": { + "assignedTags": {"all": [], "inherited": {}, "inheritedConflict": False, "local": {}}, + "fields": { + "coordinates": {"x": 0, "y": 0}, + "descriptor": {"desc": "", "label": label}, + "iconSize": "medium", + "iconType": "default", + "localAssignedTags": [], + "sdpStrategy": None, + "siteId": None, + "tags": [], + "virtualDeviceFields": None, + }, + }, + "header": _OK_HEADER, + } + ) + + +class FakeAPI: + def __init__(self) -> None: + self.devices: dict[str, InspectApiLookupInspectDeviceResponse] = {} + self.vertices: dict[str, InspectApiLookupVertexResponseData] = {} + self.edges: dict[str, InspectApiLookupEdgeResponseItem] = {} + self.update_response = InspectApiUpdateTopologyResponse.model_validate( + load_fixture("update_topology_success.json") + ) + self.update_calls: list[Any] = [] + self.assign_calls: list[tuple[str, list[str]]] = [] + self.unassign_calls: list[tuple[str, list[str]]] = [] + + def lookup_inspect_device(self, device_id: str) -> InspectApiLookupInspectDeviceResponse: + if device_id not in self.devices: + raise KeyError(device_id) + return self.devices[device_id] + + def lookup_vertices(self, ids: list[str]) -> SimpleNamespace: + return SimpleNamespace(data={i: self.vertices[i] for i in ids if i in self.vertices}) + + def lookup_edges(self, ids: list[str]) -> SimpleNamespace: + return SimpleNamespace(data={i: self.edges[i] for i in ids if i in self.edges}) + + def update_topology(self, delta: Any) -> InspectApiUpdateTopologyResponse: + self.update_calls.append(delta) + return self.update_response + + def assign_tag(self, tag_id: str, element_ids: list[str]) -> SimpleNamespace: + self.assign_calls.append((tag_id, list(element_ids))) + return _ok_simple_action() + + def unassign_tag(self, tag_id: str, element_ids: list[str]) -> SimpleNamespace: + self.unassign_calls.append((tag_id, list(element_ids))) + return _ok_simple_action() + + +def _ok_simple_action() -> SimpleNamespace: + return SimpleNamespace( + header=SimpleNamespace(ok=True, msg=[]), + data=SimpleNamespace(ok=True, msg=[]), + ) + + +class _WriteApp(InspectWriteMixin): + def __init__(self, api: FakeAPI, snapshot: InspectSnapshot) -> None: + self._inspect_api = api # type: ignore[assignment] + self._logger = __import__("logging").getLogger("test") + self._snapshot = snapshot diff --git a/tests/inspect/test_exports.py b/tests/inspect/test_exports.py new file mode 100644 index 0000000..e348479 --- /dev/null +++ b/tests/inspect/test_exports.py @@ -0,0 +1,49 @@ +"""Export contract tests: the inspect package and its model package define a complete ``__all__`` +so star imports stay well-defined (no namespace pollution).""" + +from __future__ import annotations + +from videoipath_automation_tool import apps +from videoipath_automation_tool.apps import inspect +from videoipath_automation_tool.apps.inspect import model +from videoipath_automation_tool.apps.inspect.model import ( + actions, + alarms, + collector, + common, + ngraph, + tags, + update_topology, + virtual, +) + + +def test_model_package_all_aggregates_submodules() -> None: + expected = { + *actions.__all__, + *alarms.__all__, + *collector.__all__, + *common.__all__, + *ngraph.__all__, + *tags.__all__, + *update_topology.__all__, + *virtual.__all__, + } + assert set(model.__all__) == expected + assert len(model.__all__) == len(set(model.__all__)) # no duplicates across submodules + + +def test_model_package_all_names_resolve() -> None: + for name in model.__all__: + assert hasattr(model, name), f"model.__all__ contains unresolvable name: {name}" + + +def test_inspect_package_all_names_resolve() -> None: + assert {"InspectApp", "InspectDevice", "InspectError"} <= set(inspect.__all__) + for name in inspect.__all__: + assert hasattr(inspect, name), f"inspect.__all__ contains unresolvable name: {name}" + + +def test_apps_star_import_is_clean() -> None: + assert apps.inspect is inspect + assert hasattr(inspect, "__all__") diff --git a/tests/inspect/test_models.py b/tests/inspect/test_models.py new file mode 100644 index 0000000..4ca43a3 --- /dev/null +++ b/tests/inspect/test_models.py @@ -0,0 +1,324 @@ +"""Contract tests: every fixture parses into its DTO, and write-payload builders reproduce +the verified request shapes byte-for-byte.""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + +from videoipath_automation_tool.apps.inspect.model.actions import ( + InspectApiLookupEdgesResponse, + InspectApiLookupInspectDeviceResponse, + InspectApiLookupVertexResponse, + InspectApiLookupVerticesResponse, +) +from videoipath_automation_tool.apps.inspect.model.alarms import InspectApiAlarmItem +from videoipath_automation_tool.apps.inspect.model.collector import ( + InspectApiDoubleVertexInfo, + InspectApiExternalEdgeLiveStatus, + InspectApiExternalEdgesByDeviceKeyItem, + InspectApiNodeStatusItem, + InspectApiPathItem, + InspectApiSingleVertexInfo, + InspectPortStatus, +) +from videoipath_automation_tool.apps.inspect.model.common import ( + InspectApiStatusSummary, + InspectSeverity, + map_severity, +) +from videoipath_automation_tool.apps.inspect.model.update_topology import ( + InspectApiUpdateTopologyData, + InspectApiUpdateTopologyResponse, +) + + +def test_device_skeleton_items_parse_and_expose_effective_label(load: Callable[[str], dict[str, Any]]) -> None: + items = _node_items(load("skeleton_nodestatus_short.json")) + assert items + for raw in items: + node = InspectApiNodeStatusItem.model_validate(raw) + assert node.id + assert node.effective_label # comes from descriptor.label, not a top-level label + + +def test_device_skeleton_exposes_description(load: Callable[[str], dict[str, Any]]) -> None: + items = _node_items(load("skeleton_nodestatus_short.json")) + node = InspectApiNodeStatusItem.model_validate(items[0]) + assert node.effective_description == "Example device description" + + +def test_node_effective_description_prefers_descriptor_then_fdescriptor() -> None: + both = InspectApiNodeStatusItem.model_validate( + {"_id": "device-a", "descriptor": {"desc": "user desc"}, "fDescriptor": {"desc": "factory desc"}} + ) + assert both.effective_description == "user desc" + + factory_only = InspectApiNodeStatusItem.model_validate( + {"_id": "device-a", "descriptor": {"desc": ""}, "fDescriptor": {"desc": "factory desc"}} + ) + assert factory_only.effective_description == "factory desc" + + assert InspectApiNodeStatusItem.model_validate({"_id": "device-a"}).effective_description is None + + +def test_device_detail_parses_modules_and_ports(load: Callable[[str], dict[str, Any]]) -> None: + items = _node_items(load("device_hydration_modules_ports.json")) + node = InspectApiNodeStatusItem.model_validate(items[0]) + assert node.modules + module = next(iter(node.modules.values())) + assert module.ports + + +def test_device_detail_ports_expose_factory_label_and_override(load: Callable[[str], dict[str, Any]]) -> None: + items = _node_items(load("device_hydration_modules_ports.json")) + node = InspectApiNodeStatusItem.model_validate(items[0]) + ports = next(iter(node.modules.values())).ports + port = ports["device-a.dev.module-1.port-out-1"] + assert port.label == "port-out-1" # factory label survives the override + assert port.effective_label == "port-out-1 (out)" + assert port.effective_description == "Example port description" + + +def test_port_vertex_info_parses_single_and_double(load: Callable[[str], dict[str, Any]]) -> None: + items = _node_items(load("device_hydration_modules_ports.json")) + node = InspectApiNodeStatusItem.model_validate(items[0]) + ports = next(iter(node.modules.values())).ports + + single = ports["device-a.dev.module-1.port-out-1"].parsed_vertex_info + assert isinstance(single, InspectApiSingleVertexInfo) + assert single.vertexType == "Out" + assert single.fields is not None and single.fields.isActive is True and single.fields.isControlled is True + + double = ports["device-a.dev.module-1.port-bidi-1"].parsed_vertex_info + assert isinstance(double, InspectApiDoubleVertexInfo) + assert double.in_ is not None and double.in_.vertexType == "In" + assert double.out is not None and double.out.vertexType == "Out" + + +def test_port_parsed_vertex_info_coerces_raw_dict() -> None: + port = InspectPortStatus.model_construct( + vertexInfo={"type": "single", "id": "device-a.1.p1.out", "vertexType": "Out"} + ) + info = port.parsed_vertex_info + assert isinstance(info, InspectApiSingleVertexInfo) + assert info.id == "device-a.1.p1.out" + + assert InspectPortStatus.model_construct(vertexInfo={"type": "unknown"}).parsed_vertex_info is None + assert InspectPortStatus.model_validate({"_id": "device-a.1.p1"}).parsed_vertex_info is None + + +def test_lookup_vertices_batch_response_parses(load: Callable[[str], dict[str, Any]]) -> None: + resp = InspectApiLookupVerticesResponse.model_validate(load("lookup_inspect_vertices_by_ids.json")) + details = resp.data["device-a.module-1.port-out-1.out"] + assert details.vertexType == "Out" + assert details.fields.typeFields is not None and details.fields.typeFields.type == "ip" + assert details.fields.controlProps is not None + assert details.fields.controlProps.configPriority == "off" + assert details.fields.controlProps.onlyInitial is False + + +def test_edge_skeleton_items_parse(load: Callable[[str], dict[str, Any]]) -> None: + items = load("edge_skeleton.json")["data"]["status"]["collector"]["externalEdgesByDeviceKey"]["_items"] + assert len(items) > 0 + edge = InspectApiExternalEdgesByDeviceKeyItem.model_validate(items[0]) + assert "::" in edge.id + assert edge.primary is not None + + +def test_paths_fixture_parses(load: Callable[[str], dict[str, Any]]) -> None: + items = load("inspect_paths_limit5.json")["data"]["status"]["collector"]["inspect"]["paths"]["_items"] + for raw in items: + path = InspectApiPathItem.model_validate(raw) + assert path.serviceFields.bid + + +def test_lookup_edges_returns_full_persisted_edge_form(load: Callable[[str], dict[str, Any]]) -> None: + resp = InspectApiLookupEdgesResponse.model_validate(load("lookup_inspect_edges_by_ids.json")) + key = next(iter(resp.data)) + edge = resp.data[key].edge + assert edge.fromId and edge.toId + assert edge.capacity == 65535 + + +def test_lookup_vertex_edit_form(load: Callable[[str], dict[str, Any]]) -> None: + resp = InspectApiLookupVertexResponse.model_validate(load("lookup_inspect_vertex_by_id.json")) + assert resp.data.fields.typeFields is not None + assert resp.data.vertexType in ("In", "Out", "Internal", None) + + +def test_lookup_router_vertex_exposes_park_port(load: Callable[[str], dict[str, Any]]) -> None: + resp = InspectApiLookupVertexResponse.model_validate(load("lookup_inspect_router_vertex_by_id.json")) + assert resp.data.fields.typeFields is not None + assert resp.data.fields.typeFields.type == "router" + assert resp.data.fields.typeFields.parkPort == 42 + + +def test_lookup_codec_vertex_preserves_generic_specific(load: Callable[[str], dict[str, Any]]) -> None: + fixture = load("lookup_inspect_codec_vertex_by_id.json") + resp = InspectApiLookupVertexResponse.model_validate(fixture) + type_fields = resp.data.fields.typeFields + assert type_fields is not None and type_fields.type == "codec" + # generic/specific are preserved losslessly via extra="allow" and re-serialize byte-for-byte. + built = resp.data.fields.model_dump(mode="json", by_alias=True) + assert built["typeFields"] == fixture["data"]["fields"]["typeFields"] + + +def test_lookup_device_fields() -> None: + resp = InspectApiLookupInspectDeviceResponse.model_validate(_device_lookup_fixture()) + assert resp.data.fields.coordinates is not None + + +def test_replace_devices_payload_roundtrips_byte_for_byte(load: Callable[[str], dict[str, Any]]) -> None: + fixture = load("update_topology_replace_devices.json") + data = InspectApiUpdateTopologyData.model_validate(fixture["request"]["data"]) + built = data.model_dump(mode="json", by_alias=True) + assert built["replaceDevices"] == fixture["request"]["data"]["replaceDevices"] + + +def test_replace_vertices_payload_roundtrips_byte_for_byte(load: Callable[[str], dict[str, Any]]) -> None: + fixture = load("update_topology_replace_vertices.json") + data = InspectApiUpdateTopologyData.model_validate(fixture["request"]["data"]) + built = data.model_dump(mode="json", by_alias=True) + assert built["replaceVertices"] == fixture["request"]["data"]["replaceVertices"] + + +def test_commit_success_and_failure_flags(load: Callable[[str], dict[str, Any]]) -> None: + ok = InspectApiUpdateTopologyResponse.model_validate(load("update_topology_success.json")) + assert ok.committed is True + + fail_booking = InspectApiUpdateTopologyResponse.model_validate(load("update_topology_fail_booking.json")) + assert fail_booking.committed is False + assert fail_booking.data.validation.details # per-entity detail present + + fail_remove = InspectApiUpdateTopologyResponse.model_validate(load("update_topology_fail_remove.json")) + assert fail_remove.committed is False + + +def test_inspect_icon_type_matches_topology_icon_type() -> None: + """Drift guard: the inspect-local Literal must stay in sync with the topology app's IconType.""" + from typing import get_args + + from videoipath_automation_tool.apps.inspect.model.common import InspectIconType + from videoipath_automation_tool.apps.topology.model.n_graph_elements.topology_n_graph_element import IconType + + assert set(get_args(InspectIconType)) == set(get_args(IconType)) + + +def test_inspect_literals_match_topology_literals() -> None: + """Drift guard: inspect Literals that mirror topology must stay in sync.""" + from typing import get_args, get_type_hints + + from videoipath_automation_tool.apps.inspect.model.common import ( + InspectCodecFormat, + InspectControl, + InspectMapCType, + InspectSipsMode, + ) + from videoipath_automation_tool.apps.topology.model.n_graph_elements.topology_codec_vertex import CodecFormat + from videoipath_automation_tool.apps.topology.model.n_graph_elements.topology_n_graph_element import ( + Control, + MapsElement, + SipsMode, + ) + + assert set(get_args(InspectSipsMode)) == set(get_args(SipsMode)) + assert set(get_args(InspectControl)) == set(get_args(Control)) + assert set(get_args(InspectCodecFormat)) == set(get_args(CodecFormat)) + assert set(get_args(InspectMapCType)) == set(get_args(get_type_hints(MapsElement)["cType"])) + + +def test_port_assigned_tags_from_tags_info() -> None: + port = InspectPortStatus.model_validate( + { + "_id": "device-a.1.p1", + "tagsInfo": {"assigned": {"all": ["Video~~T"], "inherited": {}, "local": {"Video~~T": {}}}}, + } + ) + assert port.assigned_tags == ["Video~~T"] + assert InspectPortStatus.model_validate({"_id": "device-a.1.p2"}).assigned_tags == [] + + +def test_inspect_severity_labels_and_int_compat() -> None: + assert InspectSeverity.OK == 1 + assert int(InspectSeverity.CRITICAL) == 6 + assert str(InspectSeverity.NOTICE) == "Notice" + assert InspectSeverity.MAJOR.label == "Major" + assert InspectSeverity.NONE < InspectSeverity.OK < InspectSeverity.CRITICAL + + +def test_map_severity_known_unknown_and_passthrough() -> None: + assert map_severity(0) is InspectSeverity.NONE + assert map_severity(6) is InspectSeverity.CRITICAL + assert map_severity(99) == 99 + assert map_severity("ok") == "ok" + assert map_severity(None) is None + assert map_severity(InspectSeverity.MINOR) is InspectSeverity.MINOR + + +def test_status_summary_maps_severity_fields() -> None: + summary = InspectApiStatusSummary.model_validate({"sa": 0, "severity": 1}) + assert summary.sa is InspectSeverity.NONE + assert summary.severity is InspectSeverity.OK + assert summary.severity == 1 + + unknown = InspectApiStatusSummary.model_validate({"sa": 99, "severity": None}) + assert unknown.sa == 99 + assert unknown.severity is None + + +def test_edge_live_status_and_sync_severity_map() -> None: + live = InspectApiExternalEdgeLiveStatus.model_validate( + {"alarm": 1, "bandwidth": None, "maintenance": None, "ptp": 1} + ) + assert live.alarm is InspectSeverity.OK + assert live.ptp is InspectSeverity.OK + assert live.bandwidth is None + + node = InspectApiNodeStatusItem.model_validate({"_id": "device-a", "syncSeverity": 2}) + assert node.syncSeverity is InspectSeverity.NOTICE + + +def test_alarm_item_parses_and_maps_severity(load: Callable[[str], dict[str, Any]]) -> None: + items = load("alarms_current.json")["data"]["status"]["alarms"]["current"]["_items"] + alarm = InspectApiAlarmItem.model_validate(items[0]) + assert alarm.id_field == "1:device-a.dev:Mock" + assert alarm.id is not None and alarm.id.pointId == ["device-a", "dev"] + assert alarm.info is not None + assert alarm.info.details == "Mock driver in use" + assert alarm.info.severity is InspectSeverity.NOTICE + assert alarm.info.sa is InspectSeverity.NOTICE + assert alarm.acked is False + + major = InspectApiAlarmItem.model_validate(items[1]) + assert major.info is not None + assert major.info.severity is InspectSeverity.MAJOR + assert major.info.details == "Loss of protection" + + +# --- Internal --- + + +def _node_items(payload: dict[str, Any]) -> list[dict[str, Any]]: + return payload["data"]["status"]["collector"]["inspect"]["nodeStatus"]["_items"] + + +def _device_lookup_fixture() -> dict[str, Any]: + # lookupInspectDevice example from endpoints.md (anonymized) + return { + "data": { + "assignedTags": {"all": [], "inherited": {}, "inheritedConflict": False, "local": {}}, + "fields": { + "coordinates": {"x": 500, "y": 8150}, + "descriptor": {"desc": "", "label": "Example Device A"}, + "iconSize": "medium", + "iconType": "gateway", + "localAssignedTags": [], + "sdpStrategy": "always", + "siteId": None, + "tags": [], + "virtualDeviceFields": None, + }, + }, + "header": {"auth": True, "caption": "OK", "code": "OK", "id": "0", "msg": [], "ok": True, "user": "api-user"}, + } diff --git a/tests/inspect/test_queries.py b/tests/inspect/test_queries.py new file mode 100644 index 0000000..78d7922 --- /dev/null +++ b/tests/inspect/test_queries.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +import urllib.parse + +import pytest + +from videoipath_automation_tool.apps.inspect.api import queries +from videoipath_automation_tool.apps.inspect.errors import InspectQueryTooLongError + + +def test_all_queries_within_length_limit() -> None: + for build in ( + queries.device_skeleton, + queries.edge_skeleton, + queries.paths_section, + queries.alarms_section, + queries.collector_full, + queries.virtual_templates, + queries.virtual_devices, + ): + path = build() + assert len(path) < queries.MAX_QUERY_LENGTH + assert path.startswith("/rest/v2/data/status/") + + +def test_collector_queries_use_collector_namespace() -> None: + for build in (queries.device_skeleton, queries.edge_skeleton, queries.paths_section, queries.collector_full): + assert build().startswith("/rest/v2/data/status/collector/") + + +def test_alarms_query_uses_alarms_namespace() -> None: + path = urllib.parse.unquote(queries.alarms_section()) + assert path.startswith("/rest/v2/data/status/alarms/current/") + for field in ("acked", "hidden", "id", "desc", "info"): + assert field in path + + +def test_virtual_queries_use_network_namespace() -> None: + assert "/status/network/virtualTemplates/**" in queries.virtual_templates() + assert "/status/network/virtualDevices/**" in queries.virtual_devices() + + +def test_device_skeleton_suppresses_modules_and_selects_skeleton_fields() -> None: + path = queries.device_skeleton() + decoded = urllib.parse.unquote(path) + assert 'modules/"_noId"' in decoded + assert "nodeStatus/*" in decoded + for field in ("descriptor", "meta", "status", "syncSeverity", "tags"): + assert field in decoded + + +def test_device_detail_uses_direct_id_and_full_subtree() -> None: + path = queries.device_detail("device12") + assert path.endswith("/nodeStatus/device12/**") + + +def test_edge_pair_targets_single_pair() -> None: + path = queries.edge_pair("device12::device7") + decoded = urllib.parse.unquote(path) + assert "externalEdgesByDeviceKey/device12::device7" in decoded + + +def test_encode_preserves_grammar_characters_and_encodes_quotes_and_spaces() -> None: + encoded = queries.encode("/a b/*/x,y/'z'/\"q\"") + assert "%20" in encoded # space encoded + assert "%22" in encoded # double-quote encoded + assert "/*/" in encoded # star preserved + assert "'z'" in encoded # single-quote preserved + + +def test_query_too_long_raises(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(queries, "MAX_QUERY_LENGTH", 10) + with pytest.raises(InspectQueryTooLongError): + queries.device_skeleton() diff --git a/tests/inspect/test_repr.py b/tests/inspect/test_repr.py new file mode 100644 index 0000000..66316c1 --- /dev/null +++ b/tests/inspect/test_repr.py @@ -0,0 +1,217 @@ +"""Concise, side-effect-free __repr__/__str__ for Inspect developer-facing classes.""" + +from __future__ import annotations + +from types import SimpleNamespace + +import pytest + +from videoipath_automation_tool.apps.inspect.domain.alarm import InspectAlarm +from videoipath_automation_tool.apps.inspect.domain.device import InspectDevice, VirtualDeviceSpec +from videoipath_automation_tool.apps.inspect.domain.module import VirtualModuleSpec +from videoipath_automation_tool.apps.inspect.domain.port import InspectPortTemplate, PortFromTemplate +from videoipath_automation_tool.apps.inspect.domain.vertex import InspectVertex +from videoipath_automation_tool.apps.inspect.model.alarms import InspectApiAlarmItem +from videoipath_automation_tool.apps.inspect.model.collector import InspectApiSingleVertexInfo +from videoipath_automation_tool.apps.inspect.snapshot import InspectSnapshot +from videoipath_automation_tool.apps.inspect.transaction import CommitResult, InspectTransaction + +from .conftest import load_fixture +from .test_snapshot import FakeFetcher + + +def _assert_clean_repr(obj: object, *, class_name: str, contains: str) -> str: + text = repr(obj) + assert text.startswith(f"{class_name}(") + assert contains in text + assert "InspectSnapshot object" not in text + assert "path_item=" not in text + assert "_items" not in text + assert str(obj) == text + return text + + +@pytest.fixture +def snap() -> tuple[InspectSnapshot, FakeFetcher]: + fetcher = FakeFetcher() + snapshot = InspectSnapshot( + fetcher=fetcher, + device_items=fetcher.get_device_skeleton(), + edge_items=fetcher.get_edge_skeleton(), + ) + fetcher.skeleton_calls = 0 + return snapshot, fetcher + + +def test_device_repr_is_concise_and_does_not_hydrate(snap: tuple[InspectSnapshot, FakeFetcher]) -> None: + snapshot, fetcher = snap + device = snapshot.get_device("leaf-a") + assert device is not None + text = _assert_clean_repr(device, class_name="InspectDevice", contains="leaf-a") + assert "label='LEAF-A'" in text + assert "virtual=" not in text + assert device.is_hydrated is False + assert fetcher.device_detail_calls == [] + + +def test_device_repr_marks_virtual_id() -> None: + fetcher = FakeFetcher() + snapshot = InspectSnapshot(fetcher=fetcher, device_items=[], edge_items=[]) + device = InspectDevice(snapshot=snapshot, id="virtual.2") + text = repr(device) + assert text.startswith("InspectDevice(") + assert "id='virtual.2'" in text + assert "virtual=True" in text + assert str(device) == text + + +def test_device_repr_survives_missing_record(snap: tuple[InspectSnapshot, FakeFetcher]) -> None: + snapshot, _ = snap + device = snapshot.get_device("leaf-a") + assert device is not None + snapshot._devices_by_id.pop("leaf-a") + text = repr(device) + assert text.startswith("InspectDevice(") + assert "id='leaf-a'" in text + assert "label=" not in text + assert str(device) == text + + +def test_port_module_edge_service_vertex_reprs(snap: tuple[InspectSnapshot, FakeFetcher]) -> None: + snapshot, _ = snap + device = snapshot.get_device("leaf-a") + assert device is not None + + ports = device.ports + assert ports + port = ports[0] + _assert_clean_repr(port, class_name="InspectPort", contains=port.id or "") + assert f"device='{port.indexed.device_id}'" in repr(port) + + modules = device.modules + assert modules + module = modules[0] + text = _assert_clean_repr(module, class_name="InspectModule", contains=module.id) + assert "device='leaf-a'" in text + + edge = snapshot.edges[0] + text = _assert_clean_repr(edge, class_name="InspectEdge", contains=edge.id) + assert "from_device=" in text and "to_device=" in text + + services = snapshot.services + assert services + service = services[0] + text = _assert_clean_repr(service, class_name="InspectService", contains=service.booking_id) + assert "path_item=" not in text + + vertex = port._offline_vertices()[0] + text = _assert_clean_repr(vertex, class_name="InspectVertex", contains=vertex.id) + assert "type=" in text + + +def test_typed_vertex_repr_uses_subclass_name(snap: tuple[InspectSnapshot, FakeFetcher]) -> None: + snapshot, _ = snap + vertex = snapshot.get_vertex("leaf-a.0.up1") + assert vertex is not None + text = _assert_clean_repr(vertex, class_name="InspectIpVertex", contains=vertex.id) + assert text.startswith("InspectIpVertex(") + + +def test_snapshot_repr(snap: tuple[InspectSnapshot, FakeFetcher]) -> None: + snapshot, _ = snap + text = _assert_clean_repr(snapshot, class_name="InspectSnapshot", contains="devices=2") + assert "edge_pairs=1" in text + + +def test_internal_index_record_reprs(snap: tuple[InspectSnapshot, FakeFetcher]) -> None: + snapshot, _ = snap + device = snapshot.get_device("leaf-a") + assert device is not None + _ = device.ports + + record = snapshot.get_device_record("leaf-a") + assert record is not None + text = _assert_clean_repr(record, class_name="_DeviceRecord", contains="leaf-a") + assert "level=" in text + + indexed_port = snapshot._ports_by_device_id["leaf-a"][0] + text = _assert_clean_repr(indexed_port, class_name="_IndexedPort", contains="leaf-a") + assert "port_id=" in text + + indexed_edge = snapshot._edges_by_device_id["leaf-a"][0] + text = _assert_clean_repr(indexed_edge, class_name="_IndexedEdge", contains=indexed_edge.edge_id) + assert "from_device_id=" in text + + +def test_builder_reprs() -> None: + port = PortFromTemplate(template_id="tpl-a", count=3) + text = _assert_clean_repr(port, class_name="PortFromTemplate", contains="tpl-a") + assert "count=3" in text + + module = VirtualModuleSpec(ports=[port], module_number=1) + text = _assert_clean_repr(module, class_name="VirtualModuleSpec", contains="ports=1") + assert "module_number=1" in text + + device_spec = VirtualDeviceSpec(modules=[module, VirtualModuleSpec()]) + text = _assert_clean_repr(device_spec, class_name="VirtualDeviceSpec", contains="modules=2") + + template = InspectPortTemplate(id="tpl-a", label="Port A", kind="ip", direction="Out", vertex={"type": "ip"}) + text = _assert_clean_repr(template, class_name="InspectPortTemplate", contains="tpl-a") + assert "vertex=" not in text + assert "kind='ip'" in text + + +def test_alarm_repr_truncates_long_message() -> None: + item = InspectApiAlarmItem.model_validate( + load_fixture("alarms_current.json")["data"]["status"]["alarms"]["current"]["_items"][0] + ) + alarm = InspectAlarm(item=item) + text = _assert_clean_repr(alarm, class_name="InspectAlarm", contains=alarm.id or "") + assert "severity=" in text + assert "message=" in text + + long_details = "x" * 80 + long_item = InspectApiAlarmItem.model_validate( + { + "_id": "alarm-long", + "info": {"details": long_details, "severity": 3}, + } + ) + long_alarm = InspectAlarm(item=long_item) + text = repr(long_alarm) + assert "…" in text + assert long_details not in text + + +def test_transaction_and_commit_result_reprs() -> None: + tx = InspectTransaction(api=SimpleNamespace()) + text = _assert_clean_repr(tx, class_name="InspectTransaction", contains="staged=0") + assert "committed=" not in text + assert "discarded=" not in text + + tx._committed = True + assert "committed=True" in repr(tx) + + result = CommitResult(applied_ids=["device-a", "device-b"], created_ids=["virtual.1"]) + text = _assert_clean_repr(result, class_name="CommitResult", contains="applied=2") + assert "created=1" in text + assert "response=" not in text + + +def test_format_repr_skips_failing_callables() -> None: + from videoipath_automation_tool.apps.inspect.model.common import format_repr + + class _Probe: + pass + + text = format_repr(_Probe(), id="a", label=lambda: (_ for _ in ()).throw(RuntimeError("boom")), extra=None) + assert text == "_Probe(id='a')" + + +def test_offline_vertex_repr_without_lookup(snap: tuple[InspectSnapshot, FakeFetcher]) -> None: + snapshot, fetcher = snap + info = InspectApiSingleVertexInfo.model_validate({"id": "leaf-a.0.up1", "vertexType": "Out"}) + vertex = InspectVertex(snapshot=snapshot, id="leaf-a.0.up1", vertex_info=info) + text = _assert_clean_repr(vertex, class_name="InspectVertex", contains="leaf-a.0.up1") + assert "type='Out'" in text + assert fetcher.vertex_lookup_calls == [] diff --git a/tests/inspect/test_snapshot.py b/tests/inspect/test_snapshot.py new file mode 100644 index 0000000..51e5e50 --- /dev/null +++ b/tests/inspect/test_snapshot.py @@ -0,0 +1,749 @@ +"""Snapshot unit tests with a fake fetcher: skeleton indexes, exactly-one hydration per device, +section laziness, preload fan-out, refresh, and post-commit targeted refresh.""" + +from __future__ import annotations + +from collections.abc import Iterator + +import pytest + +from videoipath_automation_tool.apps.inspect.model.actions import ( + InspectApiLookupEdgesResponse, + InspectApiLookupVerticesResponse, +) +from videoipath_automation_tool.apps.inspect.model.alarms import InspectApiAlarmItem +from videoipath_automation_tool.apps.inspect.model.collector import ( + InspectApiExternalEdgesByDeviceKeyItem, + InspectApiNodeStatusItem, + InspectApiPathItem, +) +from videoipath_automation_tool.apps.inspect.model.common import InspectSeverity +from videoipath_automation_tool.apps.inspect.snapshot import HydrationLevel, InspectSnapshot + +from .conftest import load_fixture + + +def test_skeleton_indexes_devices_and_edges(snapshot: tuple[InspectSnapshot, FakeFetcher]) -> None: + snap, fetcher = snapshot + assert {d.id for d in snap.devices} == {"spine-a", "leaf-a"} + leaf = snap.get_device("leaf-a") + assert leaf.label == "LEAF-A" + assert leaf.is_virtual is True + assert leaf.coordinates == {"x": 0.0, "y": 0.0} + assert len(snap.edges) == 1 + assert fetcher.device_detail_calls == [] # nothing hydrated yet + + +def test_find_by_label(snapshot: tuple[InspectSnapshot, FakeFetcher]) -> None: + snap, _ = snapshot + assert snap.find_device_by_label("SPINE-A").id == "spine-a" + + +def test_device_description_from_descriptor(snapshot: tuple[InspectSnapshot, FakeFetcher]) -> None: + snap, _ = snapshot + assert snap.get_device("leaf-a").description == "LEAF-A description" + + +def test_ports_trigger_exactly_one_hydration(snapshot: tuple[InspectSnapshot, FakeFetcher]) -> None: + snap, fetcher = snapshot + leaf = snap.get_device("leaf-a") + assert leaf.is_hydrated is False + ports = leaf.ports + assert {p.id for p in ports} == {"leaf-a.dev.0.up1", "leaf-a.dev.0.host1"} + assert leaf.is_hydrated is True + # Access again → no second fetch + _ = leaf.ports + assert fetcher.device_detail_calls == ["leaf-a"] + # Other device still skeleton + assert snap.get_device("spine-a").is_hydrated is False + + +def test_edges_do_not_trigger_hydration(snapshot: tuple[InspectSnapshot, FakeFetcher]) -> None: + snap, fetcher = snapshot + edge = snap.edges[0] + assert edge.from_device.id == "leaf-a" + assert edge.to_device.id == "spine-a" + assert edge.status is not None + assert fetcher.device_detail_calls == [] + + +def test_edge_from_port_triggers_owning_device_hydration(snapshot: tuple[InspectSnapshot, FakeFetcher]) -> None: + snap, fetcher = snapshot + edge = snap.edges[0] + port = edge.from_port + assert port is not None + assert port.id == "leaf-a.dev.0.up1" + assert fetcher.device_detail_calls == ["leaf-a"] + + +def test_services_section_is_lazy(snapshot: tuple[InspectSnapshot, FakeFetcher]) -> None: + snap, fetcher = snapshot + assert fetcher.section_calls == 0 + services = snap.services + assert {s.booking_id for s in services} == {"1001"} + _ = snap.services # second access + assert fetcher.section_calls == 1 + assert {d.id for d in snap.get_services_for_device("leaf-a")[0].path_devices} == {"leaf-a", "spine-a"} + + +def test_alarms_section_is_lazy_and_correlates(snapshot: tuple[InspectSnapshot, FakeFetcher]) -> None: + snap, fetcher = snapshot + fetcher._alarms = [ + InspectApiAlarmItem.model_validate(raw) + for raw in load_fixture("alarms_current.json")["data"]["status"]["alarms"]["current"]["_items"] + ] + # Remap fixture device-a alarms onto leaf-a for this snapshot's devices. + for item in fetcher._alarms: + assert item.id is not None + item.id.pointId = ["leaf-a", *item.id.pointId[1:]] + + assert fetcher.alarm_section_calls == 0 + device = snap.get_device("leaf-a") + alarms = device.alarms + assert fetcher.alarm_section_calls == 1 + assert len(alarms) == 3 + assert alarms[0].severity == InspectSeverity.MAJOR # worst first + assert alarms[0].message == "Loss of protection" + assert device.status_message == "Loss of protection" + _ = device.alarms + assert fetcher.alarm_section_calls == 1 + + module_alarms = snap.get_alarms_for_module("leaf-a", "leaf-a.dev.module-1") + assert len(module_alarms) == 1 + assert module_alarms[0].message == "Loss of protection" + + port_alarms = snap.get_alarms_for_port("leaf-a.dev.module-1.port-out-1") + assert len(port_alarms) == 1 + assert port_alarms[0].message == "Loss of disjunctivity" + assert port_alarms[0].severity == InspectSeverity.MINOR + + +def test_post_commit_marks_alarms_stale(snapshot: tuple[InspectSnapshot, FakeFetcher]) -> None: + snap, fetcher = snapshot + fetcher._alarms = [ + InspectApiAlarmItem.model_validate( + { + "_id": "1:leaf-a.dev:Mock", + "acked": False, + "id": {"alertId": "Mock", "component": 1, "pointId": ["leaf-a", "dev"]}, + "info": {"details": "Mock driver in use", "severity": 2, "sa": 2}, + } + ) + ] + assert len(snap.get_device("leaf-a").alarms) == 1 + assert fetcher.alarm_section_calls == 1 + snap.apply_post_commit(mark_paths_stale=True) + assert len(snap.get_device("leaf-a").alarms) == 1 + assert fetcher.alarm_section_calls == 2 + + +def test_linked_devices_from_edges(snapshot: tuple[InspectSnapshot, FakeFetcher]) -> None: + snap, _ = snapshot + assert {d.id for d in snap.get_device("leaf-a").linked_devices} == {"spine-a"} + + +def test_preload_hydrates_all(snapshot: tuple[InspectSnapshot, FakeFetcher]) -> None: + snap, fetcher = snapshot + snap.preload() + assert set(fetcher.device_detail_calls) == {"spine-a", "leaf-a"} + assert snap.get_device("spine-a").is_hydrated + + +def test_refresh_returns_new_snapshot(snapshot: tuple[InspectSnapshot, FakeFetcher]) -> None: + snap, fetcher = snapshot + new = snap.refresh() + assert new is not snap + assert fetcher.skeleton_calls == 1 + assert {d.id for d in new.devices} == {"spine-a", "leaf-a"} + + +def test_post_commit_removes_locally_and_refreshes_pair(snapshot: tuple[InspectSnapshot, FakeFetcher]) -> None: + snap, fetcher = snapshot + # remove a device locally + snap.apply_post_commit(removed_ids=["spine-a"], mark_paths_stale=False) + assert snap.get_device("spine-a") is None + assert {d.id for d in snap.devices} == {"leaf-a"} + + +def test_post_commit_refreshes_edge_pair(snapshot: tuple[InspectSnapshot, FakeFetcher]) -> None: + snap, fetcher = snapshot + snap.apply_post_commit(pair_ids=["leaf-a::spine-a"]) + assert "leaf-a::spine-a" in fetcher.edge_pair_calls + + +def test_post_commit_marks_paths_stale(snapshot: tuple[InspectSnapshot, FakeFetcher]) -> None: + snap, fetcher = snapshot + _ = snap.services # load section + assert fetcher.section_calls == 1 + snap.apply_post_commit(mark_paths_stale=True) + _ = snap.services # reload + assert fetcher.section_calls == 2 + + +def test_post_commit_reindexes_changed_label(snapshot: tuple[InspectSnapshot, FakeFetcher]) -> None: + # Latent-bug guard: a committed label change must re-point the label index. + snap, fetcher = snapshot + fetcher._details["leaf-a"] = _detail_node("leaf-a", "LEAF-A-RENAMED", ["leaf-a.dev.0.up1"]) + snap.apply_post_commit(device_ids=["leaf-a"], mark_paths_stale=False) + assert snap.find_device_by_label("LEAF-A-RENAMED").id == "leaf-a" + assert snap.find_device_by_label("LEAF-A") is None + + +def test_post_commit_refetch_failure_does_not_raise_and_self_heals( + snapshot: tuple[InspectSnapshot, FakeFetcher], +) -> None: + snap, fetcher = snapshot + calls = {"n": 0} + real = fetcher.get_device_detail + + def flaky(device_id: str) -> InspectApiNodeStatusItem | None: + calls["n"] += 1 + if calls["n"] == 1: + raise RuntimeError("network blip") + return real(device_id) + + fetcher.get_device_detail = flaky + # The refresh failure is swallowed (the commit result must survive); the device is marked stale. + snap.apply_post_commit(device_ids=["leaf-a"], mark_paths_stale=False) + # Next access re-fetches (self-heal) and succeeds. + assert snap.get_device("leaf-a").label == "LEAF-A" + assert calls["n"] == 2 + + +def test_apply_network_refresh_adds_new_device_and_edge(snapshot: tuple[InspectSnapshot, FakeFetcher]) -> None: + snap, fetcher = snapshot + fetcher._details["leaf-b"] = _detail_node("leaf-b", "LEAF-B", ["leaf-b.dev.0.up1"]) + fetcher.get_edge_skeleton = lambda: [ + _edge_pair("leaf-a", "spine-a", "leaf-a.dev.0.up1", "spine-a.dev.0.swp1"), + _edge_pair("leaf-b", "spine-a", "leaf-b.dev.0.up1", "spine-a.dev.0.swp2"), + ] + snap.apply_network_refresh(["leaf-b"]) + assert snap.get_device("leaf-b") is not None + assert snap.find_device_by_label("LEAF-B").id == "leaf-b" + assert {e.pair_id for e in snap.get_edges_for_device("leaf-b")} == {"leaf-b::spine-a"} + + +def test_full_snapshot_is_hydrated_without_fetcher() -> None: + fetcher = FakeFetcher() + # Build a full snapshot manually (device_level=FULL, path_items provided) + snap = InspectSnapshot( + fetcher=None, + device_items=[fetcher._details["leaf-a"], fetcher._details["spine-a"]], + edge_items=[_edge_pair("leaf-a", "spine-a", "leaf-a.dev.0.up1", "spine-a.dev.0.swp1")], + device_level=HydrationLevel.FULL, + path_items=fetcher._paths, + ) + leaf = snap.get_device("leaf-a") + assert leaf.is_hydrated + assert len(leaf.ports) == 2 + # sections available without a fetcher + assert {s.booking_id for s in snap.services} == {"1001"} + # refresh without a fetcher raises + with pytest.raises(RuntimeError): + snap.refresh() + + +def test_port_is_lean_and_vertex_carries_flags(snapshot: tuple[InspectSnapshot, FakeFetcher]) -> None: + snap, _ = snapshot + port = snap.get_port("leaf-a", "leaf-a.dev.0.up1") + # Port keeps only the module-level attributes. + assert port.label == "up1" # descriptor override + assert port.factory_label == "up1-factory" + assert not hasattr(port, "is_active") # vertex-specific fields live on the vertex now + assert not hasattr(port, "edge") # edges are accessed via the collection now + # The direction/status flags live on the vertex. + assert port.is_bidirectional is False + assert port.vertex_out is not None + assert port.vertex_out.id == "leaf-a.0.up1" + assert port.vertex_out.vertex_type == "Out" + assert port.vertex_out.is_active is True + assert port.vertex_out.is_controlled is True + assert port.vertex_out.is_endpoint is False + assert port.vertex_in is None + + +def test_bidirectional_port_vertex_accessors(snapshot: tuple[InspectSnapshot, FakeFetcher]) -> None: + snap, fetcher = snapshot + fetcher._details["leaf-a"] = _filter_detail_node("leaf-a", "LEAF-A") + port = snap.get_port("leaf-a", "leaf-a.dev.1.bidi1") + assert port.is_bidirectional is True + assert port.vertex_out is not None and port.vertex_out.vertex_type == "Out" + assert port.vertex_in is not None and port.vertex_in.vertex_type == "In" + assert port.vertex_out.id == "leaf-a.1.bidi1.out" + assert port.vertex_in.id == "leaf-a.1.bidi1.in" + + +def test_vertex_config_attrs_fetch_once_and_cache(snapshot: tuple[InspectSnapshot, FakeFetcher]) -> None: + snap, fetcher = snapshot + vertex = snap.get_port("leaf-a", "leaf-a.dev.0.up1").vertex_out + assert vertex is not None + assert vertex.type_fields is not None and vertex.type_fields.type == "ip" + assert vertex.vertex_kind == "ip" + assert vertex.sips_mode == "NONE" + assert vertex.control_props is not None and vertex.control_props.configPriority == "off" + assert vertex.extra_alert_filters == [] + assert vertex.custom == {} + assert vertex.custom_schemas == {} + assert vertex.queueable is False + assert vertex.destination_monitor_leader is False + assert vertex.park_port is None + _ = vertex.vertex_kind # second access → cached + assert fetcher.vertex_lookup_calls == [["leaf-a.0.up1"]] + + +def test_vertex_lookup_invalidated_by_post_commit_device_refresh( + snapshot: tuple[InspectSnapshot, FakeFetcher], +) -> None: + snap, fetcher = snapshot + _ = snap.get_port("leaf-a", "leaf-a.dev.0.up1").vertex_out.vertex_kind + assert len(fetcher.vertex_lookup_calls) == 1 + snap.apply_post_commit(device_ids=["leaf-a"], mark_paths_stale=False) + _ = snap.get_port("leaf-a", "leaf-a.dev.0.up1").vertex_out.vertex_kind + assert len(fetcher.vertex_lookup_calls) == 2 + + +def test_vertex_flags_offline_but_config_none_without_fetcher() -> None: + fetcher = FakeFetcher() + snap = InspectSnapshot( + fetcher=None, + device_items=[fetcher._details["leaf-a"]], + device_level=HydrationLevel.FULL, + ) + vertex = snap.get_port("leaf-a", "leaf-a.dev.0.up1").vertex_out + assert vertex is not None + assert vertex.vertex_type == "Out" # offline from the port's vertexInfo + assert vertex.is_active is True # offline + assert vertex.vertex_kind is None # needs a lookup; no fetcher + assert vertex.sips_mode is None + assert vertex.control_props is None + assert vertex.park_port is None + + +def test_filter_ports_by_module_and_direction(snapshot: tuple[InspectSnapshot, FakeFetcher]) -> None: + snap, fetcher = snapshot + fetcher._details["leaf-a"] = _filter_detail_node("leaf-a", "LEAF-A") + leaf = snap.get_device("leaf-a") + assert {p.label for p in leaf.filter_ports(module_id="leaf-a.dev.0")} == {"up1", "host1"} + assert {p.label for p in leaf.filter_ports(vertex_type="Out")} == {"up1"} + assert {p.label for p in leaf.filter_ports(vertex_type="BiDirectional")} == {"bidi1"} + assert {p.label for p in leaf.filter_ports(module_id="leaf-a.dev.1", vertex_type="BiDirectional")} == {"bidi1"} + assert fetcher.vertex_lookup_calls == [] + + +def test_filter_ports_by_flags(snapshot: tuple[InspectSnapshot, FakeFetcher]) -> None: + snap, fetcher = snapshot + fetcher._details["leaf-a"] = _filter_detail_node("leaf-a", "LEAF-A") + leaf = snap.get_device("leaf-a") + assert {p.label for p in leaf.filter_ports(active=True)} == {"up1", "bidi1"} + assert {p.label for p in leaf.filter_ports(active=False)} == {"host1"} # mgmt1 (unknown) never matches + assert {p.label for p in leaf.filter_ports(endpoint=True)} == {"host1", "bidi1"} + assert {p.label for p in leaf.filter_ports(controlled=True)} == {"up1"} + assert {p.label for p in leaf.filter_ports(active=True, endpoint=True)} == {"bidi1"} + assert fetcher.vertex_lookup_calls == [] + + +def test_filter_ports_by_kind_uses_one_batched_lookup(snapshot: tuple[InspectSnapshot, FakeFetcher]) -> None: + snap, fetcher = snapshot + fetcher._details["leaf-a"] = _filter_detail_node("leaf-a", "LEAF-A") + leaf = snap.get_device("leaf-a") + ports = leaf.filter_ports(kind="ip") + assert {p.label for p in ports} == {"up1", "host1", "bidi1"} # mgmt1 has no vertex → excluded + assert len(fetcher.vertex_lookup_calls) == 1 # one batched call for all uncached vertices + assert set(fetcher.vertex_lookup_calls[0]) == {"leaf-a.0.up1", "leaf-a.0.host1", "leaf-a.1.bidi1.out"} + _ = leaf.filter_ports(kind="ip") # cached → no further lookups + assert len(fetcher.vertex_lookup_calls) == 1 + + +# --- Internal --- + + +def _skeleton_node( + device_id: str, + label: str, + x: float = 0.0, + y: float = 0.0, + sync: int = 0, +) -> InspectApiNodeStatusItem: + return InspectApiNodeStatusItem.model_validate( + { + "_id": device_id, + "_vid": device_id, + "deviceId": device_id, + "descriptor": {"desc": f"{label} description", "label": label}, + "meta": {"coordinates": {"x": x, "y": y}, "isVirtual": True, "iconType": "switch"}, + "status": {"sa": 0, "severity": 0}, + "syncSeverity": sync, + "tags": ["#e2e"], + "modules": {}, + } + ) + + +def _detail_node(device_id: str, label: str, port_pids: list[str]) -> InspectApiNodeStatusItem: + ports = { + pid: { + "pid": pid, + "descriptor": {"label": pid.split(".")[-1]}, + "label": f"{pid.split('.')[-1]}-factory", + "status": {"sa": 0, "severity": 0}, + "vertexInfo": _single_vertex_info( + pid.replace(".dev.", "."), "Out", active=True, controlled=True, endpoint=False + ), + } + for pid in port_pids + } + return InspectApiNodeStatusItem.model_validate( + { + "_id": device_id, + "_vid": device_id, + "deviceId": device_id, + "descriptor": {"desc": "", "label": label}, + "meta": {"coordinates": {"x": 0, "y": 0}}, + "status": {"sa": 0, "severity": 0}, + "syncSeverity": 0, + "modules": { + f"{device_id}.dev.0": { + "pid": f"{device_id}.dev.0", + "descriptor": {"label": "Slot 0"}, + "ports": ports, + } + }, + } + ) + + +def _single_vertex_info(vertex_id: str, vertex_type: str, *, active: bool, controlled: bool, endpoint: bool) -> dict: + return { + "type": "single", + "id": vertex_id, + "vertexType": vertex_type, + "fields": {"isActive": active, "isControlled": controlled, "isEndpoint": endpoint}, + } + + +def _filter_detail_node(device_id: str, label: str) -> InspectApiNodeStatusItem: + """A hydrated device with port variety for filter tests: two single vertices (Out active + controlled / In inactive endpoint), one double (bidirectional endpoint), one without vertexInfo.""" + + def port(module: str, name: str, vertex_info: dict | None) -> dict: + entry: dict = {"pid": f"{device_id}.dev.{module}.{name}", "descriptor": {"label": name}} + if vertex_info is not None: + entry["vertexInfo"] = vertex_info + return entry + + modules = { + f"{device_id}.dev.0": { + "pid": f"{device_id}.dev.0", + "descriptor": {"label": "Slot 0"}, + "ports": { + f"{device_id}.dev.0.up1": port( + "0", + "up1", + _single_vertex_info(f"{device_id}.0.up1", "Out", active=True, controlled=True, endpoint=False), + ), + f"{device_id}.dev.0.host1": port( + "0", + "host1", + _single_vertex_info(f"{device_id}.0.host1", "In", active=False, controlled=False, endpoint=True), + ), + }, + }, + f"{device_id}.dev.1": { + "pid": f"{device_id}.dev.1", + "descriptor": {"label": "Slot 1"}, + "ports": { + f"{device_id}.dev.1.bidi1": port( + "1", + "bidi1", + { + "type": "double", + "in": _single_vertex_info( + f"{device_id}.1.bidi1.in", "In", active=True, controlled=False, endpoint=True + ), + "out": _single_vertex_info( + f"{device_id}.1.bidi1.out", "Out", active=True, controlled=False, endpoint=True + ), + }, + ), + f"{device_id}.dev.1.mgmt1": port("1", "mgmt1", None), + }, + }, + } + return InspectApiNodeStatusItem.model_validate( + { + "_id": device_id, + "_vid": device_id, + "deviceId": device_id, + "descriptor": {"desc": "", "label": label}, + "modules": modules, + } + ) + + +def _edge_pair(dev_a: str, dev_b: str, port_a: str, port_b: str) -> InspectApiExternalEdgesByDeviceKeyItem: + edge_id = f"{port_a}::{port_b}" + return InspectApiExternalEdgesByDeviceKeyItem.model_validate( + { + "_id": f"{dev_a}::{dev_b}", + "_vid": f"{dev_a}::{dev_b}", + "primary": { + "devicePid": dev_a, + "label": dev_a, + "data": { + edge_id: { + "id": edge_id, + "fromStatus": {"context": {"devicePid": dev_a, "portPid": port_a}, "label": "out"}, + "toStatus": {"context": {"devicePid": dev_b, "portPid": port_b}, "label": "in"}, + } + }, + }, + "secondary": {"devicePid": dev_b, "label": dev_b, "data": {}}, + "status": {"alarm": 0, "bandwidth": 0, "maintenance": 0, "ptp": 0}, + } + ) + + +def _path_item(booking: str, dev_a: str, dev_b: str) -> InspectApiPathItem: + return InspectApiPathItem.model_validate( + { + "_id": f"{booking}::main", + "_vid": f"_:{booking}::main", + "serviceFields": {"bid": booking, "isMain": True, "fromLabel": "src", "toLabel": "dst"}, + "path": [ + {"bid": booking, "structure": {"deviceId": dev_a, "devicePid": dev_a}}, + {"bid": booking, "structure": {"deviceId": dev_b, "devicePid": dev_b}}, + ], + } + ) + + +class FakeFetcher: + """Records how many detail/section fetches occur so laziness can be asserted.""" + + def __init__(self) -> None: + self.device_detail_calls: list[str] = [] + self.edge_pair_calls: list[str] = [] + self.vertex_lookup_calls: list[list[str]] = [] + self.edge_lookup_calls: list[list[str]] = [] + self.section_calls = 0 + self.alarm_section_calls = 0 + self.skeleton_calls = 0 + self._details = { + "spine-a": _detail_node("spine-a", "SPINE-A", ["spine-a.dev.0.swp1", "spine-a.dev.0.swp2"]), + "leaf-a": _detail_node("leaf-a", "LEAF-A", ["leaf-a.dev.0.up1", "leaf-a.dev.0.host1"]), + } + self._paths = [_path_item("1001", "leaf-a", "spine-a")] + self._alarms: list[InspectApiAlarmItem] = [] + + def get_device_skeleton(self) -> list[InspectApiNodeStatusItem]: + self.skeleton_calls += 1 + return [_skeleton_node("spine-a", "SPINE-A"), _skeleton_node("leaf-a", "LEAF-A")] + + def get_edge_skeleton(self) -> list[InspectApiExternalEdgesByDeviceKeyItem]: + return [_edge_pair("leaf-a", "spine-a", "leaf-a.dev.0.up1", "spine-a.dev.0.swp1")] + + def get_device_detail(self, device_id: str) -> InspectApiNodeStatusItem | None: + self.device_detail_calls.append(device_id) + return self._details.get(device_id) + + def get_edge_pair(self, pair_id: str) -> InspectApiExternalEdgesByDeviceKeyItem: + self.edge_pair_calls.append(pair_id) + a, b = pair_id.split("::") + return _edge_pair(a, b, f"{a}.dev.0.up1", f"{b}.dev.0.swp1") + + def get_paths_section(self) -> list[InspectApiPathItem]: + self.section_calls += 1 + return self._paths + + def get_alarms_section(self) -> list[InspectApiAlarmItem]: + self.alarm_section_calls += 1 + return list(self._alarms) + + def lookup_vertices(self, vertex_ids: list[str]) -> InspectApiLookupVerticesResponse: + self.vertex_lookup_calls.append(list(vertex_ids)) + data = {vertex_id: _vertex_lookup_item(vertex_id) for vertex_id in vertex_ids} + return InspectApiLookupVerticesResponse.model_validate({"data": data, "header": _fetcher_ok_header()}) + + def lookup_edges(self, edge_ids: list[str]) -> InspectApiLookupEdgesResponse: + self.edge_lookup_calls.append(list(edge_ids)) + data = { + edge_id: { + "edge": {"fromId": edge_id.split("::")[0], "toId": edge_id.split("::")[-1]}, + "fromDevice": None, + "toDevice": None, + } + for edge_id in edge_ids + } + return InspectApiLookupEdgesResponse.model_validate({"data": data, "header": _fetcher_ok_header()}) + + +def _fetcher_ok_header() -> dict[str, object]: + return { + "auth": True, + "caption": "Operation Successful", + "code": "OK", + "errorCodes": [], + "errorDetails": [], + "id": "0", + "msg": [], + "ok": True, + "user": "api-user", + } + + +def _vertex_lookup_item(vertex_id: str) -> dict[str, object]: + """An IP-vertex lookup item; codec ids (containing 'codec') return the codec fixture's data.""" + if "codec" in vertex_id: + item = dict(load_fixture("lookup_inspect_codec_vertex_by_id.json")["data"]) + item["id"] = vertex_id + return item + return { + "id": vertex_id, + "isVirtual": False, + "vertexType": "Out", + "customSchemas": {}, + "fields": { + "active": True, + "controlProps": {"configPriority": "off", "onlyInitial": False}, + "custom": {}, + "desc": "", + "destinationMonitorLeader": False, + "extraAlertFilters": [], + "label": vertex_id, + "localAssignedTags": [], + "queueable": False, + "sipsMode": "NONE", + "tags": [], + "typeFields": {"type": "ip", "ipAddress": "10.0.0.1", "vlanId": "100"}, + "useAsEndpoint": False, + }, + } + + +# --- Typed vertices + edge config (new field coverage) --- + + +def test_get_vertex_returns_typed_ip_vertex(snapshot: tuple[InspectSnapshot, FakeFetcher]) -> None: + from videoipath_automation_tool.apps.inspect.domain.vertex import InspectIpVertex + + snap, _ = snapshot + vertex = snap.get_vertex("leaf-a.0.up1") + assert isinstance(vertex, InspectIpVertex) + assert vertex.vertex_kind == "ip" + assert vertex.vertex_type == "Out" + assert vertex.ip_address == "10.0.0.1" + assert vertex.vlan_id == "100" + + +def test_get_vertex_returns_typed_codec_vertex(snapshot: tuple[InspectSnapshot, FakeFetcher]) -> None: + from videoipath_automation_tool.apps.inspect.domain.vertex import InspectCodecVertex + + snap, _ = snapshot + vertex = snap.get_vertex("device-a.1.codec-out-1.out") + assert isinstance(vertex, InspectCodecVertex) + assert vertex.vertex_kind == "codec" + assert vertex.codec_format == "Video" + assert vertex.is_igmp_source is False + assert vertex.sdp_support is True + assert vertex.main_dst_info == {"ip": "10.0.0.1", "mac": None, "port": 5000, "vlan": None} + + +def test_edge_config_props_and_detail_cache(snapshot: tuple[InspectSnapshot, FakeFetcher]) -> None: + snap, fetcher = snapshot + edge = snap.edges[0] + assert edge.redundancy_mode == "Any" + assert edge.fixed_weight == 1 + assert edge.conflict_priority == "off" # on-wire 0 -> "off" + assert edge.services_capacity == 65535 + assert edge.bandwidth_weight_factor == 0 + _ = edge.label # further access is served from the cache + assert fetcher.edge_lookup_calls == [[edge.id]] + + +# --- Modules --- + + +def test_device_modules_group_ports(snapshot: tuple[InspectSnapshot, FakeFetcher]) -> None: + snap, _ = snapshot + leaf = snap.get_device("leaf-a") + modules = leaf.modules + assert len(modules) == 1 + module = modules[0] + assert module.id == "leaf-a.dev.0" + assert module.label == "Slot 0" + assert {p.label for p in module.ports} == {"up1", "host1"} + # module.ports == device.ports grouped by module, and every port links back to its module + assert {p.id for p in module.ports} == { + p.id for p in leaf.ports if p.module is not None and p.module.id == "leaf-a.dev.0" + } + assert all(p.module is not None for p in leaf.ports) + assert all(p.module is not None and p.module.id == module.id for p in module.ports) + # flattened convenience: one vertex per (single-vertex) port + assert len(module.vertices) == 2 + assert leaf.get_module("leaf-a.dev.0").id == "leaf-a.dev.0" + assert leaf.get_module("no-such-module") is None + + +def test_module_vertices_uses_one_batched_lookup(snapshot: tuple[InspectSnapshot, FakeFetcher]) -> None: + snap, fetcher = snapshot + module = snap.get_device("leaf-a").get_module("leaf-a.dev.0") + assert module is not None + vertices = module.vertices + assert {v.id for v in vertices} == {"leaf-a.0.up1", "leaf-a.0.host1"} + assert len(fetcher.vertex_lookup_calls) == 1 + assert set(fetcher.vertex_lookup_calls[0]) == {"leaf-a.0.up1", "leaf-a.0.host1"} + + +def test_get_vertices_by_module_label_uses_one_batched_lookup( + snapshot: tuple[InspectSnapshot, FakeFetcher], +) -> None: + snap, fetcher = snapshot + fetcher._details["leaf-a"] = _filter_detail_node("leaf-a", "LEAF-A") + leaf = snap.get_device("leaf-a") + vertices = leaf.get_vertices_by_module_label("Slot 1") + assert {v.id for v in vertices} == {"leaf-a.1.bidi1.out", "leaf-a.1.bidi1.in"} + assert len(fetcher.vertex_lookup_calls) == 1 + assert set(fetcher.vertex_lookup_calls[0]) == {"leaf-a.1.bidi1.out", "leaf-a.1.bidi1.in"} + ip_only = leaf.get_vertices_by_module_label("Slot 1", kind="ip") + assert {v.id for v in ip_only} == {"leaf-a.1.bidi1.out", "leaf-a.1.bidi1.in"} + assert len(fetcher.vertex_lookup_calls) == 1 # cached → no further lookups + + +def test_preload_continues_when_one_device_fails(snapshot: tuple[InspectSnapshot, FakeFetcher]) -> None: + snap, fetcher = snapshot + + original = fetcher.get_device_detail + + def flaky_detail(device_id: str) -> InspectApiNodeStatusItem | None: + if device_id == "spine-a": + raise RuntimeError("simulated detail failure") + return original(device_id) + + fetcher.get_device_detail = flaky_detail # type: ignore[method-assign] + snap.preload() + assert "leaf-a" in fetcher.device_detail_calls + assert snap.get_device("leaf-a").is_hydrated is True + assert snap.get_device("spine-a").is_hydrated is False + assert "spine-a" in snap._stale_devices + + +def test_device_exposes_multiple_modules(snapshot: tuple[InspectSnapshot, FakeFetcher]) -> None: + snap, fetcher = snapshot + fetcher._details["leaf-a"] = _filter_detail_node("leaf-a", "LEAF-A") + leaf = snap.get_device("leaf-a") + modules = {m.label: m for m in leaf.modules} + assert set(modules) == {"Slot 0", "Slot 1"} + assert {p.label for p in modules["Slot 0"].ports} == {"up1", "host1"} + assert {p.label for p in modules["Slot 1"].ports} == {"bidi1", "mgmt1"} + + +@pytest.fixture +def snapshot() -> Iterator[tuple[InspectSnapshot, FakeFetcher]]: + fetcher = FakeFetcher() + snap = InspectSnapshot( + fetcher=fetcher, + device_items=fetcher.get_device_skeleton(), + edge_items=fetcher.get_edge_skeleton(), + ) + fetcher.skeleton_calls = 0 # reset after construction + yield snap, fetcher diff --git a/tests/inspect/test_transaction.py b/tests/inspect/test_transaction.py new file mode 100644 index 0000000..068aa9e --- /dev/null +++ b/tests/inspect/test_transaction.py @@ -0,0 +1,543 @@ +"""Transaction unit tests with a fake API (offline). + +Covers staging + intent application, payload-shape assertions against the verified write forms, +the three-flag commit result, conflict detection (compare-and-commit), the ``check_conflicts=False`` +bypass, ``rebase``, and the post-commit targeted-refresh hook derivation. +""" + +from __future__ import annotations + +from types import SimpleNamespace +from typing import Any + +import pytest + +from videoipath_automation_tool.apps.inspect.transaction import InspectTransaction +from videoipath_automation_tool.apps.inspect.errors import ( + InspectCommitConflictError, + InspectCommitError, + InspectEntityNotFoundError, +) +from videoipath_automation_tool.apps.inspect.model.actions import ( + InspectApiLookupEdgeResponseItem, + InspectApiLookupInspectDeviceResponse, + InspectApiLookupVertexResponseData, +) +from videoipath_automation_tool.apps.inspect.model.update_topology import InspectApiUpdateTopologyResponse + +from .conftest import load_fixture + +_OK_HEADER = { + "auth": True, + "caption": "OK", + "code": "OK", + "errorCodes": [], + "errorDetails": [], + "id": "0", + "msg": [], + "ok": True, + "user": "api-user", +} + +A_OUT = "device12.1.Ethernet1.out" +B_IN = "device7.0.swp1.in" +EDGE_ID = f"{A_OUT}::{B_IN}" + + +# --- Staging + payload shape --- + + +def test_connect_builds_edge_payload() -> None: + api = FakeAPI() + with _txn(api) as tx: + tx.connect(A_OUT, B_IN, bidirectional=False, weight=10) + tx.commit() + delta = api.update_calls[0] + assert list(delta.replaceEdges) == [EDGE_ID] + edge = delta.replaceEdges[EDGE_ID] + assert edge.fromId == A_OUT and edge.toId == B_IN + assert edge.weight == 10 and edge.capacity == 65535 and edge.redundancyMode == "Any" + + +def test_connect_bidirectional_stages_reverse_edge() -> None: + api = FakeAPI() + with _txn(api) as tx: + tx.connect(A_OUT, B_IN, bidirectional=True) + tx.commit() + keys = set(api.update_calls[0].replaceEdges) + assert keys == {EDGE_ID, "device7.0.swp1.out::device12.1.Ethernet1.in"} + + +def test_connect_existing_edge_requires_overwrite() -> None: + api = FakeAPI() + api.edges[EDGE_ID] = _edge_item() + with _txn(api) as tx: + with pytest.raises(ValueError, match="already exists"): + tx.connect(A_OUT, B_IN, bidirectional=False) + + +def test_update_edge_applies_intent() -> None: + api = FakeAPI() + api.edges[EDGE_ID] = _edge_item() + with _txn(api) as tx: + tx.update_edge(EDGE_ID, weight=42) + tx.commit() + assert api.update_calls[0].replaceEdges[EDGE_ID].weight == 42 + + +def test_update_edge_missing_raises_not_found() -> None: + api = FakeAPI() + with _txn(api) as tx: + with pytest.raises(InspectEntityNotFoundError): + tx.update_edge(EDGE_ID, weight=42) + + +def test_update_edge_sets_all_config_fields() -> None: + api = FakeAPI() + api.edges[EDGE_ID] = _edge_item() + with _txn(api) as tx: + tx.update_edge( + EDGE_ID, + label="A -> B", + description="link", + include_formats=["fmt-a"], + exclude_formats=["fmt-b"], + conflict_priority="high", + bandwidth_weight_factor=5, + weight_per_service=3, + ) + tx.commit() + form = api.update_calls[0].replaceEdges[EDGE_ID] + assert form.descriptor.label == "A -> B" and form.descriptor.desc == "link" + assert form.includeFormats == ["fmt-a"] and form.excludeFormats == ["fmt-b"] + assert form.conflictPri == 1 # "high" mapped to the on-wire int + assert form.weightFactors["bandwidth"]["weight"] == 5 + assert form.weightFactors["service"]["weight"] == 3 + assert form.weightFactors["service"]["max"] == 100 # untouched sub-value preserved + + +def test_update_edge_also_opposite_stages_reverse() -> None: + opposite_id = "device7.0.swp1.out::device12.1.Ethernet1.in" + api = FakeAPI() + api.edges[EDGE_ID] = _edge_item() + api.edges[opposite_id] = _edge_item() + with _txn(api) as tx: + tx.update_edge(EDGE_ID, weight=7, also_opposite=True) + tx.commit() + replaced = api.update_calls[0].replaceEdges + assert set(replaced) == {EDGE_ID, opposite_id} + assert replaced[EDGE_ID].weight == 7 and replaced[opposite_id].weight == 7 + + +def test_update_edge_also_opposite_missing_raises() -> None: + api = FakeAPI() + api.edges[EDGE_ID] = _edge_item() + with _txn(api) as tx: + with pytest.raises(InspectEntityNotFoundError): + tx.update_edge(EDGE_ID, weight=7, also_opposite=True) + + +def test_update_device_only_changes_label_when_set() -> None: + api = FakeAPI() + api.devices["device12"] = _device_response(label="Original", icon_type="switch") + with _txn(api) as tx: + tx.update_device("device12", icon_type="router") + tx.commit() + form = api.update_calls[0].replaceDevices["device12"] + assert form.descriptor.label == "Original" # round-tripped, not cleared (descriptor mandatory) + assert form.iconType == "router" + + +def test_update_device_sets_label() -> None: + api = FakeAPI() + api.devices["device12"] = _device_response(label="Original") + with _txn(api) as tx: + tx.update_device("device12", label="BU-LEAF-A") + tx.commit() + assert api.update_calls[0].replaceDevices["device12"].descriptor.label == "BU-LEAF-A" + + +def test_update_device_sets_all_edit_fields() -> None: + api = FakeAPI() + api.devices["device12"] = _device_response(label="Original") + with _txn(api) as tx: + tx.update_device("device12", description="Spine A", site_id="site-a", icon_size="large") + tx.commit() + form = api.update_calls[0].replaceDevices["device12"] + assert form.descriptor.desc == "Spine A" + assert form.siteId == "site-a" + assert form.iconSize == "large" + assert form.descriptor.label == "Original" # untouched + + +def test_place_device_sets_coordinates() -> None: + api = FakeAPI() + api.devices["device12"] = _device_response() + with _txn(api) as tx: + tx.place_device("device12", 1600, 9050) + tx.commit() + assert api.update_calls[0].replaceDevices["device12"].coordinates == {"x": 1600, "y": 9050} + + +def test_update_vertex_sets_endpoint() -> None: + api = FakeAPI() + api.vertices[A_OUT] = _vertex_data() + with _txn(api) as tx: + tx.update_vertex(A_OUT, use_as_endpoint=True) + tx.commit() + assert api.update_calls[0].replaceVertices[A_OUT].useAsEndpoint is True + + +def test_update_vertex_assigns_tags_as_local() -> None: + # Port tag assignment goes to localAssignedTags, not the plain fields.tags list. + api = FakeAPI() + api.vertices[A_OUT] = _vertex_data() + with _txn(api) as tx: + tx.update_vertex(A_OUT, tags=["Video~~T"]) + tx.commit() + form = api.update_calls[0].replaceVertices[A_OUT] + assert form.localAssignedTags == ["Video~~T"] + assert form.tags == [] + + +def test_update_vertex_sets_description() -> None: + api = FakeAPI() + api.vertices[A_OUT] = _vertex_data() + with _txn(api) as tx: + tx.update_vertex(A_OUT, description="Router input") + tx.commit() + assert api.update_calls[0].replaceVertices[A_OUT].desc == "Router input" + + +def test_update_vertex_sets_active_and_sips_mode() -> None: + api = FakeAPI() + api.vertices[A_OUT] = _vertex_data() + with _txn(api) as tx: + tx.update_vertex(A_OUT, active=False, sips_mode="SIPSAuto") + tx.commit() + form = api.update_calls[0].replaceVertices[A_OUT] + assert form.active is False + assert form.sipsMode == "SIPSAuto" + + +def test_update_vertex_sets_control_props_from_dict() -> None: + api = FakeAPI() + api.vertices[A_OUT] = _vertex_data() + with _txn(api) as tx: + tx.update_vertex(A_OUT, control_props={"configPriority": "high", "onlyInitial": True}) + tx.commit() + form = api.update_calls[0].replaceVertices[A_OUT] + assert form.controlProps is not None + assert form.controlProps.configPriority == "high" + assert form.controlProps.onlyInitial is True + + +def test_update_vertex_sets_extra_alert_filters_and_custom() -> None: + api = FakeAPI() + api.vertices[A_OUT] = _vertex_data() + with _txn(api) as tx: + tx.update_vertex( + A_OUT, + extra_alert_filters=["alarm-point-a"], + custom={"param-a": "value-a"}, + ) + tx.commit() + form = api.update_calls[0].replaceVertices[A_OUT] + assert form.extraAlertFilters == ["alarm-point-a"] + assert form.custom == {"param-a": "value-a"} + + +def test_update_vertex_sets_park_port() -> None: + api = FakeAPI() + api.vertices[A_OUT] = _router_vertex_data() + with _txn(api) as tx: + tx.update_vertex(A_OUT, park_port=99) + tx.commit() + form = api.update_calls[0].replaceVertices[A_OUT] + assert form.typeFields is not None + assert form.typeFields.parkPort == 99 + + +def test_update_vertex_sets_ip_typefields() -> None: + api = FakeAPI() + api.vertices[A_OUT] = _vertex_data() # the IP vertex fixture (typeFields populated) + with _txn(api) as tx: + tx.update_vertex(A_OUT, ip_address="10.0.0.1", vlan_id="100", public=True, supports_igmp=False) + tx.commit() + form = api.update_calls[0].replaceVertices[A_OUT] + assert form.typeFields is not None + assert form.typeFields.ipAddress == "10.0.0.1" + assert form.typeFields.vlanId == "100" + assert form.typeFields.public is True + assert form.typeFields.supportsIgmpCfg is False + + +def test_remove_appends_to_remove_list() -> None: + api = FakeAPI() + with _txn(api) as tx: + tx.remove(EDGE_ID) + tx.commit() + assert api.update_calls[0].remove == [EDGE_ID] + + +def test_remove_virtual_device_id_classified_as_device() -> None: + from videoipath_automation_tool.apps.inspect.transaction import _DEVICE, _EDGE, _VERTEX, _device_of, _entity_kind + + assert _entity_kind("virtual.2") == _DEVICE + assert _entity_kind("virtual.2.0.1") == _VERTEX + assert _entity_kind("device12") == _DEVICE + assert _entity_kind("device12.1.Ethernet1.out") == _VERTEX + assert _entity_kind(EDGE_ID) == _EDGE + assert _device_of("virtual.2.0.1") == "virtual.2" + assert _device_of("device12.1.Ethernet1.out") == "device12" + + api = FakeAPI() + with _txn(api) as tx: + tx.remove("virtual.2") + tx.commit() + assert api.update_calls[0].remove == ["virtual.2"] + + +def test_disconnect_removes_both_directions() -> None: + api = FakeAPI() + with _txn(api) as tx: + tx.disconnect(A_OUT, B_IN, bidirectional=True) + tx.commit() + assert set(api.update_calls[0].remove) == {EDGE_ID, "device7.0.swp1.out::device12.1.Ethernet1.in"} + + +# --- Commit result / failure --- + + +def test_commit_success_returns_result() -> None: + api = FakeAPI() + api.edges[EDGE_ID] = _edge_item() + with _txn(api) as tx: + tx.update_edge(EDGE_ID, weight=5) + result = tx.commit() + assert result.ok is True + assert result.applied_ids == [EDGE_ID] + + +def test_commit_failure_raises_commit_error() -> None: + api = FakeAPI() + api.update_response = InspectApiUpdateTopologyResponse.model_validate( + load_fixture("update_topology_fail_remove.json") + ) + with _txn(api) as tx: + tx.remove("nonexistent-edge-id-xyz") + with pytest.raises(InspectCommitError) as exc: + tx.commit() + assert "non-existent" in str(exc.value) + + +def test_empty_commit_rejected() -> None: + api = FakeAPI() + tx = _txn(api) + with pytest.raises(ValueError, match="Nothing staged"): + tx.commit() + + +# --- Conflict detection --- + + +def test_conflict_detected_on_baseline_change() -> None: + api = FakeAPI() + api.edges[EDGE_ID] = _edge_item(weight=1) + tx = _txn(api) + tx.update_edge(EDGE_ID, weight=99) + api.edges[EDGE_ID] = _edge_item(weight=5) # concurrent out-of-band change + with pytest.raises(InspectCommitConflictError) as exc: + tx.commit() + conflict = exc.value.conflicts[0] + assert conflict.entity_id == EDGE_ID + assert "weight" in conflict.field_diffs + assert conflict.field_diffs["weight"] == (1, 5) + assert not api.update_calls # nothing sent + + +def test_conflict_check_bypass() -> None: + api = FakeAPI() + api.edges[EDGE_ID] = _edge_item(weight=1) + tx = _txn(api) + tx.update_edge(EDGE_ID, weight=99) + api.edges[EDGE_ID] = _edge_item(weight=5) + tx.commit(check_conflicts=False) + assert api.update_calls # sent despite the change + + +def test_conflict_when_entity_vanishes() -> None: + api = FakeAPI() + api.edges[EDGE_ID] = _edge_item() + tx = _txn(api) + tx.update_edge(EDGE_ID, weight=7) + api.edges.clear() # removed out-of-band + with pytest.raises(InspectCommitConflictError) as exc: + tx.commit() + assert "__exists__" in exc.value.conflicts[0].field_diffs + + +def test_rebase_refetches_baseline() -> None: + api = FakeAPI() + api.edges[EDGE_ID] = _edge_item(weight=1) + tx = _txn(api) + tx.update_edge(EDGE_ID, weight=99) + api.edges[EDGE_ID] = _edge_item(weight=5) + with pytest.raises(InspectCommitConflictError): + tx.commit() + tx.rebase() + tx.commit() # now baseline == server, no conflict + assert api.update_calls[0].replaceEdges[EDGE_ID].weight == 99 + + +# --- Post-commit refresh hook derivation --- + + +def test_post_commit_refresh_derives_affected_ids() -> None: + api = FakeAPI() + snapshot = FakeSnapshot() + with _txn(api, snapshot=snapshot) as tx: + tx.connect(A_OUT, B_IN, bidirectional=True) + tx.commit() + call = snapshot.calls[0] + assert set(call["pair_ids"]) == {"device12::device7", "device7::device12"} + assert call["device_ids"] == [] + assert call["mark_paths_stale"] is True + + +def test_post_commit_refresh_vertex_maps_to_device() -> None: + api = FakeAPI() + api.vertices[A_OUT] = _vertex_data() + snapshot = FakeSnapshot() + with _txn(api, snapshot=snapshot) as tx: + tx.update_vertex(A_OUT, use_as_endpoint=True) + tx.commit() + assert snapshot.calls[0]["device_ids"] == ["device12"] + + +# --- Lifecycle --- + + +def test_reuse_after_commit_rejected() -> None: + api = FakeAPI() + api.edges[EDGE_ID] = _edge_item() + tx = _txn(api) + tx.update_edge(EDGE_ID, weight=5) + tx.commit() + with pytest.raises(RuntimeError, match="already committed"): + tx.update_edge(EDGE_ID, weight=6) + + +def test_context_exit_without_commit_discards(caplog: pytest.LogCaptureFixture) -> None: + api = FakeAPI() + api.edges[EDGE_ID] = _edge_item() + with _txn(api) as tx: + tx.update_edge(EDGE_ID, weight=5) + assert not api.update_calls + assert tx._discarded + + +# --- Internal --- + + +def _device_response( + label: str = "Dev A", + coordinates: dict[str, int] | None = None, + icon_type: str = "switch", + tags: list[str] | None = None, +) -> InspectApiLookupInspectDeviceResponse: + return InspectApiLookupInspectDeviceResponse.model_validate( + { + "data": { + "assignedTags": {"all": [], "inherited": {}, "inheritedConflict": False, "local": {}}, + "fields": { + "coordinates": coordinates or {"x": 0, "y": 0}, + "descriptor": {"desc": "", "label": label}, + "iconSize": "medium", + "iconType": icon_type, + "localAssignedTags": [], + "sdpStrategy": None, + "siteId": None, + "tags": tags or [], + "virtualDeviceFields": None, + }, + }, + "header": _OK_HEADER, + } + ) + + +def _vertex_data() -> InspectApiLookupVertexResponseData: + fixture = load_fixture("lookup_inspect_vertex_by_id.json") + return InspectApiLookupVertexResponseData.model_validate(fixture["data"]) + + +def _router_vertex_data() -> InspectApiLookupVertexResponseData: + fixture = load_fixture("lookup_inspect_router_vertex_by_id.json") + return InspectApiLookupVertexResponseData.model_validate(fixture["data"]) + + +def _edge_item(weight: int = 1) -> InspectApiLookupEdgeResponseItem: + edge = { + "active": True, + "bandwidth": -1.0, + "capacity": 65535, + "conflictPri": 0, + "descriptor": {"desc": "", "label": ""}, + "excludeFormats": [], + "fDescriptor": {"desc": "", "label": ""}, + "fromId": A_OUT, + "includeFormats": [], + "redundancyMode": "Any", + "tags": [], + "toId": B_IN, + "weight": weight, + "weightFactors": {"bandwidth": {"weight": 0}, "service": {"max": 100, "weight": 0}}, + } + return InspectApiLookupEdgeResponseItem.model_validate( + {"edge": edge, "fromDevice": "device12", "toDevice": "device7"} + ) + + +class FakeAPI: + def __init__(self) -> None: + self.devices: dict[str, InspectApiLookupInspectDeviceResponse] = {} + self.vertices: dict[str, InspectApiLookupVertexResponseData] = {} + self.edges: dict[str, InspectApiLookupEdgeResponseItem] = {} + self.update_response = InspectApiUpdateTopologyResponse.model_validate( + load_fixture("update_topology_success.json") + ) + self.update_calls: list[Any] = [] + self.lookup_device_calls: list[str] = [] + self.lookup_vertices_calls: list[list[str]] = [] + self.lookup_edges_calls: list[list[str]] = [] + + def lookup_inspect_device(self, device_id: str) -> InspectApiLookupInspectDeviceResponse: + self.lookup_device_calls.append(device_id) + if device_id not in self.devices: + raise KeyError(device_id) + return self.devices[device_id] + + def lookup_vertices(self, ids: list[str]) -> SimpleNamespace: + self.lookup_vertices_calls.append(list(ids)) + return SimpleNamespace(data={i: self.vertices[i] for i in ids if i in self.vertices}) + + def lookup_edges(self, ids: list[str]) -> SimpleNamespace: + self.lookup_edges_calls.append(list(ids)) + return SimpleNamespace(data={i: self.edges[i] for i in ids if i in self.edges}) + + def update_topology(self, delta: Any) -> InspectApiUpdateTopologyResponse: + self.update_calls.append(delta) + return self.update_response + + +class FakeSnapshot: + def __init__(self) -> None: + self.calls: list[dict[str, Any]] = [] + + def apply_post_commit(self, **kwargs: Any) -> None: + self.calls.append(kwargs) + + +def _txn(api: FakeAPI, snapshot: FakeSnapshot | None = None) -> InspectTransaction: + return InspectTransaction(api, snapshot=snapshot) diff --git a/tests/inspect/test_virtual_devices.py b/tests/inspect/test_virtual_devices.py new file mode 100644 index 0000000..228e4ca --- /dev/null +++ b/tests/inspect/test_virtual_devices.py @@ -0,0 +1,423 @@ +"""Virtual device / port-template domain, wire, and actions tests.""" + +from __future__ import annotations + +import logging +from collections.abc import Callable +from types import SimpleNamespace +from typing import Any + +import pytest + +from videoipath_automation_tool.apps.inspect.app.actions import InspectActionsMixin +from videoipath_automation_tool.apps.inspect.app.write import InspectWriteMixin +from videoipath_automation_tool.apps.inspect.api import InspectAPI +from videoipath_automation_tool.apps.inspect.domain.device import InspectDevice, VirtualDeviceSpec +from videoipath_automation_tool.apps.inspect.domain.port import ( + InspectPortTemplate, + PortFromTemplate, + _ports_to_count_by_template, +) +from videoipath_automation_tool.apps.inspect.errors import InspectError +from videoipath_automation_tool.apps.inspect.model.actions import ( + InspectApiLookupInspectDeviceFields, + InspectApiLookupInspectDeviceResponse, +) +from videoipath_automation_tool.apps.inspect.model.collector import InspectApiNodeStatusItem +from videoipath_automation_tool.apps.inspect.model.update_topology import InspectApiUpdateTopologyResponse +from videoipath_automation_tool.apps.inspect.model.virtual import ( + InspectApiAddVirtualTopologyData, + InspectApiUpdateVirtualInstancesData, + InspectApiUpdateVirtualInstancesResponse, + InspectApiUpdateVirtualTemplatesData, + InspectApiVirtualDeviceInstance, + InspectApiVirtualTemplateItem, +) +from videoipath_automation_tool.apps.inspect.snapshot import InspectSnapshot + + +def test_virtual_templates_fixture_parses(load: Callable[[str], dict[str, Any]]) -> None: + items = load("virtual_templates.json")["data"]["status"]["network"]["virtualTemplates"]["_items"] + templates = [InspectApiVirtualTemplateItem.model_validate(item) for item in items] + assert templates[0].id == "generic_bidir" + assert templates[0].vertex.type == "genericVertex" + assert templates[1].id == "video_in" + assert templates[1].vertex.codecFormat == "Video" + + +def test_virtual_devices_fixture_parses(load: Callable[[str], dict[str, Any]]) -> None: + items = load("virtual_devices.json")["data"]["status"]["network"]["virtualDevices"]["_items"] + devices = [InspectApiVirtualDeviceInstance.model_validate(item) for item in items] + assert devices[0].id == "virtual.1" + assert devices[0].modules[0].vertices[0].templateId == "ip_in" + assert devices[1].modules == [] + + +def test_update_virtual_instances_create_response_parses(load: Callable[[str], dict[str, Any]]) -> None: + resp = InspectApiUpdateVirtualInstancesResponse.model_validate(load("update_virtual_instances_create.json")) + assert resp.data.addedDeviceLabels == {"virtual.1": "Virtual Device 1"} + assert resp.data.res.ok is True + assert resp.data.validation.result.ok is True + + +def test_lookup_virtual_device_fields_typed(load: Callable[[str], dict[str, Any]]) -> None: + resp = InspectApiLookupInspectDeviceResponse.model_validate(load("lookup_inspect_virtual_device.json")) + fields = resp.data.fields.virtualDeviceFields + assert fields is not None + assert fields.dynamic[0].moduleNumber == 0 + assert fields.dynamic[0].vertices[0].templateId == "generic_bidir" + assert fields.manual == [] + assert resp.data.fields.coordinates is None + + +def test_virtual_device_edit_form_round_trips_virtual_device_fields( + load: Callable[[str], dict[str, Any]], +) -> None: + """replaceDevices can round-trip a virtual device's lookup form including virtualDeviceFields.""" + raw_fields = load("lookup_inspect_virtual_device.json")["data"]["fields"] + fields = InspectApiLookupInspectDeviceFields.model_validate(raw_fields) + dumped = fields.model_dump(mode="json", by_alias=True) + assert dumped["virtualDeviceFields"] == raw_fields["virtualDeviceFields"] + assert dumped["descriptor"]["label"] == "Virtual Device 1" + + +def test_spec_to_wire_matches_ui_payload() -> None: + spec = ( + VirtualDeviceSpec.empty() + .add_port("video_in", count=2) + .add_port("video_out", count=2) + .add_module() + .add_port("ip_in") + ) + wire = spec.to_wire() + payload = wire.model_dump(mode="json", by_alias=True) + assert payload == { + "modules": [ + { + "moduleNumber": None, + "vertices": [ + {"templateId": "video_in", "count": 2}, + {"templateId": "video_out", "count": 2}, + ], + }, + { + "moduleNumber": None, + "vertices": [{"templateId": "ip_in", "count": 1}], + }, + ] + } + + +def test_spec_from_ports() -> None: + spec = VirtualDeviceSpec.from_ports("ip_in", ("ip_out", 2), PortFromTemplate(template_id="video_in", count=1)) + assert [p.template_id for p in spec.modules[0].ports] == ["ip_in", "ip_out", "video_in"] + assert spec.modules[0].ports[1].count == 2 + + +def test_port_template_domain_summary(load: Callable[[str], dict[str, Any]]) -> None: + items = load("virtual_templates.json")["data"]["status"]["network"]["virtualTemplates"]["_items"] + template = InspectPortTemplate.from_wire(InspectApiVirtualTemplateItem.model_validate(items[1])) + assert template.id == "video_in" + assert template.label == "Video in" + assert template.kind == "codecVertex" + assert template.direction == "In" + assert template.codec_format == "Video" + + +def test_ports_to_count_by_template_merges_lists() -> None: + assert _ports_to_count_by_template( + [PortFromTemplate(template_id="video_in", count=2), PortFromTemplate(template_id="video_in", count=3)] + ) == {"video_in": 5} + assert _ports_to_count_by_template({"ip_out": 1}) == {"ip_out": 1} + + +def test_create_virtual_devices_returns_inspect_devices(load: Callable[[str], dict[str, Any]]) -> None: + create = load("update_virtual_instances_create.json")["data"] + app = _App(post_data=create, skeleton_nodes=[_virtual_skeleton_node("virtual.1", "Virtual Device 1")]) + devices = app.create_virtual_devices(VirtualDeviceSpec.from_ports("generic_bidir"), copies=2) + assert len(devices) == 1 + assert isinstance(devices[0], InspectDevice) + assert devices[0].id == "virtual.1" + assert devices[0].is_virtual is True + assert devices[0].label == "Virtual Device 1" + url, payload = app._inspect_api.vip_connector.rest.post_calls[0] + assert url.endswith("/network/updateVirtualInstances") + assert len(payload["data"]["add"]) == 2 + assert payload["data"]["add"][0]["modules"][0]["vertices"] == [{"templateId": "generic_bidir", "count": 1}] + assert app._snapshot.upsert_calls == [["virtual.1"]] + + +def test_create_virtual_device_singular(load: Callable[[str], dict[str, Any]]) -> None: + app = _App( + post_data=load("update_virtual_instances_create.json")["data"], + skeleton_nodes=[_virtual_skeleton_node("virtual.1", "Virtual Device 1")], + ) + device = app.create_virtual_device(VirtualDeviceSpec.empty()) + assert device.id == "virtual.1" + assert device.is_virtual is True + + +def test_create_virtual_device_raises_on_failure() -> None: + app = _App( + post_data={ + "addedDeviceLabels": {}, + "res": {"msg": ["boom"], "ok": False}, + "validation": {"createIds": [], "details": {}, "result": {"msg": [], "ok": True}}, + }, + skeleton_nodes=[], + ) + with pytest.raises(InspectError): + app.create_virtual_device(VirtualDeviceSpec.empty()) + + +def test_remove_device_from_topology_uses_update_topology_for_virtual( + load: Callable[[str], dict[str, Any]], +) -> None: + """Virtual device removal uses the normal updateTopology path (verified 2025.4.9).""" + api = _FakeWriteAPI(load("update_topology_success.json")) + app = _WriteApp(api) + result = app.remove_device_from_topology("virtual.1") + assert result.ok is True + assert len(api.update_calls) == 1 + assert api.update_calls[0].remove == ["virtual.1"] + assert api.virtual_instance_calls == [] + + +def test_transaction_remove_virtual_and_physical_uses_update_topology( + load: Callable[[str], dict[str, Any]], +) -> None: + api = _FakeWriteAPI(load("update_topology_success.json")) + app = _WriteApp(api) + with app.transaction() as tx: + tx.remove_device("virtual.1") + tx.remove_device("device5") + result = tx.commit(check_conflicts=False) + assert result.ok is True + assert len(api.update_calls) == 1 + assert set(api.update_calls[0].remove) == {"virtual.1", "device5"} + assert api.virtual_instance_calls == [] + + +def test_add_virtual_ports() -> None: + app = _App(post_data={"msg": [], "ok": True}, skeleton_nodes=[]) + assert app.add_virtual_ports("virtual.1", 0, {"ip_out": 1}) is True + url, payload = app._inspect_api.vip_connector.rest.post_calls[0] + assert url.endswith("/network/addVirtualTopology") + assert payload["data"] == { + "deviceId": "virtual.1", + "moduleId": 0, + "countByVertexTemplate": {"ip_out": 1}, + } + + +def test_port_template_crud_payloads() -> None: + app = _App(post_data={"msg": [], "ok": True}, skeleton_nodes=[]) + assert app.create_port_template("example_tpl", "Example", {"type": "genericVertex", "vertexType": "In"}) is True + assert app.delete_port_templates(["example_tpl"]) is True + add_payload = app._inspect_api.vip_connector.rest.post_calls[0][1]["data"] + assert add_payload["add"]["example_tpl"]["label"] == "Example" + remove_payload = app._inspect_api.vip_connector.rest.post_calls[1][1]["data"] + assert remove_payload["remove"] == ["example_tpl"] + + +def test_list_port_templates(load: Callable[[str], dict[str, Any]]) -> None: + app = _App( + get_data=load("virtual_templates.json")["data"], + post_data={"msg": [], "ok": True}, + skeleton_nodes=[], + ) + templates = app.list_port_templates() + assert [t.id for t in templates] == ["generic_bidir", "video_in"] + + +def test_invalid_inputs_rejected() -> None: + app = _App(post_data={"msg": [], "ok": True}, skeleton_nodes=[]) + with pytest.raises(ValueError): + app.create_virtual_devices(VirtualDeviceSpec.empty(), copies=0) + with pytest.raises(ValueError): + app.create_port_template("", "x", {}) + with pytest.raises(ValueError): + PortFromTemplate(template_id="x", count=0) + + +def test_api_virtual_reads_and_writes(load: Callable[[str], dict[str, Any]]) -> None: + templates_data = load("virtual_templates.json")["data"] + rest = _FakeRest(get_data=templates_data, post_data=load("update_virtual_instances_create.json")["data"]) + api = InspectAPI(SimpleNamespace(rest=rest)) + templates = api.get_virtual_templates() + assert templates[0].id == "generic_bidir" + assert rest.get_calls[0][0].endswith("/virtualTemplates/**") + assert rest.get_calls[0][1] is True + + rest._get_data = load("virtual_devices.json")["data"] + devices = api.get_virtual_devices() + assert devices[0].id == "virtual.1" + assert rest.get_calls[1][0].endswith("/virtualDevices/**") + + resp = api.update_virtual_instances( + InspectApiUpdateVirtualInstancesData(add=[VirtualDeviceSpec.from_ports("generic_bidir").to_wire()]) + ) + assert resp.data.addedDeviceLabels["virtual.1"] == "Virtual Device 1" + assert rest.post_calls[0][0].endswith("/updateVirtualInstances") + + rest._post_data = {"msg": [], "ok": True} + api.update_virtual_templates(InspectApiUpdateVirtualTemplatesData()) + api.add_virtual_topology( + InspectApiAddVirtualTopologyData(deviceId="virtual.1", moduleId=0, countByVertexTemplate={"ip_out": 1}) + ) + assert rest.post_calls[1][0].endswith("/updateVirtualTemplates") + assert rest.post_calls[2][0].endswith("/addVirtualTopology") + + +def test_upsert_devices_from_skeleton_indexes_virtual_nodes() -> None: + nodes = [_virtual_skeleton_node("virtual.1", "Virtual Device 1")] + api = InspectAPI(SimpleNamespace(rest=_FakeRest(get_data={}, post_data={}))) + api.get_device_skeleton = lambda: list(nodes) # type: ignore[method-assign] + snap = InspectSnapshot(fetcher=api, device_items=[], edge_items=[]) + snap.upsert_devices_from_skeleton(["virtual.1"]) + device = snap.get_device("virtual.1") + assert device is not None + assert device.is_virtual is True + assert device.label == "Virtual Device 1" + + +# --- Internal --- + + +def _virtual_skeleton_node(device_id: str, label: str) -> InspectApiNodeStatusItem: + dash = device_id.replace(".", "-") + return InspectApiNodeStatusItem.model_validate( + { + "_id": dash, + "_vid": dash, + "descriptor": {"label": label}, + "deviceId": device_id, + "resourceId": f"device:{dash}", + } + ) + + +class _FakeRest: + def __init__( + self, + get_data: dict[str, Any] | None = None, + post_data: dict[str, Any] | None = None, + skeleton_nodes: list[InspectApiNodeStatusItem] | None = None, + ) -> None: + self._get_data = get_data or {} + self._post_data = post_data or {} + self._skeleton_nodes = skeleton_nodes or [] + self.get_calls: list[tuple[str, bool]] = [] + self.post_calls: list[tuple[str, dict[str, Any]]] = [] + + def get(self, url_path: str, allow_projection: bool = False, **kwargs: Any) -> SimpleNamespace: + self.get_calls.append((url_path, allow_projection)) + return SimpleNamespace(data=self._get_data, header=_ok_header()) + + def post(self, url_path: str, body: Any, **kwargs: Any) -> SimpleNamespace: + payload = body.model_dump(mode="json", by_alias=True) + self.post_calls.append((url_path, payload)) + return SimpleNamespace(data=self._post_data, header=_ok_header()) + + +class _RecordingSnapshot: + def __init__(self, devices: dict[str, InspectDevice] | None = None) -> None: + self.network_refresh_calls: list[list[str]] = [] + self.upsert_calls: list[list[str]] = [] + self._devices = devices or {} + + def apply_network_refresh(self, device_ids: list[str]) -> None: + self.network_refresh_calls.append(list(device_ids)) + + def upsert_devices_from_skeleton(self, device_ids: list[str]) -> None: + self.upsert_calls.append(list(device_ids)) + + def get_device(self, device_id: str) -> InspectDevice | None: + return self._devices.get(device_id) + + def apply_post_commit(self, **kwargs: Any) -> None: + return None + + +class _App(InspectActionsMixin): + def __init__( + self, + post_data: dict[str, Any], + get_data: dict[str, Any] | None = None, + skeleton_nodes: list[InspectApiNodeStatusItem] | None = None, + snapshot: _RecordingSnapshot | None = None, + ) -> None: + self._logger = logging.getLogger("test") + rest = _FakeRest(get_data=get_data, post_data=post_data, skeleton_nodes=skeleton_nodes) + self._inspect_api = InspectAPI(SimpleNamespace(rest=rest)) + # Bind skeleton fetch onto the API for recording-snapshot tests that call real upsert. + self._inspect_api.get_device_skeleton = lambda: list(skeleton_nodes or []) # type: ignore[method-assign] + if snapshot is not None: + self._snapshot = snapshot + else: + devices: dict[str, InspectDevice] = {} + recording = _RecordingSnapshot(devices=devices) + # Populate devices after upsert by wrapping upsert to create InspectDevice stubs. + real_snap = InspectSnapshot(fetcher=self._inspect_api, device_items=[], edge_items=[]) + + def _upsert(device_ids: list[str]) -> None: + recording.upsert_calls.append(list(device_ids)) + real_snap.upsert_devices_from_skeleton(device_ids) + for device_id in device_ids: + device = real_snap.get_device(device_id) + if device is not None: + devices[device_id] = device + + recording.upsert_devices_from_skeleton = _upsert # type: ignore[method-assign] + recording.get_device = devices.get # type: ignore[method-assign] + self._snapshot = recording + + def _get_snapshot(self) -> Any: + return self._snapshot + + +class _FakeWriteAPI: + def __init__(self, topology_response: dict[str, Any]) -> None: + self.update_response = InspectApiUpdateTopologyResponse.model_validate(topology_response) + self.update_calls: list[Any] = [] + self.virtual_instance_calls: list[Any] = [] + + def update_topology(self, delta: Any) -> InspectApiUpdateTopologyResponse: + self.update_calls.append(delta) + return self.update_response + + def update_virtual_instances(self, data: Any) -> Any: + self.virtual_instance_calls.append(data) + raise AssertionError("updateVirtualInstances must not be used for virtual device removal") + + +class _WriteApp(InspectWriteMixin): + def __init__(self, api: _FakeWriteAPI) -> None: + self._logger = logging.getLogger("test") + self._inspect_api = api # type: ignore[assignment] + self._snapshot = _RecordingSnapshot() + + +def _ok_header() -> SimpleNamespace: + return SimpleNamespace( + model_dump=lambda mode="json": { + "auth": True, + "caption": "OK", + "code": "OK", + "errorCodes": [], + "errorDetails": [], + "id": "0", + "msg": [], + "ok": True, + "user": "api-user", + }, + auth=True, + caption="OK", + code="OK", + errorCodes=[], + errorDetails=[], + id="0", + msg=[], + ok=True, + user="api-user", + ) diff --git a/tests/topology/__init__.py b/tests/topology/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/topology/test_version_compat.py b/tests/topology/test_version_compat.py new file mode 100644 index 0000000..4abb7df --- /dev/null +++ b/tests/topology/test_version_compat.py @@ -0,0 +1,44 @@ +"""TopologyApp VideoIPath version compatibility gate.""" + +from __future__ import annotations + +import warnings +from types import SimpleNamespace + +import pytest + +from videoipath_automation_tool.apps.topology.errors import TopologyUnsupportedError +from videoipath_automation_tool.apps.topology.topology_app import TopologyApp + + +def _fake_connector(version: str) -> SimpleNamespace: + return SimpleNamespace(videoipath_version=version) + + +def test_topology_app_supported_on_2024() -> None: + with warnings.catch_warnings(record=True) as record: + warnings.simplefilter("always", DeprecationWarning) + app = TopologyApp(vip_connector=_fake_connector("2024.4.30")) # type: ignore[arg-type] + assert app is not None + assert not any(issubclass(w.category, DeprecationWarning) for w in record) + + +def test_topology_app_deprecated_on_2025() -> None: + with pytest.warns(DeprecationWarning, match="deprecated on VideoIPath 2025") as record: + app = TopologyApp(vip_connector=_fake_connector("2025.4.9")) # type: ignore[arg-type] + assert app is not None + assert len(record) == 1 + assert "InspectApp" in str(record[0].message) + + +def test_topology_app_unsupported_on_2026() -> None: + with pytest.raises(TopologyUnsupportedError, match="not supported on VideoIPath 2026.1.0"): + TopologyApp(vip_connector=_fake_connector("2026.1.0")) # type: ignore[arg-type] + + +def test_topology_app_skips_gate_for_unparseable_version() -> None: + with warnings.catch_warnings(record=True) as record: + warnings.simplefilter("always", DeprecationWarning) + app = TopologyApp(vip_connector=_fake_connector("unknown")) # type: ignore[arg-type] + assert app is not None + assert not any(issubclass(w.category, DeprecationWarning) for w in record) diff --git a/tests/validators/test_virtual_device_id.py b/tests/validators/test_virtual_device_id.py index 8918aec..b7760a5 100644 --- a/tests/validators/test_virtual_device_id.py +++ b/tests/validators/test_virtual_device_id.py @@ -1,6 +1,16 @@ import pytest -from videoipath_automation_tool.validators.virtual_device_id import validate_virtual_device_id +from videoipath_automation_tool.validators.virtual_device_id import is_virtual_device_id, validate_virtual_device_id + + +class TestIsVirtualDeviceId: + @pytest.mark.parametrize("device_id", ["virtual.0", "virtual.1", "virtual.123"]) + def test_true_for_virtual_ids(self, device_id: str): + assert is_virtual_device_id(device_id) is True + + @pytest.mark.parametrize("device_id", ["device5", "virtual.1.0.out", "virtual-1", "", None, 1]) + def test_false_for_non_virtual_ids(self, device_id: str): + assert is_virtual_device_id(device_id) is False class TestValidateVirtualDeviceId: